You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
103 lines
3.8 KiB
103 lines
3.8 KiB
// 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 {
|
|
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, 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))
|
|
}
|