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
parent
13910b395b
commit
5b3d2d4f34
@ -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))
|
||||
}
|
||||
Loading…
Reference in new issue