From 2213b8c3fd7e20dac09b82ae3fba383d8993edd6 Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 1 Oct 2026 18:47:24 +0200 Subject: [PATCH] Key of words v2: salted with capsule_id, and the checks of the writer As the spec v0.11 draft decides after the review (38.1): - the salt is "DateKeys llave de palabras v2|chain|round|capsule_id", so the same words give another key in each capsule and a dictionary cannot attack together the many capsules of a popular round; - the words are lowered with the table of Unicode 18.0.0 of pathrule; - wordkey.Check refuses controls, Default_Ignorable code points and unassigned ones, and counts toward the six words only the different ones of three letters or more. EncryptOptions.Words takes the words: the writer derives their identity once it has drawn capsule_id, and adds its recipient to the credentials. The CLI passes them to the writer, and decrypt salts them with the capsule_id of the capsule. New vector of 38.1. Co-Authored-By: Claude Opus 5.5 --- capsule/encrypt.go | 32 ++++++-- capsule/encrypt3_test.go | 37 +++++++++ cmd/datekeys/main.go | 21 ++--- cmd/datekeys/main_test.go | 4 +- spec/DateKeys_Protocol_Specification_v0.11.md | 2 +- wordkey/wordkey.go | 80 +++++++++++++------ wordkey/wordkey_test.go | 59 +++++++++++--- 7 files changed, 178 insertions(+), 57 deletions(-) diff --git a/capsule/encrypt.go b/capsule/encrypt.go index f26dc15..97a43d7 100644 --- a/capsule/encrypt.go +++ b/capsule/encrypt.go @@ -18,6 +18,7 @@ import ( "g.activething.com/go/DateKeys/datekey" "g.activething.com/go/DateKeys/extension" "g.activething.com/go/DateKeys/profile" + "g.activething.com/go/DateKeys/wordkey" ) // EncryptOptions configures EncryptFiles and Encrypt. @@ -39,6 +40,11 @@ type EncryptOptions struct { // existing one is accepted. The recipients and the portable key are the // credentials of the capsule: from 1 to 16 (spec §39). NewPortableKey bool + // Words are the words of a key of words, as wordkey.Normalize returns + // them and wordkey.Check accepts them, for time_and_key (spec §38.1). + // The writer derives their identity once it has drawn capsule_id, which + // salts it, and adds its recipient to the credentials. Nil for none. + Words []string // Length is L for Encrypt, the exact number of bytes src delivers, at // most MaxPayloadLength. It is sealed in the control before the payload // is written, so it must be known in advance: a source of unknown length @@ -221,9 +227,19 @@ func (s *sealer) write(dst io.Writer, f Format, length uint64, body func(w io.Wr // Step 7 of spec §62: the 16 recipients of INNER_ACCESS_AGE, the // credentials and a dummy in each slot left, in a random order. + // The key of words is salted with capsule_id (spec §38.1), so its + // recipient joins the credentials only now. + credentials := s.credentials + if len(opts.Words) != 0 { + id, err := wordkey.Identity(opts.Words, opts.Profile.ChainHash[:], s.dk.Round, capsuleID[:]) + if err != nil { + return nil, err + } + credentials = append(append([]age.Recipient(nil), credentials...), id.Recipient()) + } var access []age.Recipient if opts.Policy == TimeAndKey { - if access, err = fillSlots(s.credentials); err != nil { + if access, err = fillSlots(credentials); err != nil { return nil, err } } @@ -508,8 +524,8 @@ func selfCheckPayload(p *payloadWriter, payloadRaw []byte, padded uint64) error func accessRecipients(opts EncryptOptions) ([]age.Recipient, *age.X25519Identity, error) { switch opts.Policy { case TimeOnly: - if len(opts.Recipients) != 0 || opts.NewPortableKey { - return nil, nil, errors.New("capsule: time_only takes no recipients and no portable key") + if len(opts.Recipients) != 0 || opts.NewPortableKey || len(opts.Words) != 0 { + return nil, nil, errors.New("capsule: time_only takes no recipients, no portable key and no key of words") } return nil, nil, nil case TimeAndKey: @@ -520,11 +536,17 @@ func accessRecipients(opts EncryptOptions) ([]age.Recipient, *age.X25519Identity if opts.NewPortableKey { n++ } + if len(opts.Words) != 0 { + if err := wordkey.Check(opts.Words); err != nil { + return nil, nil, fmt.Errorf("capsule: %w", err) + } + n++ + } if n == 0 { - return nil, nil, errors.New("capsule: time_and_key needs at least one recipient or a portable key") + return nil, nil, errors.New("capsule: time_and_key needs at least one recipient, a portable key or a key of words") } if n > agewrap.AccessSlots { - return nil, nil, fmt.Errorf("capsule: time_and_key takes at most %d credentials, recipients and portable key together; %d given", agewrap.AccessSlots, n) + return nil, nil, fmt.Errorf("capsule: time_and_key takes at most %d credentials, recipients, portable key and key of words together; %d given", agewrap.AccessSlots, n) } var out []age.Recipient seen := make(map[string]bool) diff --git a/capsule/encrypt3_test.go b/capsule/encrypt3_test.go index c9414a8..3c66c3c 100644 --- a/capsule/encrypt3_test.go +++ b/capsule/encrypt3_test.go @@ -2,6 +2,7 @@ package capsule_test import ( "bytes" + "encoding/hex" "errors" "io" "reflect" @@ -16,6 +17,8 @@ import ( "g.activething.com/go/DateKeys/capsule" "g.activething.com/go/DateKeys/extension" "g.activething.com/go/DateKeys/internal/testkit" + "g.activething.com/go/DateKeys/profile" + "g.activething.com/go/DateKeys/wordkey" ) // The tests of this file cover the writer of format 3 (spec v0.10, §61, @@ -169,6 +172,34 @@ func TestEncryptFilesTimeAndKey(t *testing.T) { } } +// Spec §38.1: a key of words is salted with the capsule_id that EncryptFiles +// draws, and its identity opens the capsule. +func TestEncryptFilesWords(t *testing.T) { + opts := files3(t) + words := wordkey.Normalize("Perro luna casa verde trén mar") + opts.Policy, opts.Words = capsule.TimeAndKey, words + var dkc bytes.Buffer + res, err := capsule.EncryptFiles(&dkc, []capsule.Source{source("a.txt", "secreto")}, opts) + if err != nil { + t.Fatal(err) + } + chain, _ := hex.DecodeString(profile.QuicknetChainHash) + id, err := wordkey.Identity(words, chain, 1000, res.CapsuleID[:]) + if err != nil { + t.Fatal(err) + } + if r := open3(t, dkc.Bytes(), &testkit.MemorySink{}, id); r.err != nil || string(r.sink.Files[0]) != "secreto" { + t.Errorf("with the words: %v", r.err) + } + other := res.CapsuleID + other[0] ^= 1 + if wrong, err := wordkey.Identity(words, chain, 1000, other[:]); err != nil { + t.Fatal(err) + } else if r := open3(t, dkc.Bytes(), &testkit.MemorySink{}, wrong); r.err == nil { + t.Error("the words of another capsule_id opened it") + } +} + // Spec §62.1 rules 3, 14, 15 and 18: EncryptFiles rejects what the reader // would reject, and a file whose size is not its Size, before it writes // anything, with a message that names the rule and the character. @@ -216,6 +247,12 @@ func TestEncryptFilesRejects(t *testing.T) { id, _ := age.GenerateX25519Identity() o.Recipients = []age.Recipient{id.Recipient()} }, "time_only takes no recipients"}, + {"time_only with words", []capsule.Source{source("a", "")}, func(o *capsule.EncryptOptions) { + o.Words = wordkey.Normalize("perro luna casa verde tren mar") + }, "no key of words"}, + {"too few words", []capsule.Source{source("a", "")}, func(o *capsule.EncryptOptions) { + o.Policy, o.Words = capsule.TimeAndKey, wordkey.Normalize("perro luna casa") + }, "at least 6 different words"}, {"an instant in the past", []capsule.Source{source("a", "")}, func(o *capsule.EncryptOptions) { o.Now = time.Now }, "is not in the future"}, } { opts := files3(t) diff --git a/cmd/datekeys/main.go b/cmd/datekeys/main.go index c6b8534..c2205a9 100644 --- a/cmd/datekeys/main.go +++ b/cmd/datekeys/main.go @@ -60,7 +60,8 @@ left (spec §29, §39). decrypt writes the files of a format 3 capsule to the new folder PATH, and the content of formats 1 and 2 to the new file PATH. -words and -words-file give a key of words to a time_and_key capsule: at -least 6 words of your own that open it with decrypt, instead of a .dkk +least 6 different words of 3 or more letters that open it with decrypt, +instead of a .dkk (wordkey). Case, accents and extra spaces do not matter. -words leaves them in the shell history; -words-file reads them from a file.` @@ -201,8 +202,8 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error { } opts.Recipients = append(opts.Recipients, x) } - // The key of words is one more recipient, derived for the round of the - // unlock time, as the writer resolves it. + // The key of words is one more credential: the writer derives it once it + // has drawn capsule_id, which salts it (spec §38.1). text, err := wordsText("encrypt", *words, *wordsFile) if err != nil { return err @@ -212,18 +213,10 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error { return errors.New("encrypt: -words and -words-file need -policy time_and_key") } w := wordkey.Normalize(text) - if len(w) < wordkey.MinWords { - return fmt.Errorf("encrypt: a key of words needs at least %d words, not %d", wordkey.MinWords, len(w)) - } - dk, err := datekey.Resolve(opts.Profile, unlock) - if err != nil { - return fmt.Errorf("encrypt: %w", err) - } - id, err := wordkey.Identity(w, opts.Profile.ChainHash[:], dk.Round) - if err != nil { + if err := wordkey.Check(w); err != nil { return fmt.Errorf("encrypt: %w", err) } - opts.Recipients = append(opts.Recipients, id.Recipient()) + opts.Words = w } if *dkk != "" { if err := checkNew(*dkk); err != nil { @@ -315,7 +308,7 @@ func decrypt(args []string, stdout, stderr io.Writer, now func() time.Time) erro // The words are salted with the chain and the round of the capsule: // steps 1 to 8 give them. When they fail, Open reports why. if insp, err := capsule.Inspect(src, capsule.InspectOptions{Registry: reg}); err == nil { - id, err := wordkey.Identity(wordkey.Normalize(text), insp.Profile.ChainHash[:], insp.Header.DateKey.Round) + id, err := wordkey.Identity(wordkey.Normalize(text), insp.Profile.ChainHash[:], insp.Header.DateKey.Round, insp.Header.CapsuleID[:]) if err != nil { return fmt.Errorf("decrypt: %w", err) } diff --git a/cmd/datekeys/main_test.go b/cmd/datekeys/main_test.go index 753fe10..86e7f72 100644 --- a/cmd/datekeys/main_test.go +++ b/cmd/datekeys/main_test.go @@ -588,7 +588,9 @@ func TestKeyOfWords(t *testing.T) { args []string want string }{ - {[]string{"-policy", "time_and_key", "-words", "uno dos tres"}, "at least 6 words, not 3"}, + {[]string{"-policy", "time_and_key", "-words", "uno dos tres"}, "at least 6 different words of 3 or more letters, not 3"}, + {[]string{"-policy", "time_and_key", "-words", "de la casa al mar en tren verde"}, "not 4"}, + {[]string{"-policy", "time_and_key", "-words", "perro luna casa verde tren mar" + string(rune(0x200B))}, "invisible character U+200B"}, {[]string{"-words", "uno dos tres cuatro cinco seis"}, "need -policy time_and_key"}, {[]string{"-policy", "time_and_key", "-words", "a", "-words-file", words}, "are exclusive"}, } { diff --git a/spec/DateKeys_Protocol_Specification_v0.11.md b/spec/DateKeys_Protocol_Specification_v0.11.md index de8c113..5091b77 100644 --- a/spec/DateKeys_Protocol_Specification_v0.11.md +++ b/spec/DateKeys_Protocol_Specification_v0.11.md @@ -1885,7 +1885,7 @@ id = PBKDF2-HMAC-SHA256(P, S, 600000 iteraciones, 32 bytes) ; RFC 8018 Y SHOULD: -- recomendar más palabras, o unas al azar, y rechazar las repetidas o de menos de 3 caracteres; +- contar para ese mínimo solo las palabras distintas de 3 caracteres o más, que es lo que hace el SDK oficial, y recomendar más, o unas al azar; - mostrar las palabras normalizadas y pedir que se escriban de nuevo; - avisar de que no se reutilice una contraseña: tras la fecha, la cápsula sirve para probarla. diff --git a/wordkey/wordkey.go b/wordkey/wordkey.go index ded5bc3..ead24a9 100644 --- a/wordkey/wordkey.go +++ b/wordkey/wordkey.go @@ -1,16 +1,16 @@ -// Package wordkey derives the X25519 identity of a key of words: words a -// person chooses, at least MinWords, that open a time_and_key capsule -// instead of a .dkk file or an age identity of their own. The words are -// normalized so that case, accents and extra spaces do not matter, and -// stretched with PBKDF2-HMAC-SHA256, Rounds rounds, salted with the chain -// hash and the round of the capsule, so that each date needs its own -// attack. The identity is an ordinary X25519 recipient of the capsule: the +// Package wordkey derives the X25519 identity of a key of words (spec +// §38.1): words a person chooses that open a time_and_key capsule instead of +// a .dkk file or an age identity of their own. The words are normalized with +// the Unicode 18.0.0 tables of pathrule, so that case, accents and extra +// spaces do not matter, and stretched with PBKDF2-HMAC-SHA256, Rounds rounds, +// salted with the chain hash, the round and the capsule_id of the capsule, so +// that each capsule needs its own attack, even among the many of a popular +// round. The identity is an ordinary X25519 recipient of the capsule: the // format does not change. // -// It is the derivation of wordkey.ts in datekeys-ts, byte for byte, as -// docs/spec_v0.11/llave_palabras.md describes it. Once the date has come, -// whoever holds the .dkc can try words offline: words of the person's own -// are weaker than random ones. +// It is the derivation of wordkey.ts in datekeys-ts, byte for byte. Once the +// date has come, whoever holds the .dkc can try words offline: words of the +// person's own are weaker than random ones. package wordkey import ( @@ -19,45 +19,77 @@ import ( "fmt" "strings" "unicode" + "unicode/utf8" "filippo.io/age" - "g.activething.com/go/DateKeys/codec/bech32" "g.activething.com/go/DateKeys/internal/pathrule" ) const ( - // MinWords is the fewest words a writer accepts. + // MinWords is the fewest different words of MinLetters characters or + // more that a writer accepts. MinWords = 6 + // MinLetters is the fewest characters of a word that counts toward + // MinWords. Shorter words may be part of the key, but do not count. + MinLetters = 3 // Rounds of PBKDF2-HMAC-SHA256, OWASP's figure for 2023. Rounds = 600_000 ) -// Normalize returns the words of text: its NFD, by the Unicode tables of -// pathrule, without the combining marks U+0300 to U+036F, each code point -// in lower case by its simple mapping, split at white space as -// unicode.IsSpace defines it. +// Normalize returns the words of text: its NFD, by the tables of pathrule, +// without the combining marks U+0300 to U+036F, each code point in lower case +// by its simple mapping of Unicode 18.0.0, split at white space as +// unicode.IsSpace defines it, which is the list of spec §38.1. func Normalize(text string) []string { var b strings.Builder for _, r := range pathrule.NFD(text) { if r >= 0x300 && r <= 0x36f { continue } - b.WriteRune(unicode.ToLower(r)) + b.WriteRune(pathrule.Lower(r)) } return strings.Fields(b.String()) } -// Key returns the raw X25519 identity of words for a capsule of the round -// of the chain whose hash is chainHash. The caller clears it. -func Key(words []string, chainHash []byte, round uint64) ([]byte, error) { - salt := fmt.Sprintf("DateKeys llave de palabras v1|%x|%d", chainHash, round) +// Check reports why a writer refuses words, as Normalize returns them, for a +// key (spec §38.1): fewer than MinWords different words of MinLetters +// characters or more, or a control, a Default_Ignorable_Code_Point or a code +// point unassigned in Unicode 18.0.0, which a person cannot see and would not +// type again. +func Check(words []string) error { + counted := make(map[string]bool) + for _, w := range words { + for _, r := range w { + switch { + case unicode.IsControl(r): + return fmt.Errorf("wordkey: the words hold the control character U+%04X", r) + case pathrule.DefaultIgnorable(r): + return fmt.Errorf("wordkey: the words hold the invisible character U+%04X", r) + case !pathrule.Assigned(r): + return fmt.Errorf("wordkey: the words hold U+%04X, unassigned in Unicode %s", r, pathrule.UnicodeVersion) + } + } + if utf8.RuneCountInString(w) >= MinLetters { + counted[w] = true + } + } + if len(counted) < MinWords { + return fmt.Errorf("wordkey: a key of words needs at least %d different words of %d or more letters, not %d", MinWords, MinLetters, len(counted)) + } + return nil +} + +// Key returns the raw X25519 identity of words for the capsule capsuleID, of +// the round of the chain whose hash is chainHash. The caller clears it. +func Key(words []string, chainHash []byte, round uint64, capsuleID []byte) ([]byte, error) { + salt := fmt.Sprintf("DateKeys llave de palabras v2|%x|%d|%x", chainHash, round, capsuleID) return pbkdf2.Key(sha256.New, strings.Join(words, " "), []byte(salt), Rounds, 32) } // Identity returns the age X25519 identity of words, as Key derives it. -func Identity(words []string, chainHash []byte, round uint64) (*age.X25519Identity, error) { - raw, err := Key(words, chainHash, round) +func Identity(words []string, chainHash []byte, round uint64, capsuleID []byte) (*age.X25519Identity, error) { + raw, err := Key(words, chainHash, round, capsuleID) if err != nil { return nil, err } diff --git a/wordkey/wordkey_test.go b/wordkey/wordkey_test.go index 1895358..9dc9722 100644 --- a/wordkey/wordkey_test.go +++ b/wordkey/wordkey_test.go @@ -3,6 +3,7 @@ package wordkey import ( "encoding/hex" "slices" + "strings" "testing" "g.activething.com/go/DateKeys/profile" @@ -18,28 +19,62 @@ func TestNormalize(t *testing.T) { if got := Normalize(" "); len(got) != 0 { t.Fatalf("Normalize of spaces = %q", got) } + // The lower case of Unicode 18.0.0, not that of the runtime: U+A7CB, + // added in Unicode 16.0, lowers to U+0264, which Go 1.26 does not know. + if got := Normalize(string(rune(0xA7CB))); !slices.Equal(got, []string{string(rune(0x0264))}) { + t.Fatalf("Normalize of U+A7CB = %q", got) + } } -// The vector that wordkey.test.ts of datekeys-ts checks too. -func TestKeyMatchesTypeScript(t *testing.T) { - chain, _ := hex.DecodeString(profile.QuicknetChainHash) - words := []string{"perro", "luna", "casa", "verde", "tren", "mar"} - raw, err := Key(words, chain, 1000) - if err != nil { +func TestCheck(t *testing.T) { + if err := Check(Normalize("Perro LUNA casa verde trén mar")); err != nil { t.Fatal(err) } - if got := hex.EncodeToString(raw); got != "be74aecd9ea734bfece963597a2269188a4ea8a1247bc381dffbd6a38a2c6376" { - t.Fatalf("Key = %s", got) + const six = "perro luna casa verde tren mar" + for _, tc := range []struct{ text, want string }{ + {"uno dos tres", "not 3"}, + {"a b c d e f g h", "not 0"}, + {"perro perro perro perro perro perro", "not 1"}, + {"de la casa al mar en tren verde", "not 4"}, + {six + string(rune(0x200B)), "invisible character U+200B"}, + {six + string(rune(0x0001)), "control character U+0001"}, + {six + string(rune(0x0378)), "U+0378, unassigned"}, + } { + if err := Check(Normalize(tc.text)); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("Check(%q) = %v, want %q", tc.text, err, tc.want) + } } - id, err := Identity(words, chain, 1000) +} + +// The vector of spec §38.1, which wordkey.test.ts of datekeys-ts checks too. +func TestKeyVector(t *testing.T) { + chain, _ := hex.DecodeString(profile.QuicknetChainHash) + capsuleID, _ := hex.DecodeString("000102030405060708090a0b0c0d0e0f") + words := Normalize("perro luna casa verde tren mar") + raw, err := Key(words, chain, 1000, capsuleID) if err != nil { t.Fatal(err) } - other, err := Identity(words, chain, 1001) + if got := hex.EncodeToString(raw); got != "fceec4d8ca8de86c85a1f26ed49f82a2b38431bd0ce36db995ae7dfd49b96e41" { + t.Fatalf("Key = %s", got) + } + id, err := Identity(words, chain, 1000, capsuleID) if err != nil { t.Fatal(err) } - if id.Recipient().String() == other.Recipient().String() { - t.Fatal("the round does not change the key") + otherCapsule := slices.Clone(capsuleID) + otherCapsule[15] ^= 1 + for _, o := range []struct { + name string + round uint64 + id []byte + }{{"round", 1001, capsuleID}, {"capsule_id", 1000, otherCapsule}} { + other, err := Identity(words, chain, o.round, o.id) + if err != nil { + t.Fatal(err) + } + if id.Recipient().String() == other.Recipient().String() { + t.Errorf("the %s does not change the key", o.name) + } } }