A key of words for the CLI: encrypt and decrypt with -words

The author asked for keys that people can keep without files. Package
wordkey derives the X25519 identity of words a person chooses, at least
six: their NFD by the tables of pathrule without the marks U+0300 to
U+036F, each code point in lower case by its simple mapping, split at
white space, joined by one space, and stretched with PBKDF2-HMAC-SHA256
of the standard library, 600 000 rounds, salted with the chain hash and
the round of the capsule. It is wordkey.ts of datekeys-ts byte for byte:
both check the same vector.

encrypt takes -words or -words-file for a time_and_key capsule, and adds
the key as one more recipient, derived for the round of -at; decrypt
takes them and adds the identity, for the round the capsule shows. The
format does not change. Checked: the CLI opened a capsule that the page
wrote with words, with the release from the public relays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.11
dev 7 days ago
parent 13910b395b
commit 5b3d2d4f34

@ -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 {

@ -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)
}
}
}

@ -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))
}

@ -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")
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.