|
|
package testkit
|
|
|
|
|
|
import (
|
|
|
"encoding/hex"
|
|
|
"errors"
|
|
|
"fmt"
|
|
|
"slices"
|
|
|
"strings"
|
|
|
|
|
|
"g.activething.com/go/DateKeys/profile"
|
|
|
"g.activething.com/go/DateKeys/wordkey"
|
|
|
)
|
|
|
|
|
|
// WordKeyVectorFile is testdata/vectors/wordkey.json: the key of words of
|
|
|
// spec v0.11, §38.1. Normalize gives the words of a text; Check, what a
|
|
|
// writer does with them; Keys, the identity that the words derive for a
|
|
|
// capsule, with its recipient.
|
|
|
type WordKeyVectorFile struct {
|
|
|
Spec string `json:"spec"`
|
|
|
Description string `json:"description"`
|
|
|
Normalize []WordNormalize `json:"normalize"`
|
|
|
Check []WordCheck `json:"check"`
|
|
|
Keys []WordKeyVector `json:"keys"`
|
|
|
}
|
|
|
|
|
|
// WordNormalize is a text and its words, as wordkey.Normalize gives them.
|
|
|
type WordNormalize struct {
|
|
|
Name string `json:"name"`
|
|
|
Text string `json:"text"`
|
|
|
Words []string `json:"words"`
|
|
|
}
|
|
|
|
|
|
// WordCheck is a text, its words and the result of wordkey.Check on them:
|
|
|
// ResultOK, or the text of its error.
|
|
|
type WordCheck struct {
|
|
|
Name string `json:"name"`
|
|
|
Text string `json:"text"`
|
|
|
Words []string `json:"words"`
|
|
|
Result string `json:"result"`
|
|
|
Error string `json:"error,omitempty"`
|
|
|
}
|
|
|
|
|
|
// WordKeyVector is the identity of words for the capsule capsule_id of the
|
|
|
// round of the chain chain_hash: key, the raw X25519 identity, and
|
|
|
// recipient, its age1… recipient.
|
|
|
type WordKeyVector struct {
|
|
|
Name string `json:"name"`
|
|
|
Words []string `json:"words"`
|
|
|
ChainHash string `json:"chain_hash"`
|
|
|
Round uint64 `json:"round"`
|
|
|
CapsuleID string `json:"capsule_id"`
|
|
|
Key string `json:"key"`
|
|
|
Recipient string `json:"recipient"`
|
|
|
}
|
|
|
|
|
|
// cp is the string of the code point r: invisible code points are written
|
|
|
// by number in this file, never as themselves.
|
|
|
func cp(r rune) string { return string(r) }
|
|
|
|
|
|
// The spaces of spec §38.1, step 4.
|
|
|
var wordSpaces = []rune{
|
|
|
0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x20, 0x85, 0xa0, 0x1680,
|
|
|
0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200a,
|
|
|
0x2028, 0x2029, 0x202f, 0x205f, 0x3000,
|
|
|
}
|
|
|
|
|
|
// The six words of the vector of spec §38.1.
|
|
|
const sixWords = "perro luna casa verde tren mar"
|
|
|
|
|
|
// WordKeyVectors computes testdata/vectors/wordkey.json, and fails if a
|
|
|
// vector does not get the words, the result or the key it is written for.
|
|
|
func WordKeyVectors() (WordKeyVectorFile, error) {
|
|
|
f := WordKeyVectorFile{
|
|
|
Spec: SpecVersion,
|
|
|
Description: "The key of words (spec §38.1): the words of a text, after NFD, without U+0300 to U+036F, in simple lower case of Unicode 18.0.0 and split by the spaces of the list; " +
|
|
|
"what a writer refuses; and the identity, PBKDF2-HMAC-SHA256 of 600000 rounds, for a chain hash, a round and a capsule_id. See testdata/README.md.",
|
|
|
}
|
|
|
var errs []error
|
|
|
fail := func(format string, args ...any) { errs = append(errs, fmt.Errorf(format, args...)) }
|
|
|
|
|
|
// Normalize.
|
|
|
norm := []struct {
|
|
|
name, text string
|
|
|
want []string
|
|
|
}{
|
|
|
{"the words of the vector of §38.1", sixWords, strings.Fields(sixWords)},
|
|
|
{"accents, upper case and runs of spaces", " Ábaco" + cp(0xa0) + "ÁRBOL" + cp(0x09) + "niño ΣΑΣ İ ", []string{"abaco", "arbol", "nino", "σασ", "i"}},
|
|
|
{"«Ábaco ÁRBOL» of §38.1", "Ábaco ÁRBOL", []string{"abaco", "arbol"}},
|
|
|
{"«abaco arbol» of §38.1", "abaco arbol", []string{"abaco", "arbol"}},
|
|
|
{"«ábaco árbol» of §38.1", "ábaco árbol", []string{"abaco", "arbol"}},
|
|
|
{"the accents written as combining marks", "a" + cp(0x301) + "baco a" + cp(0x301) + "rbol", []string{"abaco", "arbol"}},
|
|
|
{"punctuation counts", "Perro, luna.", []string{"perro,", "luna."}},
|
|
|
{"no text", "", []string{}},
|
|
|
{"only spaces", " " + cp(0x09) + cp(0x3000) + " ", []string{}},
|
|
|
{"the simple lower case of a final sigma", "ΟΔΟΣ", []string{"οδοσ"}},
|
|
|
{"capital sharp s, lowered by its simple mapping", "STRAẞE", []string{"straße"}},
|
|
|
{"a dotless i stays", "ı", []string{"ı"}},
|
|
|
{"the angstrom sign, whose NFD is A and a ring", cp(0x212b), []string{"a"}},
|
|
|
{"Hangul, decomposed into jamo", "한글", []string{"ᄒ" + "ᅡ" + "ᆫ" + "ᄀ" + "ᅳ" + "ᆯ"}},
|
|
|
{"a combining mark outside U+0300 to U+036F stays", "a" + cp(0x20d7), []string{"a" + cp(0x20d7)}},
|
|
|
{"fullwidth letters are not folded to ASCII", "ABC", []string{"abc"}},
|
|
|
{"U+A7CB, lowered by Unicode 18.0.0 to U+0264", cp(0xa7cb), []string{cp(0x264)}},
|
|
|
{"a zero width space is not a space", "a" + cp(0x200b) + "b", []string{"a" + cp(0x200b) + "b"}},
|
|
|
{"U+180E is not a space", "a" + cp(0x180e) + "b", []string{"a" + cp(0x180e) + "b"}},
|
|
|
{"a byte order mark is not a space", "a" + cp(0xfeff) + "b", []string{"a" + cp(0xfeff) + "b"}},
|
|
|
}
|
|
|
for _, s := range wordSpaces {
|
|
|
norm = append(norm, struct {
|
|
|
name, text string
|
|
|
want []string
|
|
|
}{fmt.Sprintf("U+%04X is a space", s), "uno" + cp(s) + "dos", []string{"uno", "dos"}})
|
|
|
}
|
|
|
for _, c := range norm {
|
|
|
got := wordkey.Normalize(c.text)
|
|
|
if got == nil {
|
|
|
got = []string{}
|
|
|
}
|
|
|
if !slices.Equal(got, c.want) {
|
|
|
fail("normalize %q: %q, want %q", c.name, got, c.want)
|
|
|
}
|
|
|
f.Normalize = append(f.Normalize, WordNormalize{Name: c.name, Text: c.text, Words: got})
|
|
|
}
|
|
|
|
|
|
// Check.
|
|
|
for _, c := range []struct{ name, text, want string }{
|
|
|
{"six words", sixWords, ResultOK},
|
|
|
{"six words with upper case and accents", "Perro LUNA casa verde trén mar", ResultOK},
|
|
|
{"three words", "uno dos tres", "not 3"},
|
|
|
{"only short words", "a b c d e f g h", "not 0"},
|
|
|
{"one word six times", "perro perro perro perro perro perro", "not 1"},
|
|
|
{"short words do not count", "de la casa al mar en tren verde", "not 4"},
|
|
|
{"five words that count and a short one", "perro luna casa verde tren y", "not 5"},
|
|
|
{"a word of three letters counts", "sol luna casa verde tren mar", ResultOK},
|
|
|
{"a word of three letters that are not ASCII counts", "ñuñ luna casa verde tren mar", ResultOK},
|
|
|
{"the same words in other case count once", "perro PERRO luna casa verde tren", "not 5"},
|
|
|
{"a zero width space, which the writer refuses", sixWords + cp(0x200b), "invisible character U+200B"},
|
|
|
{"a soft hyphen, ignorable", "per" + cp(0xad) + "ro luna casa verde tren mar", "invisible character U+00AD"},
|
|
|
{"a variation selector, ignorable", sixWords + cp(0xfe0f), "invisible character U+FE0F"},
|
|
|
{"a byte order mark", cp(0xfeff) + sixWords, "invisible character U+FEFF"},
|
|
|
{"a control character", sixWords + cp(0x01), "control character U+0001"},
|
|
|
{"a C1 control character", sixWords + cp(0x9f), "control character U+009F"},
|
|
|
{"DEL", sixWords + cp(0x7f), "control character U+007F"},
|
|
|
{"an unassigned code point", sixWords + cp(0x0378), "U+0378, unassigned"},
|
|
|
{"a private use code point is assigned", sixWords + cp(0xe000), ResultOK},
|
|
|
{"an emoji", "perro luna casa verde tren mar 🌍", ResultOK},
|
|
|
} {
|
|
|
words := wordkey.Normalize(c.text)
|
|
|
if words == nil {
|
|
|
words = []string{}
|
|
|
}
|
|
|
v := WordCheck{Name: c.name, Text: c.text, Words: words, Result: ResultOK}
|
|
|
if err := wordkey.Check(words); err != nil {
|
|
|
v.Result, v.Error = "error", err.Error()
|
|
|
}
|
|
|
switch {
|
|
|
case c.want == ResultOK && v.Result != ResultOK:
|
|
|
fail("check %q: %s, want ok", c.name, v.Error)
|
|
|
case c.want != ResultOK && !strings.Contains(v.Error, c.want):
|
|
|
fail("check %q: %q, want %q", c.name, v.Error, c.want)
|
|
|
}
|
|
|
f.Check = append(f.Check, v)
|
|
|
}
|
|
|
|
|
|
// Keys.
|
|
|
quicknet := profile.QuicknetChainHash
|
|
|
const mainnet = "8990e7a9aaed2ffed73dbd7092123d6f289930540d7651336225dc172e51b2ce"
|
|
|
const id0 = "000102030405060708090a0b0c0d0e0f"
|
|
|
for _, c := range []struct {
|
|
|
name, text, chain string
|
|
|
round uint64
|
|
|
capsuleID, want string
|
|
|
}{
|
|
|
{"the vector of §38.1", sixWords, quicknet, 1000, id0, "fceec4d8ca8de86c85a1f26ed49f82a2b38431bd0ce36db995ae7dfd49b96e41"},
|
|
|
{"the next round", sixWords, quicknet, 1001, id0, ""},
|
|
|
{"another capsule_id", sixWords, quicknet, 1000, "000102030405060708090a0b0c0d0e0e", ""},
|
|
|
{"another chain hash", sixWords, mainnet, 1000, id0, ""},
|
|
|
{"round 2^53 - 1", sixWords, quicknet, 1<<53 - 1, id0, ""},
|
|
|
{"words that are not ASCII", "Ábaco ÁRBOL niño ΣΑΣ ñandú straße", quicknet, 1000, "ffeeddccbbaa99887766554433221100", ""},
|
|
|
} {
|
|
|
chain, err := hex.DecodeString(c.chain)
|
|
|
if err != nil {
|
|
|
return f, err
|
|
|
}
|
|
|
capsuleID, err := hex.DecodeString(c.capsuleID)
|
|
|
if err != nil {
|
|
|
return f, err
|
|
|
}
|
|
|
words := wordkey.Normalize(c.text)
|
|
|
raw, err := wordkey.Key(words, chain, c.round, capsuleID)
|
|
|
if err != nil {
|
|
|
return f, err
|
|
|
}
|
|
|
id, err := wordkey.Identity(words, chain, c.round, capsuleID)
|
|
|
if err != nil {
|
|
|
return f, err
|
|
|
}
|
|
|
key := hex.EncodeToString(raw)
|
|
|
if c.want != "" && key != c.want {
|
|
|
fail("key %q: %s, want %s", c.name, key, c.want)
|
|
|
}
|
|
|
for _, k := range f.Keys {
|
|
|
if k.Key == key {
|
|
|
fail("key %q: the same key as %q", c.name, k.Name)
|
|
|
}
|
|
|
}
|
|
|
f.Keys = append(f.Keys, WordKeyVector{
|
|
|
Name: c.name, Words: words, ChainHash: c.chain, Round: c.round, CapsuleID: c.capsuleID,
|
|
|
Key: key, Recipient: id.Recipient().String(),
|
|
|
})
|
|
|
}
|
|
|
return f, errors.Join(errs...)
|
|
|
}
|