Random words for a key of words: wordkey.Generate and encrypt -new-words

wordkey.Generate draws words uniformly with crypto/rand from a built-in
list, and encrypt -new-words FILE writes them to a new file (-dic, default
es; -word-count, default 7). wordkey.List and CheckList refuse a list of
fewer than 2048 words or with two words that are one once normalized
(spec 38.1). The Spanish list, 7776 words, is a draft not yet reviewed,
licensed CC BY-SA 4.0 as an adaptation of FrequencyWords; its source,
method and SHA-256 are in wordkey/lists/README.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.15
dev 22 hours ago
parent fe50885491
commit c49c67ce83

@ -11,6 +11,16 @@ tagged `spec-v0.15`: the long-term recovery of capsules. It changes no format
of `.dkc` or `.dkk`, and one verdict: a valid release in the caller's hand of `.dkc` or `.dkk`, and one verdict: a valid release in the caller's hand
opens a capsule even when the clock is before the round time. opens a capsule even when the clock is before the round time.
- **Random words.** `wordkey.Generate` draws a key of words uniformly from
a built-in list, and `encrypt -new-words FILE [-dic LIST] [-word-count N]`
writes them to a new file: 7 words by default, of the Spanish list
`wordkey/lists/es.txt`, 7776 words, a draft not yet reviewed (spec §38.1,
the SHOULD to offer random words). `wordkey.List` and `CheckList` refuse a
list of fewer than 2048 words or with two words that are one once
normalized. The list is CC BY-SA 4.0, an adaptation of FrequencyWords;
`wordkey/lists/README.md` records its source, method and SHA-256. It
changes no format and no derivation.
- **Approval.** `SpecVersion` is 0.15, and so is the `spec` field of every - **Approval.** `SpecVersion` is 0.15, and so is the `spec` field of every
file of `testdata`: the records and the vectors, regenerated, and the file of `testdata`: the records and the vectors, regenerated, and the
frozen `security_cms.json` and `locator.json`, whose `spec` field alone frozen `security_cms.json` and `locator.json`, whose `spec` field alone

@ -180,7 +180,11 @@ creador que nadie ha comprobado; una nota que incumple las reglas de texto no
se muestra, y los dos lo dicen (`public_note_unusable` en `inspect -json`). se muestra, y los dos lo dicen (`public_note_unusable` en `inspect -json`).
`-words` y `-words-file` dan a una cápsula `time_and_key` una llave de `-words` y `-words-file` dan a una cápsula `time_and_key` una llave de
palabras: al menos seis palabras distintas de tres letras o más, que la abren palabras: al menos seis palabras distintas de tres letras o más, que la abren
con `decrypt -words-file` en lugar de una `.dkk` (spec §38.1). con `decrypt -words-file` en lugar de una `.dkk` (spec §38.1). `-new-words
FICHERO` las sortea en su lugar, 7 por defecto, de una lista incluida
(`-dic es`, la única por ahora; `-word-count N`), y las escribe en un fichero
nuevo: las palabras que elige una persona son más débiles. Las listas y su
licencia están en [`wordkey/lists`](wordkey/lists/README.md).
## Librería ## Librería

@ -179,7 +179,11 @@ that nobody has checked; a note that breaks the rules of text is not shown,
and both say so (`public_note_unusable` in `inspect -json`). and both say so (`public_note_unusable` in `inspect -json`).
`-words` and `-words-file` give a `time_and_key` capsule a key of words: at `-words` and `-words-file` give a `time_and_key` capsule a key of words: at
least six different words of three or more letters, which open it with least six different words of three or more letters, which open it with
`decrypt -words-file` instead of a `.dkk` (spec §38.1). `decrypt -words-file` instead of a `.dkk` (spec §38.1). `-new-words FILE`
draws them at random instead, 7 by default, from a built-in list
(`-dic es`, the only one for now; `-word-count N`), and writes them to a new
file: words a person chooses are weaker. The lists and their license are in
[`wordkey/lists`](wordkey/lists/README.md).
## Library ## Library

@ -51,7 +51,7 @@ import (
) )
const usage = `usage: const usage = `usage:
datekeys encrypt -at TIME -in FILE|FOLDER... -out FILE.dkc [-comment TEXT] [-author TEXT] [-no-mtime] [-policy time_only|time_and_key] [-recipient age1...]... [-dkk FILE.dkk] [-words TEXT|-words-file FILE] [-padding reforzado|bloque256] [-note TEXT] [-sign KEY [-sign-pass-file FILE]] [-large-area] datekeys encrypt -at TIME -in FILE|FOLDER... -out FILE.dkc [-comment TEXT] [-author TEXT] [-no-mtime] [-policy time_only|time_and_key] [-recipient age1...]... [-dkk FILE.dkk] [-words TEXT|-words-file FILE|-new-words FILE [-dic LIST] [-word-count N]] [-padding reforzado|bloque256] [-note TEXT] [-sign KEY [-sign-pass-file FILE]] [-large-area]
datekeys decrypt -in FILE.dkc -out PATH [-dkk FILE.dkk] [-identity FILE]... [-words TEXT|-words-file FILE] [-expect-author dkauthor1...] [-relay URL]... [-release FILE] datekeys decrypt -in FILE.dkc -out PATH [-dkk FILE.dkk] [-identity FILE]... [-words TEXT|-words-file FILE] [-expect-author dkauthor1...] [-relay URL]... [-release FILE]
datekeys author keygen -out FILE (-pass-file FILE|-plain) datekeys author keygen -out FILE (-pass-file FILE|-plain)
datekeys author public -key FILE [-pass-file FILE] datekeys author public -key FILE [-pass-file FILE]
@ -81,6 +81,11 @@ instead of a .dkk
(wordkey). Case, accents and extra spaces do not matter. -words leaves them (wordkey). Case, accents and extra spaces do not matter. -words leaves them
in the shell history; -words-file reads them from a file. in the shell history; -words-file reads them from a file.
-new-words FILE draws the words at random instead, and writes them to the new
file FILE: -word-count words, 7 by default, of the list -dic, es by default,
of 7776 words. Words a person chooses are weaker than random ones: whoever
holds the .dkc can try them offline once the date has come (spec §38.1).
-note puts a public note in the capsule, in clear: anyone who has the .dkc -note puts a public note in the capsule, in clear: anyone who has the .dkc
reads it before the date, nobody can check who wrote it, and with the date it reads it before the date, nobody can check who wrote it, and with the date it
can identify someone. decrypt shows it as text of the creator. can identify someone. decrypt shows it as text of the creator.
@ -203,6 +208,9 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
fs.Var(&recipients, "recipient", "time_and_key: X25519 recipient age1... (repeatable)") fs.Var(&recipients, "recipient", "time_and_key: X25519 recipient age1... (repeatable)")
words := fs.String("words", "", "time_and_key: at least 6 words that open the capsule; they stay in the shell history") words := fs.String("words", "", "time_and_key: at least 6 words that open the capsule; they stay in the shell history")
wordsFile := fs.String("words-file", "", "time_and_key: file with the words that open the capsule") wordsFile := fs.String("words-file", "", "time_and_key: file with the words that open the capsule")
newWords := fs.String("new-words", "", "time_and_key: new file with words drawn at random from a list, that open the capsule")
dic := fs.String("dic", "es", "list of -new-words: "+strings.Join(wordkey.Languages(), ", "))
wordCount := fs.Int("word-count", wordkey.DefaultCount, "number of words of -new-words, 6 or more")
note := fs.String("note", "", "public note of the capsule: one line that anyone with the .dkc reads before the date, and that can identify someone with it") note := fs.String("note", "", "public note of the capsule: one line that anyone with the .dkc reads before the date, and that can identify someone with it")
sign := fs.String("sign", "", "file with the author key that signs the capsule") sign := fs.String("sign", "", "file with the author key that signs the capsule")
signPass := fs.String("sign-pass-file", "", "file with the passphrase of the author key, or - for the standard input") signPass := fs.String("sign-pass-file", "", "file with the passphrase of the author key, or - for the standard input")
@ -245,9 +253,26 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
if err != nil { if err != nil {
return err return err
} }
if *newWords != "" {
if text != "" {
return errors.New("encrypt: -new-words excludes -words and -words-file")
}
if err := checkNew(*newWords); err != nil {
return err
}
list, err := wordkey.List(*dic)
if err != nil {
return fmt.Errorf("encrypt: %w", err)
}
drawn, err := wordkey.Generate(list, *wordCount, nil)
if err != nil {
return fmt.Errorf("encrypt: %w", err)
}
text = strings.Join(drawn, " ")
}
if text != "" { if text != "" {
if pol != capsule.TimeAndKey { if pol != capsule.TimeAndKey {
return errors.New("encrypt: -words and -words-file need -policy time_and_key") return errors.New("encrypt: -words, -words-file and -new-words need -policy time_and_key")
} }
w := wordkey.Normalize(text) w := wordkey.Normalize(text)
if err := wordkey.Check(w); err != nil { if err := wordkey.Check(w); err != nil {
@ -294,6 +319,11 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
return fmt.Errorf("the capsule was written to %s but its .dkk could not be: %w", *out, err) return fmt.Errorf("the capsule was written to %s but its .dkk could not be: %w", *out, err)
} }
} }
if *newWords != "" {
if err := writeAtomic(*newWords, func(w io.Writer) error { _, err := io.WriteString(w, text+"\n"); return err }); err != nil {
return fmt.Errorf("the capsule was written to %s but its words could not be: %w", *out, err)
}
}
fmt.Fprintf(stderr, "Encrypted locally for %s (round %d)\n datekey %s\n capsule_id %x\n format %d: %d files, a payload of %d bytes, padded to %d (%s)\n", fmt.Fprintf(stderr, "Encrypted locally for %s (round %d)\n datekey %s\n capsule_id %x\n format %d: %d files, a payload of %d bytes, padded to %d (%s)\n",
res.UnlockAt.Format(time.RFC3339), res.DateKey.Round, res.DateKey.Compact(), res.CapsuleID, res.Format, len(res.Head.Files), res.Length, res.PaddedLength, res.Padding) res.UnlockAt.Format(time.RFC3339), res.DateKey.Round, res.DateKey.Compact(), res.CapsuleID, res.Format, len(res.Head.Files), res.Length, res.PaddedLength, res.Padding)
for _, p := range skipped { for _, p := range skipped {
@ -302,6 +332,9 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
if res.PortableKey != nil { if res.PortableKey != nil {
fmt.Fprintf(stderr, " access key %s: keep it secret; it is valid for this capsule only\n", *dkk) fmt.Fprintf(stderr, " access key %s: keep it secret; it is valid for this capsule only\n", *dkk)
} }
if *newWords != "" {
fmt.Fprintf(stderr, " words %s: %d words of the list %s; keep them secret, or write them down and delete the file\n", *newWords, *wordCount, *dic)
}
if res.UnlockAt.Sub(now()) > longHorizon { if res.UnlockAt.Sub(now()) > longHorizon {
fmt.Fprintln(stderr, "warning: Quicknet V1 timelock is not post-quantum. The ciphertext may stay available for years,\n"+ fmt.Fprintln(stderr, "warning: Quicknet V1 timelock is not post-quantum. The ciphertext may stay available for years,\n"+
" and its future confidentiality depends on the provider and on the underlying cryptography (spec §53).") " and its future confidentiality depends on the provider and on the underlying cryptography (spec §53).")

@ -25,6 +25,7 @@ import (
"g.activething.com/go/DateKeys/internal/inspectview" "g.activething.com/go/DateKeys/internal/inspectview"
"g.activething.com/go/DateKeys/internal/testkit" "g.activething.com/go/DateKeys/internal/testkit"
"g.activething.com/go/DateKeys/profile" "g.activething.com/go/DateKeys/profile"
"g.activething.com/go/DateKeys/wordkey"
) )
const fixtures = "../../testdata/fixtures" const fixtures = "../../testdata/fixtures"
@ -636,12 +637,63 @@ func TestKeyOfWords(t *testing.T) {
{[]string{"-policy", "time_and_key", "-words", "perro luna casa verde tren mar" + string(rune(0x200B))}, "invisible character U+200B"}, {[]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{"-words", "uno dos tres cuatro cinco seis"}, "need -policy time_and_key"},
{[]string{"-policy", "time_and_key", "-words", "a", "-words-file", words}, "are exclusive"}, {[]string{"-policy", "time_and_key", "-words", "a", "-words-file", words}, "are exclusive"},
{[]string{"-policy", "time_and_key", "-words", "a", "-new-words", filepath.Join(dir, "n.txt")}, "-new-words excludes -words and -words-file"},
{[]string{"-policy", "time_and_key", "-new-words", words}, "already exists"},
{[]string{"-policy", "time_and_key", "-new-words", filepath.Join(dir, "n.txt"), "-dic", "xx"}, `no word list for "xx"`},
{[]string{"-policy", "time_and_key", "-new-words", filepath.Join(dir, "n.txt"), "-word-count", "5"}, "at least 6 words, not 5"},
{[]string{"-new-words", filepath.Join(dir, "n.txt")}, "-words, -words-file and -new-words need -policy time_and_key"},
} { } {
args := append([]string{"encrypt", "-at", at, "-in", in, "-out", filepath.Join(dir, "x.dkc")}, tc.args...) args := append([]string{"encrypt", "-at", at, "-in", in, "-out", filepath.Join(dir, "x.dkc")}, tc.args...)
if _, _, err := cli(t, genesis, args...); err == nil || !strings.Contains(err.Error(), tc.want) { if _, _, err := cli(t, genesis, args...); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%v: %v, want %q", tc.args, err, tc.want) t.Errorf("%v: %v, want %q", tc.args, err, tc.want)
} }
} }
if _, err := os.Stat(filepath.Join(dir, "n.txt")); err == nil {
t.Error("a refused encrypt left its words file")
}
}
func TestNewWords(t *testing.T) {
dir := t.TempDir()
in := filepath.Join(dir, "carta.txt")
os.WriteFile(in, []byte("abierta con palabras al azar"), 0o600)
p := profile.Quicknet()
unlock := time.Unix(p.GenesisTime+999*3, 0).UTC() // round 1000
dkc := filepath.Join(dir, "carta.dkc")
words := filepath.Join(dir, "palabras.txt")
_, stderr, err := cli(t, time.Unix(p.GenesisTime, 0), "encrypt", "-at", unlock.Format(time.RFC3339), "-policy", "time_and_key",
"-new-words", words, "-word-count", "8", "-in", in, "-out", dkc)
if err != nil {
t.Fatalf("encrypt: %v\n%s", err, stderr)
}
if !strings.Contains(stderr, "words "+words+": 8 words of the list es") {
t.Errorf("stderr: %s", stderr)
}
b, err := os.ReadFile(words)
if err != nil {
t.Fatal(err)
}
list, _ := wordkey.List("es")
inList := map[string]bool{}
for _, w := range list {
inList[w] = true
}
drawn := strings.Fields(string(b))
if len(drawn) != 8 || !strings.HasSuffix(string(b), "\n") {
t.Fatalf("words file %q", b)
}
for _, w := range drawn {
if !inList[w] {
t.Errorf("%q is not in the list es", w)
}
}
out := filepath.Join(dir, "abierta")
if _, stderr, err := cli(t, later, "decrypt", "-in", dkc, "-out", out, "-words-file", words, "-relay", relay(t)); err != nil {
t.Fatalf("decrypt: %v\n%s", err, stderr)
}
if b, err := os.ReadFile(filepath.Join(out, "carta.txt")); err != nil || string(b) != "abierta con palabras al azar" {
t.Fatalf("carta.txt = %q, %v", b, err)
}
} }
// Spec v0.11 §24.1: decrypt does not show a public note that breaks the rules // Spec v0.11 §24.1: decrypt does not show a public note that breaks the rules

@ -68,7 +68,7 @@ v0.14, and §47.1 and §79, new in v0.15. A case of §64 that is not in the repo
| 36.1 | Authenticity semantics | documented in `README.md`, `SECURITY.md` | — (a property the protocol does not provide) | | 36.1 | Authenticity semantics | documented in `README.md`, `SECURITY.md` | — (a property the protocol does not provide) |
| 37 | X25519 recipient V1; the writer rejects a recipient that is not canonical (bit 255 set, or u ≥ p) or of low order, and MAY reject a point of the twist | `age.X25519Recipient`; `agewrap.X25519IdentityFromRaw`, `agewrap.CheckX25519Recipient` (run by `capsule.Encrypt`); the twist check is not implemented | `agewrap.TestRawKeys`, `TestNonCanonicalRecipients`; `capsule.TestEncryptRejectsInvalidOptions` | | 37 | X25519 recipient V1; the writer rejects a recipient that is not canonical (bit 255 set, or u ≥ p) or of low order, and MAY reject a point of the twist | `age.X25519Recipient`; `agewrap.X25519IdentityFromRaw`, `agewrap.CheckX25519Recipient` (run by `capsule.Encrypt`); the twist check is not implemented | `agewrap.TestRawKeys`, `TestNonCanonicalRecipients`; `capsule.TestEncryptRejectsInvalidOptions` |
| 38 | Portable Access Key | `EncryptOptions.NewPortableKey` (fresh `I_ACCESS` per capsule; no API accepts an existing one); `accesskey.AccessKey` | `capsule.TestPortableKeysAreNeverReused` | | 38 | Portable Access Key | `EncryptOptions.NewPortableKey` (fresh `I_ACCESS` per capsule; no API accepts an existing one); `accesskey.AccessKey` | `capsule.TestPortableKeysAreNeverReused` |
| 38.1 | Key of words: one more X25519 credential of `time_and_key`, among the 16; the normalization: NFD with the tables of Unicode 18.0.0, without U+0300 to U+036F, the simple lower case of each code point, split by the spaces of the list; PBKDF2-HMAC-SHA256 of 600 000 rounds, salted with the chain hash, the round and `capsule_id`, into a raw X25519 identity; the writer requires at least 6 words, counting only different words of 3 or more letters, and refuses controls, ignorables and unassigned code points; a reader may ask for the words instead of a `.dkk` | `wordkey` (`Normalize`, `Check`, `Key`, `Identity`, `Rounds`, `MinWords`, `MinLetters`), with `pathrule.NFD`, `Lower`, `DefaultIgnorable` and `Assigned`; `capsule.EncryptOptions.Words` (`accessRecipients`; `sealer.write` derives the identity once `capsule_id` is drawn); `cmd/datekeys`: `-words` and `-words-file` of `encrypt` and `decrypt` (`wordsText`), the words of `decrypt` salted with what `capsule.Inspect` gives | `wordkey.TestNormalize`, `TestCheck`, `TestKeyVector` (the vector of §38.1); `testdata/vectors/wordkey.json` (`internal/testkit.WordKeyVectors`, `TestVectorFilesAreCurrent`): the words of a text, what a writer refuses and the identities, the cases of §64 of v0.11; `capsule.TestEncryptFilesWords` (the words of another `capsule_id` do not open); `cmd/datekeys.TestKeyOfWords` | | 38.1 | Key of words: one more X25519 credential of `time_and_key`, among the 16; the normalization: NFD with the tables of Unicode 18.0.0, without U+0300 to U+036F, the simple lower case of each code point, split by the spaces of the list; PBKDF2-HMAC-SHA256 of 600 000 rounds, salted with the chain hash, the round and `capsule_id`, into a raw X25519 identity; the writer requires at least 6 words, counting only different words of 3 or more letters, and refuses controls, ignorables and unassigned code points; a reader may ask for the words instead of a `.dkk`; SHOULD: random words of a public list by default, at least 6 of 2048 or more | `wordkey` (`Normalize`, `Check`, `Key`, `Identity`, `Rounds`, `MinWords`, `MinLetters`; `Generate`, `List`, `CheckList`, `DefaultCount`, `MinListSize`, the list `lists/es.txt`), with `pathrule.NFD`, `Lower`, `DefaultIgnorable` and `Assigned`; `capsule.EncryptOptions.Words` (`accessRecipients`; `sealer.write` derives the identity once `capsule_id` is drawn); `cmd/datekeys`: `-words` and `-words-file` of `encrypt` and `decrypt` (`wordsText`), `-new-words`, `-dic` and `-word-count` of `encrypt`, the words of `decrypt` salted with what `capsule.Inspect` gives | `wordkey.TestNormalize`, `TestCheck`, `TestKeyVector` (the vector of §38.1), `TestBuiltInLists`, `TestCheckList`, `TestGenerate`, `TestGenerateUniform`; `testdata/vectors/wordkey.json` (`internal/testkit.WordKeyVectors`, `TestVectorFilesAreCurrent`): the words of a text, what a writer refuses and the identities, the cases of §64 of v0.11; `capsule.TestEncryptFilesWords` (the words of another `capsule_id` do not open); `cmd/datekeys.TestKeyOfWords`, `TestNewWords` |
| 39 | Recipients of INNER_ACCESS_AGE: in formats 2 and 3 from 1 to 16 credentials, a dummy in each slot left (a fresh public key whose private key is dropped at once), the 16 in a uniformly random order; which slots are dummies is recorded only in the official vectors | `capsule/encrypt.go` (`accessRecipients`, `fillSlots`, `permute`); `agewrap.AccessIdentity` | `capsule.TestInnerHasSixteenStanzas`, `TestDummyRecipients`, `TestStanzaOrderIsUniform`, `TestCredentialBounds`, `TestFixtureRecipients`, `TestEncryptRoundTripBothPolicies`; `TestConformanceFixtures` (the stanza each credential opens, `access_key_stanza` and `identity_stanzas` in the records) | | 39 | Recipients of INNER_ACCESS_AGE: in formats 2 and 3 from 1 to 16 credentials, a dummy in each slot left (a fresh public key whose private key is dropped at once), the 16 in a uniformly random order; which slots are dummies is recorded only in the official vectors | `capsule/encrypt.go` (`accessRecipients`, `fillSlots`, `permute`); `agewrap.AccessIdentity` | `capsule.TestInnerHasSixteenStanzas`, `TestDummyRecipients`, `TestStanzaOrderIsUniform`, `TestCredentialBounds`, `TestFixtureRecipients`, `TestEncryptRoundTripBothPolicies`; `TestConformanceFixtures` (the stanza each credential opens, `access_key_stanza` and `identity_stanzas` in the records) |
| 40 | `.dkk` framing; `BODY_LEN` in 1..16 MiB (0 is `ERR_INTEGRITY`); order of the frame checks | `accesskey.Encode`, `accesskey.Decode` (the body buffer grows with the data read; every buffer holding the body is wiped) | `accesskey.TestDecodeRejects`, `TestDecodePrecedence`, `TestDecodeShortBodyAllocatesLittle`, `TestEncodeAndDecodeLeaveNoStaleMaterial`, `FuzzDecode` | | 40 | `.dkk` framing; `BODY_LEN` in 1..16 MiB (0 is `ERR_INTEGRITY`); order of the frame checks | `accesskey.Encode`, `accesskey.Decode` (the body buffer grows with the data read; every buffer holding the body is wiped) | `accesskey.TestDecodeRejects`, `TestDecodePrecedence`, `TestDecodeShortBodyAllocatesLittle`, `TestEncodeAndDecodeLeaveNoStaleMaterial`, `FuzzDecode` |
| 41 | `.dkk` BODY_CBOR | `AccessKey.MarshalBody`, `accesskey.DecodeBody` (hand-written `bodyWire` encode and decode) | `accesskey.TestFixtures`, `TestDecodeBodyStructure` | | 41 | `.dkk` BODY_CBOR | `AccessKey.MarshalBody`, `accesskey.DecodeBody` (hand-written `bodyWire` encode and decode) | `accesskey.TestFixtures`, `TestDecodeBodyStructure` |

@ -0,0 +1,110 @@
package wordkey
import (
"crypto/rand"
_ "embed"
"fmt"
"io"
"math/big"
"sort"
"strings"
"unicode/utf8"
)
// DefaultCount is the number of words Generate draws when the caller does
// not ask for more: 7 words of a list of 7776 are about 90 bits.
const DefaultCount = 7
// MinListSize is the fewest words of a list that Generate accepts (spec
// §38.1: at least 6 words of a list of 2048 or more).
const MinListSize = 2048
//go:embed lists/es.txt
var listES string
// lists are the word lists built into the module, by language. Each is a
// plain UTF-8 file, one word per line; lists/README.md says where each comes
// from and its license.
var lists = map[string]string{
"es": listES,
}
// Languages returns the languages of the built-in word lists, sorted.
func Languages() []string {
var l []string
for k := range lists {
l = append(l, k)
}
sort.Strings(l)
return l
}
// List returns the built-in word list of lang, after checking it with
// CheckList.
func List(lang string) ([]string, error) {
text, ok := lists[lang]
if !ok {
return nil, fmt.Errorf("wordkey: no word list for %q; the lists are %s", lang, strings.Join(Languages(), ", "))
}
words := strings.Split(strings.TrimSuffix(text, "\n"), "\n")
if err := CheckList(words); err != nil {
return nil, fmt.Errorf("wordkey: the list %q: %w", lang, err)
}
return words, nil
}
// CheckList reports why words cannot be a list for Generate: fewer than
// MinListSize words, or a word that is not one word of MinLetters
// characters or more once normalized, that holds a character Check refuses,
// or that is the same as another once normalized. Two words such as «papa»
// and «papá» would be one word with less entropy than the list promises.
func CheckList(words []string) error {
if len(words) < MinListSize {
return fmt.Errorf("%d words, fewer than %d", len(words), MinListSize)
}
seen := make(map[string]string, len(words))
for i, w := range words {
n := Normalize(w)
if len(n) != 1 || n[0] != strings.TrimSpace(n[0]) || utf8.RuneCountInString(n[0]) < MinLetters {
return fmt.Errorf("line %d, %q, is not one word of %d or more letters", i+1, w, MinLetters)
}
if err := checkRunes(n[0]); err != nil {
return fmt.Errorf("line %d: %w", i+1, err)
}
if prev, ok := seen[n[0]]; ok {
return fmt.Errorf("line %d, %q, is the same word as %q once normalized", i+1, w, prev)
}
seen[n[0]] = w
}
return nil
}
// Generate draws n different words of list, uniformly, with random, which
// is crypto/rand.Reader when nil. Each word adds log2(len(list)) bits, a
// little less for each word already drawn. n must be MinWords or more.
func Generate(list []string, n int, random io.Reader) ([]string, error) {
if n < MinWords {
return nil, fmt.Errorf("wordkey: a key of words needs at least %d words, not %d", MinWords, n)
}
if n > len(list)/2 {
return nil, fmt.Errorf("wordkey: %d words of a list of %d", n, len(list))
}
if random == nil {
random = rand.Reader
}
picked := make(map[int]bool, n)
words := make([]string, 0, n)
size := big.NewInt(int64(len(list)))
for len(words) < n {
i, err := rand.Int(random, size)
if err != nil {
return nil, fmt.Errorf("wordkey: %w", err)
}
if picked[int(i.Int64())] {
continue
}
picked[int(i.Int64())] = true
words = append(words, list[i.Int64()])
}
return words, nil
}

@ -0,0 +1,144 @@
package wordkey
import (
"bytes"
"crypto/sha256"
"fmt"
"strings"
"testing"
)
// The built-in lists and their SHA-256, as lists/README.md records them. A
// change of a list changes the hash an app pins, so it is never silent.
var listHashes = map[string]string{
"es": "ff77b487765c000da97cca58fe94a2cdb947303e7a07460614d7d95d800034fe",
}
func TestBuiltInLists(t *testing.T) {
if got := strings.Join(Languages(), " "); got != "es" {
t.Fatalf("Languages() = %q", got)
}
for lang, want := range listHashes {
if got := fmt.Sprintf("%x", sha256.Sum256([]byte(lists[lang]))); got != want {
t.Errorf("list %s: SHA-256 %s, want %s", lang, got, want)
}
words, err := List(lang)
if err != nil {
t.Fatal(err)
}
if len(words) != 7776 {
t.Errorf("list %s: %d words, want 7776", lang, len(words))
}
}
if _, err := List("xx"); err == nil || !strings.Contains(err.Error(), `no word list for "xx"; the lists are es`) {
t.Errorf("List(xx): %v", err)
}
}
func TestCheckList(t *testing.T) {
base := make([]string, MinListSize)
for i := range base {
base[i] = fmt.Sprintf("pal%04d", i)
}
if err := CheckList(base); err != nil {
t.Fatal(err)
}
with := func(i int, w string) []string {
l := append([]string(nil), base...)
l[i] = w
return l
}
for _, c := range []struct {
list []string
want string
}{
{base[:MinListSize-1], "2047 words, fewer than 2048"},
{with(5, "dos palabras"), `line 6, "dos palabras", is not one word`},
{with(5, " "), "is not one word"},
{with(5, "mi"), `"mi", is not one word of 3 or more letters`},
{with(5, "casa​"), "invisible character U+200B"},
{with(5, "PAL0001"), `line 6, "PAL0001", is the same word as "pal0001"`},
{append(with(0, "papá"), "papa"), `"papa", is the same word as "papá"`},
} {
if err := CheckList(c.list); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("CheckList: %v, want %q", err, c.want)
}
}
}
func TestGenerate(t *testing.T) {
list, err := List("es")
if err != nil {
t.Fatal(err)
}
words, err := Generate(list, DefaultCount, nil)
if err != nil {
t.Fatal(err)
}
if len(words) != DefaultCount {
t.Fatalf("%d words", len(words))
}
if err := Check(Normalize(strings.Join(words, " "))); err != nil {
t.Errorf("generated words %q: %v", words, err)
}
seen := map[string]bool{}
for _, w := range words {
if seen[w] {
t.Errorf("%q drawn twice", w)
}
seen[w] = true
}
// The same random bytes draw the same words: Generate reads nothing else.
seed := bytes.Repeat([]byte{7, 1, 200, 33}, 64)
a, _ := Generate(list, 6, bytes.NewReader(seed))
b, _ := Generate(list, 6, bytes.NewReader(seed))
if strings.Join(a, " ") != strings.Join(b, " ") {
t.Errorf("%q and %q", a, b)
}
for _, c := range []struct {
n int
want string
}{
{5, "at least 6 words, not 5"},
{len(list), "7776 words of a list of 7776"},
} {
if _, err := Generate(list, c.n, nil); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("Generate(%d): %v, want %q", c.n, err, c.want)
}
}
if _, err := Generate(list, 6, bytes.NewReader(nil)); err == nil || !strings.Contains(err.Error(), "EOF") {
t.Errorf("Generate without random bytes: %v", err)
}
}
// Every word is about as likely: over 7776·40 draws of one word, each index
// falls in its bucket of 64 between 0.8 and 1.2 times the mean.
func TestGenerateUniform(t *testing.T) {
if testing.Short() {
t.Skip("slow")
}
list, _ := List("es")
index := make(map[string]int, len(list))
for i, w := range list {
index[w] = i
}
const buckets = 64
var count [buckets]int
draws := 0
for draws < len(list)*40 {
words, err := Generate(list, 6, nil)
if err != nil {
t.Fatal(err)
}
for _, w := range words {
count[index[w]*buckets/len(list)]++
draws++
}
}
mean := float64(draws) / buckets
for i, c := range count {
if float64(c) < 0.8*mean || float64(c) > 1.2*mean {
t.Errorf("bucket %d: %d draws, mean %.0f", i, c, mean)
}
}
}

@ -0,0 +1,38 @@
# Word lists of `wordkey.Generate`
The lists from which `datekeys encrypt -new-words` draws a key of words at
random (spec §38.1: at least 6 words of a public list of 2048 or more). They
are not normative: a reader does not need them, because the key is derived
from the normalized text of the words, whatever list they came from.
| List | Words | SHA-256 | Status |
|---|---|---|---|
| `es.txt` | 7776 | `ff77b487765c000da97cca58fe94a2cdb947303e7a07460614d7d95d800034fe` | Draft, not yet reviewed by a native speaker |
A list changes only with its hash in this file and in `generate_test.go`:
an application that downloads a list pins its SHA-256 and refuses any other.
## `es.txt`
- **Source:** the frequencies of
[FrequencyWords](https://github.com/hermitdave/FrequencyWords) by Hermit
Dave, `content/2018/es/es_50k.txt`, counted on the OpenSubtitles 2018
corpus, licensed under CC BY-SA 4.0. The Spanish Hunspell dictionary of
LibreOffice (RLA-ES, `es_ES.dic` and `es_ES.aff`) is used only as a filter
and is not redistributed.
- **License:** this list is an adaptation of FrequencyWords and is licensed
under [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/),
unlike the code of this module (Apache 2.0).
- **Method:** the most frequent words that are, in order:
1. lowercase letters only, of 3 to 9 letters;
2. a base form of the dictionary: an entry with affix flags, or the
feminine its flag `G` makes. Entries without flags, which are
conjugations, plurals and pieces of names, are left out;
3. not in a short list of offensive or unpleasant words;
4. not the other half of a pair that differs only in a final `-o` or `-a`
(`chico` and `chica`): the more frequent stays;
5. not the same as a word already taken once normalized as in §38.1
(`papa` and `papá`): the more frequent stays.
The first 7776 that pass, sorted. `wordkey.CheckList` checks the last rule
and the characters of every word.

File diff suppressed because it is too large Load Diff

@ -60,15 +60,8 @@ func Normalize(text string) []string {
func Check(words []string) error { func Check(words []string) error {
counted := make(map[string]bool) counted := make(map[string]bool)
for _, w := range words { for _, w := range words {
for _, r := range w { if err := checkRunes(w); err != nil {
switch { return err
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 { if utf8.RuneCountInString(w) >= MinLetters {
counted[w] = true counted[w] = true
@ -80,6 +73,22 @@ func Check(words []string) error {
return nil return nil
} }
// checkRunes reports a control, a Default_Ignorable_Code_Point or a code
// point unassigned in Unicode 18.0.0 in w.
func checkRunes(w string) error {
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)
}
}
return nil
}
// Key returns the raw X25519 identity of words for the capsule capsuleID, of // 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. // 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) { func Key(words []string, chainHash []byte, round uint64, capsuleID []byte) ([]byte, error) {

Loading…
Cancel
Save

Powered by TurnKey Linux.