// 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. 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" "unicode/utf8" "filippo.io/age" "g.activething.com/go/DateKeys/codec/bech32" "g.activething.com/go/DateKeys/internal/pathrule" ) const ( // 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 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(pathrule.Lower(r)) } return strings.Fields(b.String()) } // 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 { if err := checkRunes(w); err != nil { return err } 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 } // 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 // 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, capsuleID []byte) (*age.X25519Identity, error) { raw, err := Key(words, chainHash, round, capsuleID) 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)) }