diff --git a/TODO.md b/TODO.md index 5964487..54fcc47 100644 --- a/TODO.md +++ b/TODO.md @@ -18,6 +18,13 @@ https://git.eeqj.de/sneak/secret/milestone/12 # 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 and the documents (https://git.eeqj.de/sneak/secret/issues/132). They were research: neither works without macOS code signing and entitlements, which diff --git a/go.mod b/go.mod index fc3d368..cdc8686 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( golang.org/x/crypto v0.38.0 golang.org/x/sys v0.33.0 golang.org/x/term v0.32.0 + golang.org/x/text v0.25.0 ) require ( @@ -31,6 +32,5 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/pmezard/go-difflib v1.0.0 // 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 ) diff --git a/pkg/agehd/agehd.go b/pkg/agehd/agehd.go index 863ea82..bf64473 100644 --- a/pkg/agehd/agehd.go +++ b/pkg/agehd/agehd.go @@ -17,6 +17,7 @@ import ( "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcutil/bech32" + "golang.org/x/text/unicode/norm" "sneak.berlin/go/secret/internal/bip39" "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 // supplied BIP-39 mnemonic and index n using BIP85. func DeriveEntropy(mnemonic string, n uint32) ([]byte, error) { - // Convert mnemonic to seed - seed := bip39.NewSeed(mnemonic, "") + // BIP-39 makes the seed from the words joined by single spaces, in + // 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 masterKey, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams) diff --git a/pkg/agehd/agehd_test.go b/pkg/agehd/agehd_test.go index 7573933..e50ca3f 100644 --- a/pkg/agehd/agehd_test.go +++ b/pkg/agehd/agehd_test.go @@ -175,6 +175,34 @@ func TestDeterministicDerivation(t *testing.T) { 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) { t.Parallel()