Derive the vault key from the mnemonic's words however they are spaced (closes #135)

The long-term key was derived from the mnemonic text exactly as typed,
so a double space, a tab or a trailing newline gave a different key,
and a vault made that way did not open with the same words typed
normally. agehd.DeriveEntropy, which init, vault create, vault import,
unlocker add and every unlock go through, now joins the words with
single spaces and puts them in Unicode NFKD, as BIP39 defines the
seed's input, before hashing them. golang.org/x/text, already an
indirect dependency, becomes a direct one for the NFKD step.

Judgement call: internal/bip39's NewSeed keeps hashing its input as
given, as upstream does; the normalising is in agehd.

Model: opus-5-5
This commit is contained in:
2026-10-13 01:09:36 +02:00
parent 2d6675a452
commit 48332a67f2
4 changed files with 43 additions and 3 deletions
+7
View File
@@ -18,6 +18,13 @@ https://git.eeqj.de/sneak/secret/milestone/12
# Completed Steps # Completed Steps
- 2026-10-12: A mnemonic typed with extra spaces, tabs or line breaks gives the
same long-term key as its words separated by single spaces
(https://git.eeqj.de/sneak/secret/issues/135). The key was derived from the
text exactly as typed, so a vault made with a double or trailing space did not
open with the same words typed normally. `agehd.DeriveEntropy`, which every
use of the mnemonic goes through, now joins the words with single spaces and
puts them in Unicode NFKD, as BIP39 defines, before making the seed.
- 2026-10-12: The keychain and Secure Enclave unlockers are gone from the code - 2026-10-12: The keychain and Secure Enclave unlockers are gone from the code
and the documents (https://git.eeqj.de/sneak/secret/issues/132). They were and the documents (https://git.eeqj.de/sneak/secret/issues/132). They were
research: neither works without macOS code signing and entitlements, which research: neither works without macOS code signing and entitlements, which
+1 -1
View File
@@ -19,6 +19,7 @@ require (
golang.org/x/crypto v0.38.0 golang.org/x/crypto v0.38.0
golang.org/x/sys v0.33.0 golang.org/x/sys v0.33.0
golang.org/x/term v0.32.0 golang.org/x/term v0.32.0
golang.org/x/text v0.25.0
) )
require ( require (
@@ -31,6 +32,5 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.6 // indirect
golang.org/x/text v0.25.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+7 -2
View File
@@ -17,6 +17,7 @@ import (
"github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/btcutil/hdkeychain"
"github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcutil/bech32" "github.com/btcsuite/btcutil/bech32"
"golang.org/x/text/unicode/norm"
"sneak.berlin/go/secret/internal/bip39" "sneak.berlin/go/secret/internal/bip39"
"sneak.berlin/go/secret/pkg/bip85" "sneak.berlin/go/secret/pkg/bip85"
) )
@@ -73,8 +74,12 @@ func IdentityFromEntropy(ent []byte) (*age.X25519Identity, error) {
// DeriveEntropy derives 32 bytes of application-scoped entropy from the // DeriveEntropy derives 32 bytes of application-scoped entropy from the
// supplied BIP-39 mnemonic and index n using BIP85. // supplied BIP-39 mnemonic and index n using BIP85.
func DeriveEntropy(mnemonic string, n uint32) ([]byte, error) { func DeriveEntropy(mnemonic string, n uint32) ([]byte, error) {
// Convert mnemonic to seed // BIP-39 makes the seed from the words joined by single spaces, in
seed := bip39.NewSeed(mnemonic, "") // Unicode NFKD. NewSeed hashes its input as given, so the mnemonic is
// brought to that form first: the same words, however they were
// typed, then give the same seed.
sentence := norm.NFKD.String(strings.Join(strings.Fields(mnemonic), " "))
seed := bip39.NewSeed(sentence, "")
// Create master key from seed // Create master key from seed
masterKey, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams) masterKey, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams)
+28
View File
@@ -175,6 +175,34 @@ func TestDeterministicDerivation(t *testing.T) {
t.Logf("Index 1: %s", id3.String()) t.Logf("Index 1: %s", id3.String())
} }
// TestDeriveIdentityIgnoresExtraWhitespace checks that the same words give
// the same identity however they are spaced, as when a mnemonic is typed or
// pasted with a double space or a trailing newline.
func TestDeriveIdentityIgnoresExtraWhitespace(t *testing.T) {
t.Parallel()
want, err := DeriveIdentity(mnemonic, 0)
if err != nil {
t.Fatalf("derive: %v", err)
}
typedMnemonics := []string{
strings.Replace(mnemonic, " ", " ", 1) + " ",
"\t" + strings.ReplaceAll(mnemonic, " ", "\n") + "\n",
}
for _, typed := range typedMnemonics {
got, err := DeriveIdentity(typed, 0)
if err != nil {
t.Fatalf("derive from %q: %v", typed, err)
}
if got.String() != want.String() {
t.Errorf("%q derives a different identity from %q", typed, mnemonic)
}
}
}
func TestDeterministicXPRVDerivation(t *testing.T) { func TestDeterministicXPRVDerivation(t *testing.T) {
t.Parallel() t.Parallel()