diff --git a/cmd/datekeys/main.go b/cmd/datekeys/main.go index f64a05c..c6b8534 100644 --- a/cmd/datekeys/main.go +++ b/cmd/datekeys/main.go @@ -3,7 +3,8 @@ // datekeys encrypt -at 2030-01-01T00:00:00Z -in fotos -in carta.txt -comment "Para Ana" -out regalo.dkc // datekeys encrypt -at 2030-01-01T00:00:00Z -policy time_and_key -dkk key.dkk -in secret.txt -out secret.dkc // datekeys inspect -in regalo.dkc -// datekeys decrypt -in regalo.dkc -out regalo [-dkk key.dkk] [-identity key.txt] +// datekeys encrypt -at 2030-01-01T00:00:00Z -policy time_and_key -words-file palabras.txt -in carta.txt -out carta.dkc +// datekeys decrypt -in regalo.dkc -out regalo [-dkk key.dkk] [-identity key.txt] [-words-file palabras.txt] // datekeys datekey resolve -at 2030-01-01T00:00:00Z // datekeys profile hash // datekeys version @@ -39,11 +40,12 @@ import ( "g.activething.com/go/DateKeys/internal/inspectview" "g.activething.com/go/DateKeys/profile" "g.activething.com/go/DateKeys/provider/drand" + "g.activething.com/go/DateKeys/wordkey" ) 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] [-padding reforzado|bloque256] - datekeys decrypt -in FILE.dkc -out PATH [-dkk FILE.dkk] [-identity FILE]... [-relay URL]... + 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] + datekeys decrypt -in FILE.dkc -out PATH [-dkk FILE.dkk] [-identity FILE]... [-words TEXT|-words-file FILE] [-relay URL]... datekeys inspect -in FILE.dkc [-json] datekeys datekey resolve -at TIME datekeys profile hash [-in PROFILE.cbor] @@ -55,7 +57,12 @@ and the files below it, with an optional comment and declared author. The content is padded, by default with the rule reforzado, and a time_and_key capsule holds 16 slots, from 1 to 16 credentials and a dummy in each slot 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.` +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 +(wordkey). Case, accents and extra spaces do not matter. -words leaves them +in the shell history; -words-file reads them from a file.` // errUsage reports a malformed command line; main prints the usage text. var errUsage = errors.New("invalid command line; run 'datekeys help'") @@ -160,6 +167,8 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error { padding := fs.String("padding", "reforzado", "padding rule of the content: reforzado or bloque256") var recipients multi 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") + wordsFile := fs.String("words-file", "", "time_and_key: file with the words that open the capsule") if err := parse(fs, args); err != nil { return err } @@ -192,6 +201,30 @@ 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. + text, err := wordsText("encrypt", *words, *wordsFile) + if err != nil { + return err + } + if text != "" { + if pol != capsule.TimeAndKey { + 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 { + return fmt.Errorf("encrypt: %w", err) + } + opts.Recipients = append(opts.Recipients, id.Recipient()) + } if *dkk != "" { if err := checkNew(*dkk); err != nil { return err @@ -239,6 +272,8 @@ func decrypt(args []string, stdout, stderr io.Writer, now func() time.Time) erro var identities, relays multi fs.Var(&identities, "identity", "age identity file with X25519 keys (repeatable)") fs.Var(&relays, "relay", "drand relay base URL (repeatable); default: public relays") + words := fs.String("words", "", "the words of a key of words; they stay in the shell history") + wordsFile := fs.String("words-file", "", "file with the words of a key of words") if err := parse(fs, args); err != nil { return err } @@ -272,6 +307,24 @@ func decrypt(args []string, stdout, stderr io.Writer, now func() time.Time) erro return err } defer src.Close() + text, err := wordsText("decrypt", *words, *wordsFile) + if err != nil { + return err + } + if text != "" { + // 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) + if err != nil { + return fmt.Errorf("decrypt: %w", err) + } + opts.Identities = append(opts.Identities, id) + } + if _, err := src.Seek(0, io.SeekStart); err != nil { + return err + } + } ctx, cancel := context.WithTimeout(context.Background(), *timeout) defer cancel() // The format decides the output: a new folder for the files of format 3, @@ -321,6 +374,30 @@ func decrypt(args []string, stdout, stderr io.Writer, now func() time.Time) erro return nil } +// wordsText is the text of the words of -words or of -words-file, at most +// 4 KiB, or "" when neither is given. +func wordsText(cmd, words, file string) (string, error) { + if words != "" && file != "" { + return "", fmt.Errorf("%s: -words and -words-file are exclusive", cmd) + } + if file == "" { + return words, nil + } + f, err := os.Open(file) + if err != nil { + return "", err + } + defer f.Close() + b, err := io.ReadAll(io.LimitReader(f, 4<<10+1)) + if err != nil { + return "", err + } + if len(b) > 4<<10 { + return "", fmt.Errorf("%s: %s is longer than 4 KiB: it is not a list of words", cmd, file) + } + return string(b), nil +} + func readIdentities(path string) ([]age.Identity, error) { f, err := os.Open(path) if err != nil { diff --git a/cmd/datekeys/main_test.go b/cmd/datekeys/main_test.go index 1b49bd9..753fe10 100644 --- a/cmd/datekeys/main_test.go +++ b/cmd/datekeys/main_test.go @@ -559,3 +559,42 @@ func TestLongHorizonWarning(t *testing.T) { } } } + +func TestKeyOfWords(t *testing.T) { + dir := t.TempDir() + in := filepath.Join(dir, "carta.txt") + os.WriteFile(in, []byte("abierta con palabras"), 0o600) + p := profile.Quicknet() + unlock := time.Unix(p.GenesisTime+999*3, 0).UTC() // round 1000 + genesis := time.Unix(p.GenesisTime, 0) + at := unlock.Format(time.RFC3339) + dkc := filepath.Join(dir, "carta.dkc") + if _, stderr, err := cli(t, genesis, "encrypt", "-at", at, "-policy", "time_and_key", "-words", "Perro luna casa verde trén mar", "-in", in, "-out", dkc); err != nil { + t.Fatalf("encrypt: %v\n%s", err, stderr) + } + words := filepath.Join(dir, "palabras.txt") + os.WriteFile(words, []byte(" perro LUNA casa\nverde tren mar\n"), 0o600) + 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" { + t.Fatalf("carta.txt = %q, %v", b, err) + } + if _, _, err := cli(t, later, "decrypt", "-in", dkc, "-out", filepath.Join(dir, "otra"), "-words", "gato luna casa verde tren mar", "-relay", relay(t)); err == nil { + t.Fatal("other words opened the capsule") + } + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"-policy", "time_and_key", "-words", "uno dos tres"}, "at least 6 words, not 3"}, + {[]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"}, + } { + 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) { + t.Errorf("%v: %v, want %q", tc.args, err, tc.want) + } + } +} diff --git a/wordkey/wordkey.go b/wordkey/wordkey.go new file mode 100644 index 0000000..ded5bc3 --- /dev/null +++ b/wordkey/wordkey.go @@ -0,0 +1,70 @@ +// 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 +// 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. +package wordkey + +import ( + "crypto/pbkdf2" + "crypto/sha256" + "fmt" + "strings" + "unicode" + + "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 = 6 + // 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. +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)) + } + 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) + 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) + if err != nil { + return nil, err + } + s, err := bech32.Encode("age-secret-key-", raw) + clear(raw) + if err != nil { + return nil, err + } + return age.ParseX25519Identity(strings.ToUpper(s)) +} diff --git a/wordkey/wordkey_test.go b/wordkey/wordkey_test.go new file mode 100644 index 0000000..1895358 --- /dev/null +++ b/wordkey/wordkey_test.go @@ -0,0 +1,45 @@ +package wordkey + +import ( + "encoding/hex" + "slices" + "testing" + + "g.activething.com/go/DateKeys/profile" +) + +func TestNormalize(t *testing.T) { + nbsp, tab := string(rune(0xa0)), string(rune(9)) + in := " Ábaco" + nbsp + "ÁRBOL" + tab + "niño ΣΑΣ İ " + want := []string{"abaco", "arbol", "nino", "σασ", "i"} + if got := Normalize(in); !slices.Equal(got, want) { + t.Fatalf("Normalize = %q, want %q", got, want) + } + if got := Normalize(" "); len(got) != 0 { + t.Fatalf("Normalize of spaces = %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 { + t.Fatal(err) + } + if got := hex.EncodeToString(raw); got != "be74aecd9ea734bfece963597a2269188a4ea8a1247bc381dffbd6a38a2c6376" { + t.Fatalf("Key = %s", got) + } + id, err := Identity(words, chain, 1000) + if err != nil { + t.Fatal(err) + } + other, err := Identity(words, chain, 1001) + if err != nil { + t.Fatal(err) + } + if id.Recipient().String() == other.Recipient().String() { + t.Fatal("the round does not change the key") + } +}