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 <noreply@anthropic.com>
v0.11
dev 6 days ago
parent 150d9524c2
commit 2213b8c3fd

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

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

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

@ -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"},
} {

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

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

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

Loading…
Cancel
Save

Powered by TurnKey Linux.