Author keys of alg 1 and Ed25519 signing in own code, as Go's authorkey

authorkey.ts ports the package authorkey of datekeys-go at spec-v0.12:
generate with an injectable random source, fromSeed, publicKey, sign,
clear, secret, a toString that hides the secret, publicString, parsePublic
(canonical, on the curve, not of small order), parseSecret, marshal, and
the key file encrypted with age and scrypt of work factor 16, read with a
maximum of 16, 64 KiB and the lines of bufio.Scanner, with the error texts
of Go and of Go's age byte for byte. Key strings are read as Go strings,
with the case and space tables of Go's package unicode (gounicode.ts,
generated by scripts/go-unicode-tables.go).

ed25519sign.ts is crypto_sign of TweetNaCl, as the Dart port, with the
SHA-512 of @noble/hashes: exact arithmetic in Float64Array, secrets never
in BigInt. No new package or module: age-encryption writes the scrypt
stanza, and the STREAM of a key file uses the ChaCha20-Poly1305 and HKDF
already imported.

scripts/authorkey-go-vectors.go, the generator of the Dart port with this
library's output, writes testing/authorkey-vectors.json in an export of
datekeys-go at spec-v0.12: 234 signatures, scalars, keys, Generate and
Encrypt with Go's draws (reproduced byte for byte), 1288 key strings,
3240 runes at the edges of the tables and 130 key files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
dev 1 day ago
parent 28f0c3f524
commit 96614d5e79

@ -0,0 +1,958 @@
//go:build ignore
//go:debug cryptocustomrand=1
// Writes src/lib/dkc/testing/authorkey-vectors.json, the vectors of the
// author keys of authorkey.ts and of the signatures of ed25519sign.ts: the
// Ed25519 signatures of Go's crypto/ed25519 and the package authorkey of
// datekeys-go, with the texts of its errors. It is the generator of the Dart
// port (datekeys-dart, tool/authorkey_go_vectors.go), with the output that
// the tests of this library read:
//
// - sign: crypto/ed25519.Sign over seeds and messages. The first 64 lines
// of sign.input of Go's crypto/ed25519 (SUPERCOP), whose lines 0, 1 and
// 2 are tests 1 to 3 of RFC 8032, 7.1, every 64th line after them, and
// line 1023, whose message of 1023 bytes is the one of its TEST 1024;
// TEST SHA(abc), the message SHA-512("abc") under the key of
// TestSignVerifyHashed of Go; seeds of a fixed seed with messages of 0
// bytes to 1 MiB; and private keys whose second half is another public
// key, which Go hashes as it is given;
// - scalars: x mod ℓ of 64-byte numbers and (a·b + c) mod ℓ of 32-byte
// ones, little-endian, with math/big, in the corners and at random;
// - keys: NewFromSeed, Public, PublicString, Secret, Marshal and String,
// and the errors of NewFromSeed and PublicString;
// - generate and encrypt: Generate and Encrypt while crypto/rand reads a
// ChaCha20 keystream under SHA-256(seed), zero nonce, with each draw in
// hexadecimal, which the tests hand to authorkey.ts in the same order;
// Generate reads it through the GODEBUG cryptocustomrand=1 of this file;
// - public and secret: ParsePublic and ParseSecret over strings, as bytes:
// valid, in the other case or mixed, of other lengths, with each Bech32
// error, other prefixes, data of other lengths and paddings, keys that
// are not canonical, not on the curve or of small order, and bytes that
// are not UTF-8;
// - runes: the same over a valid string where one character is replaced
// by a rune of as many bytes, in the prefix and in the data, for the
// code points at each edge of the sets of unicode.ToLower,
// unicode.ToUpper and unicode.IsSpace of Go: [kind, position, rune,
// text], kind 0 for ParsePublic and 1 for ParseSecret;
// - read: Read of plain and encrypted files, with the result or the text
// of the error.
//
// Every expected value is what Go gives; none is written by hand. A text is
// an index into texts, whose first entry, "", stands for no error. Binary
// values are lower-case hexadecimal. A file is a list of parts, each
// {"hex": …}, {"byte": b, "n": count} or {"sealed": …, "length", "sha256"},
// the age file of a recipe with the draws that age made, which the tests
// write again with age-encryption and those draws. Arrays of numbers are
// written on one line.
//
// It imports only public packages, so it runs in the module of the
// reference implementation, in an export of datekeys-go at the tag
// spec-v0.12 made with git archive, which it does not change. From the root
// of this repository:
//
// tmp=$(mktemp -d)
// git -C ../datekeys-go archive spec-v0.12 | tar -x -C "$tmp"
// cp scripts/authorkey-go-vectors.go "$tmp/"
// src=$(git -C ../datekeys-go rev-parse 'spec-v0.12^{commit}')
// (cd "$tmp" && go run authorkey-go-vectors.go -source "$src" \
// -testdata "$OLDPWD/testdata" \
// -out "$OLDPWD/src/lib/dkc/testing/authorkey-vectors.json")
// rm -rf "$tmp"
//
// The output is the same on every run.
package main
import (
"bufio"
"bytes"
"encoding/base64"
"compress/gzip"
"crypto/ed25519"
cryptorand "crypto/rand"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"log"
"math/big"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"unicode"
"unicode/utf8"
_ "unsafe"
"filippo.io/age"
"golang.org/x/crypto/chacha20"
"g.activething.com/go/DateKeys/authorkey"
_ "g.activething.com/go/DateKeys/codec/bech32"
)
//go:linkname createChecksum g.activething.com/go/DateKeys/codec/bech32.createChecksum
func createChecksum(hrp string, data []byte) []byte
type obj = map[string]any
func h(b []byte) string { return hex.EncodeToString(b) }
func check(err error) {
if err != nil {
_, file, line, _ := runtime.Caller(1)
log.Fatalf("%s:%d: %v", filepath.Base(file), line, err)
}
}
func mustHex(s string) []byte {
b, err := hex.DecodeString(s)
check(err)
return b
}
func label(s string) []byte {
b := sha256.Sum256([]byte("datekeys-ts authorkey: " + s))
return b[:]
}
// pattern is a plaintext of n bytes: byte i is (31·i + 7) mod 256.
func pattern(n int) []byte {
b := make([]byte, n)
for i := range b {
b[i] = byte(31*i + 7)
}
return b
}
// texts are the error texts, indexed; 0 is no error.
var texts = []string{""}
var textIndex = map[string]int{"": 0}
func t(err error) int {
if err == nil {
return 0
}
s := err.Error()
if i, ok := textIndex[s]; ok {
return i
}
texts = append(texts, s)
textIndex[s] = len(texts) - 1
return len(texts) - 1
}
// ---------------------------------------------------------------------------
// crypto/rand from a seed, as in tool/age_writer_go_vectors.go
type seeded struct {
c *chacha20.Cipher
draws [][]byte
}
func (s *seeded) Read(p []byte) (int, error) {
clear(p)
s.c.XORKeyStream(p, p)
s.draws = append(s.draws, bytes.Clone(p))
return len(p), nil
}
func with(seed string, f func()) [][]byte {
key := sha256.Sum256([]byte(seed))
c, err := chacha20.NewUnauthenticatedCipher(key[:], make([]byte, chacha20.NonceSize))
check(err)
s := &seeded{c: c}
old := cryptorand.Reader
cryptorand.Reader = s
defer func() { cryptorand.Reader = old }()
f()
return s.draws
}
func drawsOf(d [][]byte) []obj {
out := []obj{}
for _, b := range d {
out = append(out, obj{"n": len(b), "hex": h(b)})
}
return out
}
// ---------------------------------------------------------------------------
// Signatures
func signCase(name string, seed, pub, msg []byte, node bool) obj {
priv := append(bytes.Clone(seed), pub...)
sig := ed25519.Sign(priv, msg)
c := obj{"name": name, "seed": h(seed), "public_key": h(pub), "signature": h(sig)}
if len(msg) > 4096 {
if !bytes.Equal(msg, pattern(len(msg))) {
log.Fatal("a long message must be a pattern")
}
c["message_pattern"] = len(msg)
} else {
c["message"] = h(msg)
}
ownPub := ed25519.NewKeyFromSeed(seed).Public().(ed25519.PublicKey)
c["valid"] = ed25519.Verify(ownPub, msg, sig)
return c
}
func signSection() []obj {
out := []obj{}
f, err := os.Open(filepath.Join(runtime.GOROOT(), "src", "crypto", "ed25519", "testdata", "sign.input.gz"))
check(err)
defer f.Close()
gz, err := gzip.NewReader(f)
check(err)
sc := bufio.NewScanner(gz)
sc.Buffer(nil, 1<<20)
for line := 0; sc.Scan(); line++ {
if line >= 64 && line%64 != 0 && line != 1023 {
continue
}
parts := strings.Split(sc.Text(), ":")
seed := mustHex(parts[0])[:32]
pub := mustHex(parts[1])
msg := mustHex(parts[2])
sig := mustHex(parts[3])[:64]
if !bytes.Equal(ed25519.Sign(append(bytes.Clone(seed), pub...), msg), sig) {
log.Fatalf("sign.input line %d", line)
}
out = append(out, signCase(fmt.Sprintf("sign.input line %d", line), seed, pub, msg, line < 4 || line%16 == 0))
}
check(sc.Err())
// TEST SHA(abc): the key of TestSignVerifyHashed of Go, the private key
// of RFC 8032, 7.3, which 7.1 signs SHA-512("abc") with.
src, err := os.ReadFile(filepath.Join(runtime.GOROOT(), "src", "crypto", "ed25519", "ed25519_test.go"))
check(err)
m := regexp.MustCompile(`func TestSignVerifyHashed[^{]*\{[^"]*key, _ := hex\.DecodeString\("([0-9a-f]{128})"\)`).FindSubmatch(src)
if m == nil {
log.Fatal("no key in TestSignVerifyHashed")
}
key := mustHex(string(m[1]))
abc := sha512.Sum512([]byte("abc"))
out = append(out, signCase("RFC 8032 TEST SHA(abc)", key[:32], key[32:], abc[:], true))
lengths := []int{0, 1, 2, 31, 32, 33, 63, 64, 65, 99, 111, 112, 113, 127, 128, 129, 200, 255, 256, 1000, 4096}
for i := 0; i < 160; i++ {
seed := label(fmt.Sprintf("sign seed %d", i))
pub := ed25519.NewKeyFromSeed(seed).Public().(ed25519.PublicKey)
n := lengths[i%len(lengths)]
msg := label(fmt.Sprintf("sign message %d", i))
for len(msg) < n {
msg = append(msg, label(fmt.Sprintf("sign message %d %d", i, len(msg)))...)
}
out = append(out, signCase(fmt.Sprintf("seeded %d, %d bytes", i, n), seed, pub, msg[:n], i%8 == 0))
}
for _, n := range []int{64 << 10, 1 << 20} {
seed := label(fmt.Sprintf("sign long %d", n))
pub := ed25519.NewKeyFromSeed(seed).Public().(ed25519.PublicKey)
out = append(out, signCase(fmt.Sprintf("a message of %d bytes", n), seed, pub, pattern(n), n < 1<<20))
}
for _, b := range []byte{0, 0xff} {
seed := bytes.Repeat([]byte{b}, 32)
pub := ed25519.NewKeyFromSeed(seed).Public().(ed25519.PublicKey)
out = append(out, signCase(fmt.Sprintf("seed of 0x%02x", b), seed, pub, []byte("DateKeys"), true))
}
// Go hashes the second half of the private key as the public key,
// whatever it is.
for i := 0; i < 4; i++ {
seed := label(fmt.Sprintf("other key seed %d", i))
other := ed25519.NewKeyFromSeed(label(fmt.Sprintf("other key %d", i))).Public().(ed25519.PublicKey)
if i == 3 {
other = make([]byte, 32)
}
out = append(out, signCase(fmt.Sprintf("the public key of another seed, %d", i), seed, other, []byte("message"), true))
}
return out
}
// ---------------------------------------------------------------------------
// Scalars
var order, _ = new(big.Int).SetString("7237005577332262213973186563042994240857116359379907606001950938285454250989", 10)
func le(x *big.Int, n int) []byte {
b := x.FillBytes(make([]byte, n))
for i, j := 0, n-1; i < j; i, j = i+1, j-1 {
b[i], b[j] = b[j], b[i]
}
return b
}
func fromLE(b []byte) *big.Int {
r := bytes.Clone(b)
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return new(big.Int).SetBytes(r)
}
func scalarSection() obj {
// ℓ is checked against the order of crypto/ed25519: [ℓ]B is the
// identity, through a signature whose S is ℓ - 1 + 1.
two := big.NewInt(2)
if new(big.Int).Sub(order, new(big.Int).Exp(two, big.NewInt(252), nil)).String() != "27742317777372353535851937790883648493" {
log.Fatal("ℓ")
}
max512 := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 512), big.NewInt(1))
top := new(big.Int).Mul(new(big.Int).Div(max512, order), order)
reduceIn := []*big.Int{
big.NewInt(0), big.NewInt(1), new(big.Int).Sub(order, big.NewInt(1)), order,
new(big.Int).Add(order, big.NewInt(1)), new(big.Int).Mul(order, two),
new(big.Int).Lsh(big.NewInt(1), 252), new(big.Int).Lsh(big.NewInt(1), 253),
new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1)),
new(big.Int).Lsh(big.NewInt(1), 511), max512, top, new(big.Int).Sub(top, big.NewInt(1)),
new(big.Int).Add(top, big.NewInt(1)),
}
for i := 0; i < 200; i++ {
x := new(big.Int).SetBytes(append(label(fmt.Sprintf("reduce %d a", i)), label(fmt.Sprintf("reduce %d b", i))...))
if i%4 == 1 {
x.Rsh(x, uint(i%512))
}
if i%4 == 2 {
x.Add(x.Mul(new(big.Int).Rsh(x, 260), order), big.NewInt(int64(i%3)-1))
x.And(x, max512)
}
reduceIn = append(reduceIn, x)
}
reduce := []obj{}
for _, x := range reduceIn {
reduce = append(reduce, obj{"in": h(le(x, 64)), "out": h(le(new(big.Int).Mod(x, order), 32))})
}
max256 := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
corner := []*big.Int{big.NewInt(0), big.NewInt(1), new(big.Int).Sub(order, big.NewInt(1)), order, max256, new(big.Int).Lsh(big.NewInt(1), 255)}
muladd := []obj{}
add := func(a, b, c *big.Int) {
r := new(big.Int).Mul(a, b)
r.Add(r, c).Mod(r, order)
muladd = append(muladd, obj{"a": h(le(a, 32)), "b": h(le(b, 32)), "c": h(le(c, 32)), "out": h(le(r, 32))})
}
for _, a := range corner {
for _, b := range corner {
add(a, b, corner[(len(muladd))%len(corner)])
}
}
for i := 0; i < 100; i++ {
a := new(big.Int).SetBytes(label(fmt.Sprintf("muladd %d a", i)))
b := new(big.Int).SetBytes(label(fmt.Sprintf("muladd %d b", i)))
c := new(big.Int).SetBytes(label(fmt.Sprintf("muladd %d c", i)))
add(a, b, c)
}
return obj{"reduce": reduce, "muladd": muladd, "order": h(le(order, 32))}
}
// ---------------------------------------------------------------------------
// Keys
func keySection() obj {
keys := []obj{}
for i := 0; i < 24; i++ {
seed := label(fmt.Sprintf("key %d", i))
if i == 0 {
seed = make([]byte, 32)
}
k, err := authorkey.NewFromSeed(seed)
check(err)
ps, err := authorkey.PublicString(k.Public())
check(err)
keys = append(keys, obj{"seed": h(seed), "public_key": h(k.Public()), "public": ps, "secret": k.Secret(), "marshal": string(authorkey.Marshal(k)), "string": k.String(), "gostring": fmt.Sprintf("%#v", k)})
}
seedErrors := []obj{}
for _, n := range []int{0, 31, 33, 64} {
_, err := authorkey.NewFromSeed(make([]byte, n))
seedErrors = append(seedErrors, obj{"length": n, "error": err.Error()})
}
publicErrors := []obj{}
for _, n := range []int{0, 31, 33, 64} {
_, err := authorkey.PublicString(make([]byte, n))
publicErrors = append(publicErrors, obj{"length": n, "error": err.Error()})
}
return obj{"keys": keys, "seed_errors": seedErrors, "public_errors": publicErrors}
}
func generateSection() []obj {
out := []obj{}
for i := 0; i < 3; i++ {
seed := fmt.Sprintf("authorkey generate %d", i)
var k *authorkey.Key
d := with(seed, func() {
var err error
k, err = authorkey.Generate()
check(err)
})
out = append(out, obj{"seed": seed, "draws": drawsOf(d), "secret": k.Secret(), "public_key": h(k.Public())})
}
return out
}
func encryptSection() []obj {
out := []obj{}
for i, pass := range []string{"correct horse battery staple", "contraseña ñ €", "x"} {
k, err := authorkey.NewFromSeed(label(fmt.Sprintf("encrypt key %d", i)))
check(err)
seed := fmt.Sprintf("authorkey encrypt %d", i)
var buf bytes.Buffer
d := with(seed, func() { check(authorkey.Encrypt(&buf, k, pass)) })
back, err := authorkey.Read(bytes.NewReader(buf.Bytes()), pass)
check(err)
if back.Secret() != k.Secret() {
log.Fatal("Read does not give the key back")
}
out = append(out, obj{"seed": seed, "key_seed": h(label(fmt.Sprintf("encrypt key %d", i))), "passphrase": pass, "draws": drawsOf(d), "file": h(buf.Bytes())})
}
k, err := authorkey.NewFromSeed(label("encrypt key 0"))
check(err)
err = authorkey.Encrypt(&bytes.Buffer{}, k, "")
out = append(out, obj{"passphrase": "", "error": err.Error()})
return out
}
// ---------------------------------------------------------------------------
// Strings
const charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
// encode5 writes hrp and the 5-bit values with a valid checksum, in lower
// case: a Bech32 string whose data part need not be 8-bit data.
func encode5(hrp string, values []byte) string {
var b strings.Builder
b.WriteString(hrp)
b.WriteString("1")
for _, v := range values {
b.WriteByte(charset[v])
}
for _, v := range createChecksum(hrp, values) {
b.WriteByte(charset[v])
}
return b.String()
}
func to5(data []byte) []byte {
var out []byte
acc, bits := 0, 0
for _, v := range data {
acc = acc<<8 | int(v)
bits += 8
for bits >= 5 {
bits -= 5
out = append(out, byte(acc>>bits)&31)
}
}
if bits > 0 {
out = append(out, byte(acc<<(5-bits))&31)
}
return out
}
func enc(hrp string, data []byte) string {
v := to5(data)
s := encode5(strings.ToLower(hrp), v)
if strings.ToUpper(hrp) == hrp {
return strings.ToUpper(s)
}
return s
}
// variants are the strings of a valid key string s, of the prefix hrp and
// the data data: other cases, lengths, characters, prefixes, paddings.
func variants(s, hrp string, data []byte, full bool) []string {
out := []string{s, strings.ToUpper(s), strings.ToLower(s), s[:1] + strings.ToLower(s[1:]), s[:1] + strings.ToUpper(s[1:]),
s[:len(s)-1] + strings.ToUpper(s[len(s)-1:]), s[:len(s)-1] + strings.ToLower(s[len(s)-1:]),
"", s[:1], s[:len(s)-1], s + "q", s + s, " " + s[1:], s[:len(s)-1] + " ", s[:len(s)-1] + "\n"}
// Each position changed to another character of the charset, to one
// out of it and to the separator.
for i := 0; full && i < len(s); i++ {
for _, c := range []byte{'q', 'p', 'b', 'i', 'o', '1', '0', 'Z', ' ', 0, 0x7f, '"', '\\'} {
if s[i] == c {
continue
}
if c != 'q' && c != 'p' && i%5 != 0 && c != 'b' {
continue
}
out = append(out, s[:i]+string([]byte{c})+s[i+1:])
}
}
lower := strings.ToLower(hrp) == hrp
casing := func(x string) string {
if lower {
return strings.ToLower(x)
}
return strings.ToUpper(x)
}
n := len(hrp)
// Other prefixes of the same length and of a length one less or more,
// with data of the length that keeps the string length.
out = append(out, enc(casing(hrp[:n-1]+"q"), data))
out = append(out, enc(casing(hrp[:n-1]+"Q"), data))
out = append(out, enc(casing("x"+hrp[1:]), data))
v := to5(data)
out = append(out, casing(encode5(strings.ToLower(hrp[:n-1]), append(bytes.Clone(v), 0))))
out = append(out, casing(encode5(strings.ToLower(hrp[:n-1]), append(bytes.Clone(v), 1))))
out = append(out, casing(encode5(strings.ToLower(hrp+"x"), v[:len(v)-1])))
out = append(out, casing(encode5(strings.ToLower(hrp), append(bytes.Clone(v[:len(v)-1]), v[len(v)-1]|1))))
out = append(out, casing(encode5(strings.ToLower(hrp), append(bytes.Clone(v[:len(v)-1]), 31))))
out = append(out, casing(encode5(strings.ToLower(hrp+"x"), v[:len(v)-2])))
out = append(out, casing(encode5(strings.ToLower(hrp[:n-2]), append(bytes.Clone(v), 0, 0))))
out = append(out, casing(encode5(strings.ToLower(hrp[:n-1]+"1"), v[:len(v)-1])))
out = append(out, casing(encode5("", append(bytes.Clone(v), bytes.Repeat([]byte{0}, n+1)...))))
// A byte that is not ASCII and a separator 6, 7 or 8 bytes before the
// end: the position of the separator is checked first.
for _, bad := range []string{"\xff", "é"} {
for _, back := range []int{6, 7, 8} {
b := []byte(s[:3] + bad + s[3+len(bad):])
b[len(b)-back] = '1'
out = append(out, string(b))
}
}
// Bytes that are not ASCII or not UTF-8, in place of as many bytes.
for _, bad := range []string{"\xff", "\x80", "\xc0\x80", "\xe0\x80\x80", "\xed\xa0\x80", "\xf4\x90\x80\x80", "\xc3", "é", "€", "İ", "ß", "Dž", " ", "<22>", "\U0001f600"} {
for _, at := range []int{0, 3, n, n + 1, len(s) - len(bad)} {
if at+len(bad) <= len(s) {
out = append(out, s[:at]+bad+s[at+len(bad):])
}
}
}
return out
}
func keyStrings() ([]string, []string) {
var pub, sec []string
for i := 0; i < 6; i++ {
k, err := authorkey.NewFromSeed(label(fmt.Sprintf("strings %d", i)))
check(err)
ps, err := authorkey.PublicString(k.Public())
check(err)
if i < 2 {
pub = append(pub, variants(ps, authorkey.PublicPrefix, k.Public(), i == 0)...)
seed := label(fmt.Sprintf("strings %d", i))
sec = append(sec, variants(k.Secret(), authorkey.SecretPrefix, seed, i == 0)...)
} else {
pub = append(pub, ps)
sec = append(sec, k.Secret())
}
}
// Keys that the strict profile rejects: the public keys of
// ed25519_strict.json, encodings that are not canonical, and random
// encodings, about half of them off the curve.
var raws [][]byte
var strict struct {
Vectors []struct {
PublicKey string `json:"public_key"`
} `json:"vectors"`
}
check(json.Unmarshal(mustRead(filepath.Join(*testdata, "vectors", "ed25519_strict.json")), &strict))
for _, v := range strict.Vectors {
raws = append(raws, mustHex(v.PublicKey))
}
p := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 255), big.NewInt(19))
for d := int64(-1); d <= 19; d++ {
y := le(new(big.Int).Add(p, big.NewInt(d)), 32)
raws = append(raws, bytes.Clone(y))
y[31] |= 0x80
raws = append(raws, y)
}
for _, y := range []*big.Int{big.NewInt(0), big.NewInt(1), new(big.Int).Sub(p, big.NewInt(1))} {
b := le(y, 32)
raws = append(raws, bytes.Clone(b))
b[31] |= 0x80
raws = append(raws, b)
}
for i := 0; i < 48; i++ {
raws = append(raws, label(fmt.Sprintf("random key %d", i)))
}
for _, r := range raws {
s, err := authorkey.PublicString(r)
check(err)
pub = append(pub, s)
}
return pub, sec
}
func mustRead(path string) []byte {
b, err := os.ReadFile(path)
check(err)
return b
}
func publicSection(in []string) []obj {
out := []obj{}
for _, s := range in {
k, err := authorkey.ParsePublic(s)
c := obj{"in": h([]byte(s)), "text": t(err)}
if err == nil {
c["public_key"] = h(k)
}
out = append(out, c)
}
return out
}
func secretSection(in []string) []obj {
out := []obj{}
for _, s := range in {
k, err := authorkey.ParseSecret(s)
c := obj{"in": h([]byte(s)), "text": t(err)}
if err == nil {
c["public_key"] = h(k.Public())
}
out = append(out, c)
}
return out
}
// edges returns the code points at each edge of a set: the last one out
// and the first one in, the last one in and the first one out.
func edges(in func(rune) bool, add func(rune)) {
prev := in(0)
for r := rune(1); r <= unicode.MaxRune; r++ {
if r >= 0xd800 && r <= 0xdfff {
continue
}
cur := in(r)
if cur != prev {
add(r - 1)
add(r)
}
prev = cur
}
}
func runeSection(n int) obj {
seen := map[rune]bool{}
var runes []rune
add := func(r rune) {
if r < 0x80 || (r >= 0xd800 && r <= 0xdfff) || seen[r] {
return
}
seen[r] = true
runes = append(runes, r)
}
edges(func(r rune) bool { return unicode.ToLower(r) != r }, add)
edges(func(r rune) bool { return unicode.ToUpper(r) != r }, add)
edges(unicode.IsSpace, add)
add(utf8.MaxRune)
add(0xfffd)
k, err := authorkey.NewFromSeed(label("runes"))
check(err)
ps, err := authorkey.PublicString(k.Public())
check(err)
sec := k.Secret()
cases := [][]any{}
for i, r := range runes {
e := string(r)
for kind, s := range []string{ps, sec} {
for _, at := range []int{[]int{3, len(s) - 9}[i%2]} {
in := s[:at] + e + s[at+len(e):]
var err error
if kind == 0 {
_, err = authorkey.ParsePublic(in)
} else {
_, err = authorkey.ParseSecret(in)
}
if err == nil {
log.Fatalf("U+%04X passes", r)
}
cases = append(cases, []any{kind, at, r, t(err)})
}
}
}
if n > 1 {
var some [][]any
for i := 0; i < len(cases); i += n * 2 {
some = append(some, cases[i:i+2]...)
}
cases = some
}
return obj{"public": ps, "secret": sec, "cases": cases}
}
// ---------------------------------------------------------------------------
// Files
type part = obj
func sum(b []byte) string {
s := sha256.Sum256(b)
return h(s[:])
}
// sealedPart is the file of sealed(seed, pass, wf, plain), written as its
// recipe, its length and its SHA-256: the tests write it again with
// SeededRandomSource, as age writes it here.
func sealedPart(seed, pass string, wf int, plain []part) part {
f, d := sealedDraws(seed, pass, wf, join(plain))
return part{"sealed": obj{"seed": seed, "passphrase": pass, "work_factor": wf, "plain": plain, "draws": drawsOf(d)}, "length": len(f), "sha256": sum(f)}
}
func hx(b []byte) part { return part{"hex": h(b)} }
func rep(b byte, n int) part { return part{"byte": int(b), "n": n} }
func join(parts []part) []byte {
var out []byte
for _, p := range parts {
if x, ok := p["hex"]; ok {
out = append(out, mustHex(x.(string))...)
} else if s, ok := p["sealed"]; ok {
r := s.(obj)
f := sealed(r["seed"].(string), r["passphrase"].(string), r["work_factor"].(int), join(r["plain"].([]part)))
if len(f) != p["length"].(int) || sum(f) != p["sha256"].(string) {
log.Fatal("a sealed part")
}
out = append(out, f...)
} else {
out = append(out, bytes.Repeat([]byte{byte(p["byte"].(int))}, p["n"].(int))...)
}
}
return out
}
func readCase(name string, parts []part, pass string, node bool) obj {
k, err := authorkey.Read(bytes.NewReader(join(parts)), pass)
c := obj{"name": name, "file": parts, "passphrase": pass, "text": t(err)}
if err == nil {
c["public_key"] = h(k.Public())
c["secret"] = k.Secret()
}
return c
}
// sealed encrypts plain with a scrypt recipient of work factor wf while
// crypto/rand reads the keystream of seed.
func sealed(seed, pass string, wf int, plain []byte) []byte {
f, _ := sealedDraws(seed, pass, wf, plain)
return f
}
// sealedDraws is sealed with the draws of crypto/rand.
func sealedDraws(seed, pass string, wf int, plain []byte) ([]byte, [][]byte) {
var buf bytes.Buffer
d := with(seed, func() {
r, err := age.NewScryptRecipient(pass)
check(err)
r.SetWorkFactor(wf)
w, err := age.Encrypt(&buf, r)
check(err)
_, err = w.Write(plain)
check(err)
check(w.Close())
})
return buf.Bytes(), d
}
func readSection() []obj {
k1, err := authorkey.NewFromSeed(label("read 1"))
check(err)
k2, err := authorkey.NewFromSeed(label("read 2"))
check(err)
s1, s2 := k1.Secret(), k2.Secret()
p1, err := authorkey.PublicString(k1.Public())
check(err)
out := []obj{}
plain := func(name, s string) {
out = append(out, readCase(name, []part{hx([]byte(s))}, "", true))
}
plain("Marshal", string(authorkey.Marshal(k1)))
plain("the line alone", s1)
plain("the line and LF", s1+"\n")
plain("CR LF", "# c\r\n"+s1+"\r\n\r\n")
plain("CR alone", s1+"\r")
plain("CR inside", s1[:10]+"\r"+s1[10:])
plain("spaces and tabs", " \t "+s1+" \t\v\f\r\n")
plain("comments and empty lines", "\n\n# one\n # two\n\n"+s1+"\n# three\n\n")
plain("a comment without the space", "#"+s1+"\n"+s1+"\n")
plain("a comment that is not UTF-8", "# \xff\xfe\n"+s1)
plain("two keys", s1+"\n"+s2+"\n")
plain("the same key twice", s1+"\n"+s1+"\n")
plain("a key and something else", s1+"\nsomething\n")
plain("something else and a key", "something\n"+s1+"\n")
plain("a key in lower case", strings.ToLower(s1))
plain("a public key", p1)
plain("an age identity", "AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ")
plain("empty", "")
plain("LF", "\n")
plain("only comments", "# a\n# b\n")
plain("only spaces", " \n\t\n\v\f\n")
plain("NUL before the key", "\x00"+s1)
plain("a BOM before the key", bom+s1)
plain("a byte that is not UTF-8 before the key", "\xff"+s1)
plain("NEL alone, not UTF-8", "\x85"+s1)
plain("NEL in UTF-8", "\u0085"+s1+"\u0085")
// The ends of a line that are not quite a space: utf8.DecodeLastRune
// and DecodeRune give U+FFFD for them, which TrimSpace keeps.
plain("a space and a stray continuation byte at the end", s1+ideographicSpace+"\x80")
plain("a stray continuation byte and a space at the start", "\x80"+ideographicSpace+s1)
plain("a space cut at the end", s1+ideographicSpace[:2])
plain("a space cut at the start", ideographicSpace[1:]+s1)
plain("four continuation bytes after a space", s1+ideographicSpace+"\x80\x80\x80\x80")
plain("a space after the key and a stray byte", s1+" \x80")
plain("a key cut", s1[:78])
plain("a key and a byte", s1+"x")
plain("age-encryption.org/v1 without LF", "age-encryption.org/v1")
plain("age-encryption.org/v1 and a key", "age-encryption.org/v1 \n"+s1)
plain("a stray continuation byte alone on a line", "\x80\n"+s1)
plain("two stray continuation bytes before a key", "\x80\x80"+s1)
// Every space of Go, and its neighbours, around the line.
seen := map[rune]bool{}
for r := rune(0); r <= 0x3001; r++ {
if !unicode.IsSpace(r) {
continue
}
for _, x := range []rune{r - 1, r, r + 1} {
if seen[x] || x == '\n' || (x >= 0x21 && x < 0x7f && x != r) {
continue
}
seen[x] = true
e := string(x)
out = append(out, readCase(fmt.Sprintf("U+%04X around the line", x), []part{hx([]byte(e + e + s1 + e + "\n"))}, "", true))
}
}
// The limits: 64 KiB, the bufio.Scanner and its token of 64 KiB.
out = append(out, readCase("64 KiB of comment without LF", []part{hx([]byte("#")), rep('x', 65535)}, "", true))
out = append(out, readCase("64 KiB of comment with LF", []part{hx([]byte("#")), rep('x', 65534), hx([]byte("\n"))}, "", true))
out = append(out, readCase("a key, then a comment, 64 KiB in all", []part{hx([]byte(s1 + "\n#")), rep('x', 65536-82), hx([]byte("\n"))}, "", true))
out = append(out, readCase("64 KiB of spaces without LF", []part{rep(' ', 65536)}, "", true))
out = append(out, readCase("64 KiB and a byte", []part{hx([]byte(s1 + "\n#")), rep('x', 65536-80)}, "", true))
out = append(out, readCase("128 KiB", []part{rep('#', 128<<10)}, "", true))
out = append(out, readCase("64 KiB and a byte, encrypted", []part{hx([]byte("age-encryption.org/v1\n")), rep('x', 65536-21)}, "p", true))
// Encrypted files, with work factors of 1 and 2, cheap for the tests.
enc := func(name, seed, pass string, wf int, plain []byte, read string, node bool) {
out = append(out, readCase(name, []part{hx(sealed(seed, pass, wf, plain))}, read, node))
}
m1 := authorkey.Marshal(k1)
enc("encrypted, work factor 1", "read enc 1", "p", 1, m1, "p", true)
enc("encrypted, work factor 2, UTF-8 passphrase", "read enc 2", "pässwörd €", 2, m1, "pässwörd €", true)
enc("encrypted, wrong passphrase", "read enc 3", "p", 1, m1, "q", true)
enc("encrypted, no passphrase", "read enc 4", "p", 1, m1, "", true)
enc("encrypted, two keys", "read enc 5", "p", 1, []byte(s1+"\n"+s2+"\n"), "p", true)
enc("encrypted, no key", "read enc 6", "p", 1, []byte("# nothing\n"), "p", true)
enc("encrypted, empty", "read enc 7", "p", 1, nil, "p", true)
enc("encrypted, a key in lower case", "read enc 8", "p", 1, []byte(strings.ToLower(s1)), "p", true)
enc("encrypted, spaces around", "read enc 9", "p", 1, []byte(ideographicSpace+s1+paragraphSeparator+"\r"+lf), "p", true)
enc("encrypted, work factor 17", "read enc 10", "p", 17, m1, "p", false)
// Other work factors, edited into a file of work factor 1: age reads
// the work factor before it runs scrypt, and its MAC after.
w1 := sealed("read enc 11", "p", 1, m1)
for _, wf := range []string{"22", "0", "01", "31", "-1", "1 ", "16"} {
e := bytes.Replace(w1, []byte(" 1"+lf), []byte(" "+wf+lf), 1)
out = append(out, readCase("encrypted, work factor edited to "+wf, []part{hx(e)}, "p", wf != "16"))
}
large := sealedPart("read enc 12", "p", 1, []part{hx([]byte(s1 + "\n#")), rep('x', 65000), hx([]byte("\n"))})
out = append(out, readCase("encrypted, 64 KiB of plaintext", []part{large}, "p", true))
tooBig := sealedPart("read enc 13", "p", 1, []part{hx([]byte(s1 + "\n#")), rep('x', 65536)})
out = append(out, readCase("encrypted, more than 64 KiB", []part{tooBig}, "p", true))
f := sealed("read enc 14", "p", 1, m1)
out = append(out, readCase("encrypted, cut", []part{hx(f[:len(f)-1])}, "p", true))
out = append(out, readCase("encrypted, header only", []part{hx(f[:bytes.Index(f, []byte("\n--- "))+1])}, "p", true))
g := bytes.Clone(f)
g[len(g)-1] ^= 1
out = append(out, readCase("encrypted, last byte changed", []part{hx(g)}, "p", true))
g = bytes.Clone(f)
i := bytes.Index(g, []byte("\n--- ")) + 6
g[i] ^= 1
out = append(out, readCase("encrypted, MAC changed", []part{hx(g)}, "p", true))
out = append(out, readCase("encrypted, garbage", []part{hx([]byte("age-encryption.org/v1\n-> what\n"))}, "p", true))
// The stanza of a file of work factor 1 edited, which age reads before
// scrypt, or the bytes after its header, which it reads after the MAC.
hdrEnd := bytes.Index(f, []byte("\n--- ")) + 1
hdrEnd += bytes.IndexByte(f[hdrEnd:], '\n') + 1
lines := strings.SplitN(string(f[:hdrEnd]), "\n", 4) // intro, stanza, body, MAC and the rest
stanza, body := lines[1], lines[2]
args := strings.Fields(stanza) // "->", "scrypt", salt, work factor
edited := func(name, st, bd string, after []byte) {
e := []byte(lines[0] + "\n" + st + "\n" + bd + "\n" + lines[3])
out = append(out, readCase("encrypted, "+name, []part{hx(append(e, after...))}, "p", true))
}
rest := f[hdrEnd:]
edited("an X25519 stanza besides scrypt", stanza, body+"\n-> X25519 "+args[2]+"\n"+body, rest)
edited("a second scrypt stanza", stanza, body+"\n"+stanza+"\n"+body, rest)
edited("scrypt with one argument", "-> scrypt "+args[2], body, rest)
edited("scrypt with three arguments", stanza+" 1", body, rest)
edited("a salt that is not Base64", "-> scrypt !"+args[2][1:]+" 1", body, rest)
edited("a salt with bits after its end", "-> scrypt "+args[2][:21]+"B 1", body, rest)
edited("a salt of 15 bytes", "-> scrypt "+b64.EncodeToString(make([]byte, 15))+" 1", body, rest)
edited("a salt of 17 bytes", "-> scrypt "+b64.EncodeToString(make([]byte, 17))+" 1", body, rest)
edited("a work factor beyond 64 bits", "-> scrypt "+args[2]+" 99999999999999999999", body, rest)
edited("a work factor of 2^63", "-> scrypt "+args[2]+" 9223372036854775808", body, rest)
edited("a work factor of 2^63 - 1", "-> scrypt "+args[2]+" 9223372036854775807", body, rest)
raw, err := b64.DecodeString(body)
check(err)
edited("a body of 31 bytes", stanza, b64.EncodeToString(raw[:31]), rest)
edited("a body of 33 bytes", stanza, b64.EncodeToString(append(bytes.Clone(raw), 0)), rest)
out = append(out, readCase("encrypted, the header alone", []part{hx(f[:hdrEnd])}, "p", true))
out = append(out, readCase("encrypted, five bytes of the nonce", []part{hx(f[:hdrEnd+5])}, "p", true))
out = append(out, readCase("encrypted, the header and the nonce", []part{hx(f[:hdrEnd+16])}, "p", true))
out = append(out, readCase("encrypted, the header, the nonce and a byte", []part{hx(f[:hdrEnd+17])}, "p", true))
// An X25519 recipient instead of scrypt.
var xbuf bytes.Buffer
with("read enc x25519", func() {
id, err := age.GenerateX25519Identity()
check(err)
w, err := age.Encrypt(&xbuf, id.Recipient())
check(err)
_, err = w.Write(m1)
check(err)
check(w.Close())
})
out = append(out, readCase("encrypted for X25519", []part{hx(xbuf.Bytes())}, "p", true))
return out
}
// b64 is the Base64 of the stanzas of age: standard, without padding.
var b64 = base64.RawStdEncoding.Strict()
var testdata = flag.String("testdata", "", "the testdata of this repository")
func main() {
out := flag.String("out", "", "the JSON file to write")
src := flag.String("source", "", "the commit of datekeys-go")
flag.Parse()
if *out == "" || *src == "" || *testdata == "" {
log.Fatal("usage: -source <commit> -testdata <dir> -out <file>")
}
pub, sec := keyStrings()
doc := obj{
"source": *src,
"go": runtime.Version(),
"unicode": unicode.Version,
"description": "The author keys of package authorkey of datekeys-go and the signatures of crypto/ed25519, by scripts/authorkey-go-vectors.go. A text is an index into texts. A file is a list of parts: {hex}, {byte, n}, or {sealed: {seed, passphrase, work_factor, plain, draws}, length, sha256}, the age file of plain, a list of parts, for a scrypt recipient with the draws of crypto/rand in their order. A message_pattern of n is n bytes with (31·i + 7) mod 256 as byte i. Draws are those of crypto/rand, in their order.",
"sign": signSection(),
"scalars": scalarSection(),
"keys": keySection(),
"generate": generateSection(),
"encrypt": encryptSection(),
"public": publicSection(pub),
"secret": secretSection(sec),
"read": readSection(),
"runes": runeSection(1),
}
doc["texts"] = texts
var buf bytes.Buffer
e := json.NewEncoder(&buf)
e.SetEscapeHTML(false)
e.SetIndent("", " ")
check(e.Encode(doc))
// Arrays of numbers on one line each.
b := numbers.ReplaceAllFunc(buf.Bytes(), func(m []byte) []byte { return spaces.ReplaceAll(m, nil) })
check(os.WriteFile(*out, b, 0o644))
fmt.Printf("wrote %s, %d bytes\n", *out, len(b))
}
// Characters written by their code points, so that the source stays ASCII
// where they matter.
var (
lf = string(rune(0x0a))
bom = string(rune(0xfeff))
ideographicSpace = string(rune(0x3000))
paragraphSeparator = string(rune(0x2029))
)
var (
numbers = regexp.MustCompile(`\[\s*-?[0-9]+(,\s*-?[0-9]+)*\s*\]`)
spaces = regexp.MustCompile(`\s+`)
)

@ -0,0 +1,199 @@
//go:build ignore
// Writes src/lib/dkc/gounicode.ts: the three sets of code points that Go's
// strings.ToLower, strings.ToUpper and strings.TrimSpace depend on, as the
// package unicode of the Go that runs it defines them, for the key strings
// and the key files of authorkey.ts:
//
// - the code points r with unicode.ToLower(r) != r, which strings.ToLower
// changes, so that parsePublic tells "written in lower case" as Go does;
// - those with unicode.ToUpper(r) != r, for parseSecret;
// - those with unicode.IsSpace(r), which strings.TrimSpace trims from a
// line of a key file.
//
// Each set is written as runs [first, last, stride], stride 1 or 2, in
// increasing order. No value is written by hand: the generator scans every
// code point and checks the runs it writes against the functions of Go, and
// the premises of authorkey.ts against strings.ToLower, strings.ToUpper and
// strings.TrimSpace. It also writes the SHA-256 of the three bit sets, which
// gounicode.test.ts recomputes from the runs.
//
// Run it with the Go toolchain of the reference implementation (the same
// tables as the Dart port's, Unicode 15.0.0 of Go 1.26):
//
// go run scripts/go-unicode-tables.go -out src/lib/dkc/gounicode.ts
package main
import (
"crypto/sha256"
"flag"
"fmt"
"log"
"os"
"runtime"
"strings"
"unicode"
"unicode/utf8"
)
// runs returns the code points of in as runs [first, last, stride]: a run
// of stride 2 is taken when it covers at least three points, so that
// alternating cases stay short.
func runs(in func(rune) bool) [][3]rune {
var pts []rune
for r := rune(0); r <= unicode.MaxRune; r++ {
if r >= 0xd800 && r <= 0xdfff {
continue // not in valid UTF-8: a decoder gives U+FFFD instead
}
if in(r) {
pts = append(pts, r)
}
}
var out [][3]rune
for i := 0; i < len(pts); {
j := i
for j+1 < len(pts) && pts[j+1] == pts[j]+1 {
j++
}
k := i
for k+1 < len(pts) && pts[k+1] == pts[k]+2 {
k++
}
switch {
case j > i:
out = append(out, [3]rune{pts[i], pts[j], 1})
i = j + 1
case k-i >= 2:
out = append(out, [3]rune{pts[i], pts[k], 2})
i = k + 1
default:
out = append(out, [3]rune{pts[i], pts[i], 1})
i++
}
}
member := map[rune]bool{}
for _, r := range out {
for c := r[0]; c <= r[1]; c += r[2] {
if member[c] {
log.Fatalf("U+%04X twice", c)
}
member[c] = true
}
}
for r := rune(0); r <= unicode.MaxRune; r++ {
if r >= 0xd800 && r <= 0xdfff {
continue
}
if member[r] != in(r) {
log.Fatalf("U+%04X: the runs disagree", r)
}
}
return out
}
// bitSum is the SHA-256 of the set in: bit r at byte r>>3, mask 1<<(r&7).
func bitSum(in func(rune) bool) string {
bits := make([]byte, (unicode.MaxRune+1)/8)
for r := rune(0); r <= unicode.MaxRune; r++ {
if r >= 0xd800 && r <= 0xdfff {
continue
}
if in(r) {
bits[r>>3] |= 1 << (r & 7)
}
}
return fmt.Sprintf("%x", sha256.Sum256(bits))
}
func list(b *strings.Builder, name, doc string, rs [][3]rune) {
fmt.Fprintf(b, "// %s\nconst %s: readonly number[] = [\n", doc, name)
for _, r := range rs {
fmt.Fprintf(b, " 0x%04x, 0x%04x, %d,\n", r[0], r[1], r[2])
}
b.WriteString("];\n\n")
}
func main() {
out := flag.String("out", "", "the TypeScript file to write")
flag.Parse()
if *out == "" {
log.Fatal("usage: go run scripts/go-unicode-tables.go -out src/lib/dkc/gounicode.ts")
}
// strings.ToLower and ToUpper change a string exactly when one of its
// runes changes, or when it is not valid UTF-8: check that premise on
// every code point.
for r := rune(0); r <= unicode.MaxRune; r++ {
if r >= 0xd800 && r <= 0xdfff {
continue
}
s := string(r)
if (strings.ToLower(s) != s) != (unicode.ToLower(r) != r) {
log.Fatalf("strings.ToLower and unicode.ToLower disagree on U+%04X", r)
}
if (strings.ToUpper(s) != s) != (unicode.ToUpper(r) != r) {
log.Fatalf("strings.ToUpper and unicode.ToUpper disagree on U+%04X", r)
}
if (strings.TrimSpace(s) == "") != unicode.IsSpace(r) {
log.Fatalf("strings.TrimSpace and unicode.IsSpace disagree on U+%04X", r)
}
}
if strings.ToLower("\xff") == "\xff" || strings.ToUpper("\xff") == "\xff" {
log.Fatal("an invalid byte no longer changes")
}
if strings.TrimSpace(" \xff ") != "\xff" || utf8.RuneError != 0xfffd {
log.Fatal("TrimSpace no longer stops at an invalid byte")
}
lower := func(r rune) bool { return unicode.ToLower(r) != r }
upper := func(r rune) bool { return unicode.ToUpper(r) != r }
var b strings.Builder
fmt.Fprintf(&b, `// Code generated by scripts/go-unicode-tables.go with Go %s, Unicode %s.
// DO NOT EDIT: regenerate it.
//
// The code points that Go's strings.ToLower, strings.ToUpper and
// strings.TrimSpace depend on, as the package unicode of Go defines them:
// authorkey.ts reads the strings of a key and the lines of a key file with
// them, so that its errors are those of Go whatever the bytes. They are not
// the case mapping of the JavaScript engine (toLowerCase, \p{…}), whose
// version of Unicode changes with the engine, nor the tables of the path
// rules (Unicode 18.0.0, spec §29.5.1). Internal: index.ts does not
// re-export it.
/** The version of Unicode of the package unicode of Go %s. */
export const GO_UNICODE_VERSION = '%s';
/** The SHA-256 of the bit sets of the three tables (bit r at byte r >> 3), which the tests recompute. */
export const GO_UNICODE_SUMS = { lower: '%s', upper: '%s', space: '%s' } as const;
`, runtime.Version(), unicode.Version, runtime.Version(), unicode.Version, bitSum(lower), bitSum(upper), bitSum(unicode.IsSpace))
list(&b, "LOWER_CHANGES", "The runs [first, last, stride] of r with unicode.ToLower(r) != r.", runs(lower))
list(&b, "UPPER_CHANGES", "The runs [first, last, stride] of r with unicode.ToUpper(r) != r.", runs(upper))
list(&b, "SPACES", "The runs [first, last, stride] of r with unicode.IsSpace(r).", runs(unicode.IsSpace))
b.WriteString(`// Whether r is in the runs of table, by binary search on the last points.
function inRuns(table: readonly number[], r: number): boolean {
let lo = 0;
let hi = table.length / 3;
while (lo < hi) {
const m = (lo + hi) >>> 1;
if (table[3 * m + 1]! < r) lo = m + 1;
else hi = m;
}
if (lo === table.length / 3) return false;
const first = table[3 * lo]!;
return r >= first && (r - first) % table[3 * lo + 2]! === 0;
}
/** Whether Go's unicode.ToLower changes the code point r. */
export const goLowerChanges = (r: number): boolean => inRuns(LOWER_CHANGES, r);
/** Whether Go's unicode.ToUpper changes the code point r. */
export const goUpperChanges = (r: number): boolean => inRuns(UPPER_CHANGES, r);
/** Whether r is a space for Go's unicode.IsSpace. */
export const goIsSpace = (r: number): boolean => inRuns(SPACES, r);
`)
if err := os.WriteFile(*out, []byte(b.String()), 0o644); err != nil {
log.Fatal(err)
}
fmt.Printf("wrote %s\n", *out)
}

@ -15,8 +15,9 @@
// another 2.x copy anywhere but under @noble/post-quantum, which pins
// ~2.0.0 and uses its copy for ML-KEM only (plan decision 5);
// - a file of src/ imports tlock-js or drand-client;
// - a file of src/ other than ageio.ts, author.ts, cms.ts, digest.ts, ed25519strict.ts,
// ibe.ts, release.ts, x25519.ts and the tests names @noble/, or a noble import is not a subpath of @noble/curves,
// - a file of src/ other than ageio.ts, author.ts, authorkey.ts, cms.ts,
// digest.ts, ed25519sign.ts, ed25519strict.ts, ibe.ts, release.ts, x25519.ts
// and the tests names @noble/, or a noble import is not a subpath of @noble/curves,
// @noble/hashes or @noble/ciphers, the root copies that those files
// resolve to;
// - a file of src/ other than the tests and src/lib/dkc/testing/ itself
@ -54,16 +55,19 @@ const FORBIDDEN = ['tlock-js', 'drand-client'];
const NOBLE_IMPORTERS = [
'src/lib/dkc/ageio.ts',
'src/lib/dkc/author.ts',
'src/lib/dkc/authorkey.ts',
'src/lib/dkc/cms.ts',
'src/lib/dkc/digest.ts',
'src/lib/dkc/ed25519sign.ts',
'src/lib/dkc/ed25519strict.ts',
'src/lib/dkc/ibe.ts',
'src/lib/dkc/release.ts',
'src/lib/dkc/x25519.ts',
];
// The only files besides the tests that may import age-encryption (plan of
// phase 3, decision 13): the opening, the tlock recipient and the writer.
const AGE_IMPORTERS = ['src/lib/dkc/agefile.ts', 'src/lib/dkc/open.ts', 'src/lib/dkc/tlock.ts', 'src/lib/dkc/writer.ts'];
// phase 3, decision 13): the opening, the tlock recipient, the writer and
// the key files of the author keys.
const AGE_IMPORTERS = ['src/lib/dkc/agefile.ts', 'src/lib/dkc/authorkey.ts', 'src/lib/dkc/open.ts', 'src/lib/dkc/tlock.ts', 'src/lib/dkc/writer.ts'];
// The only files besides the tests that may import the core of the writer,
// which takes its random draws from the caller (decision 4): encrypt.ts, with
// crypto.getRandomValues, and the test helpers of testing/.
@ -81,13 +85,16 @@ const NOT_IN_INDEX = [
'ageio.ts',
'agefile.ts',
'author.ts',
'authorkey.ts',
'bech32.ts',
'cms.ts',
'der.ts',
'digest.ts',
'ed25519sign.ts',
'ed25519strict.ts',
'encrypt.ts',
'envelope.ts',
'gounicode.ts',
'head.ts',
'ibe.ts',
'ipaddr.ts',

@ -0,0 +1,319 @@
// Tests of authorkey.ts against the package authorkey of the Go reference:
// src/lib/dkc/testing/authorkey-vectors.json, written by
// scripts/authorkey-go-vectors.go. Every expected value and every text of an
// error is what Go gives: the keys and their strings, Generate and Encrypt
// with the draws of crypto/rand, which the tests hand to authorkey.ts and to
// age-encryption in the same order, ParsePublic and ParseSecret on strings as
// bytes, the runes at the edges of the case and space tables of Go, and Read
// of plain and encrypted files.
import { Encrypter } from 'age-encryption';
import { readFileSync } from 'node:fs';
import { inspect } from 'node:util';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
AUTHOR_KEY_WORK_FACTOR,
AUTHOR_PUBLIC_LENGTH,
AUTHOR_SECRET_LENGTH,
AuthorKey,
AuthorKeyError,
authorPublicString,
encryptAuthorKey,
marshalAuthorKey,
MAX_AUTHOR_KEY_FILE,
parseAuthorPublic,
parseAuthorSecret,
readAuthorKey,
} from './authorkey.ts';
import { concatBytes, utf8Bytes } from './bytes.ts';
import { GO_UNICODE_VERSION } from './gounicode.ts';
import { h, hx } from './testing/testdata.ts';
interface Draw {
n: number;
hex: string;
}
type Part = { hex: string } | { byte: number; n: number } | { sealed: Sealed; length: number; sha256: string };
interface Sealed {
seed: string;
passphrase: string;
work_factor: number;
plain: Part[];
draws: Draw[];
}
const V = JSON.parse(readFileSync(new URL('./testing/authorkey-vectors.json', import.meta.url), 'utf8')) as {
unicode: string;
keys: {
keys: { seed: string; public_key: string; public: string; secret: string; marshal: string; string: string; gostring: string }[];
seed_errors: { length: number; error: string }[];
public_errors: { length: number; error: string }[];
};
generate: { seed: string; draws: Draw[]; secret: string; public_key: string }[];
encrypt: { seed?: string; key_seed?: string; passphrase: string; draws?: Draw[]; file?: string; error?: string }[];
public: { in: string; text: number; public_key?: string }[];
secret: { in: string; text: number; public_key?: string }[];
runes: { public: string; secret: string; cases: [number, number, number, number][] };
read: { name: string; file: Part[]; passphrase: string; text: number; public_key?: string; secret?: string }[];
texts: string[];
};
afterEach(() => {
vi.restoreAllMocks();
});
// Hands each call of crypto.getRandomValues the next of `draws`, which must
// have its length: the order of Go's crypto/rand.
function replay(draws: readonly Uint8Array[]): () => number {
const queue = [...draws];
vi.spyOn(crypto, 'getRandomValues').mockImplementation(<T extends ArrayBufferView | null>(a: T): T => {
const d = queue.shift();
const view = new Uint8Array(a!.buffer, a!.byteOffset, a!.byteLength);
if (d === undefined || d.length !== view.length) throw new Error(`replay: a draw of ${view.length} bytes, not the next of Go (${d?.length})`);
view.set(d);
return a;
});
return () => queue.length;
}
// The draws of Go's age.Encrypt with a scrypt recipient, without the random
// label that Go's ScryptRecipient draws, its third, which age-encryption does
// not: the file key, the salt and the nonce.
function ageDraws(draws: readonly Draw[]): Uint8Array[] {
expect(draws.map((d) => d.n)).toEqual([16, 16, 16, 16]);
return [draws[0]!, draws[1]!, draws[3]!].map((d) => h(d.hex));
}
async function sha256Hex(b: Uint8Array): Promise<string> {
return hx(new Uint8Array(await crypto.subtle.digest('SHA-256', b as Uint8Array<ArrayBuffer>)));
}
// The bytes of a file of the vectors; a sealed part is written again with
// age-encryption and the draws of Go.
async function fileOf(parts: readonly Part[]): Promise<Uint8Array> {
const out: Uint8Array[] = [];
for (const p of parts) {
if ('hex' in p) out.push(h(p.hex));
else if ('byte' in p) out.push(new Uint8Array(p.n).fill(p.byte));
else {
const plain = await fileOf(p.sealed.plain);
const left = replay(ageDraws(p.sealed.draws));
const e = new Encrypter();
e.setPassphrase(p.sealed.passphrase);
e.setScryptWorkFactor(p.sealed.work_factor);
const f = await e.encrypt(plain);
expect(left()).toBe(0);
vi.restoreAllMocks();
expect(f.length).toBe(p.length);
expect(await sha256Hex(f)).toBe(p.sha256);
out.push(f);
}
}
return concatBytes(...out);
}
const text = (i: number): string => V.texts[i]!;
describe('the vectors', () => {
it('were written with the tables of Unicode that gounicode.ts holds', () => {
expect(V.unicode).toBe(GO_UNICODE_VERSION);
});
});
describe('AuthorKey', () => {
it('gives the public key, its string, the secret, the file and the hidden text of Go for each seed', () => {
expect(V.keys.keys.length).toBe(24);
for (const c of V.keys.keys) {
const k = AuthorKey.fromSeed(h(c.seed));
expect(hx(k.publicKey())).toBe(c.public_key);
expect(k.publicString()).toBe(c.public);
expect(authorPublicString(h(c.public_key))).toBe(c.public);
expect(k.secret()).toBe(c.secret);
expect(new TextDecoder().decode(marshalAuthorKey(k))).toBe(c.marshal);
expect(k.toString()).toBe(c.string);
expect(`${k}`).toBe(c.gostring);
expect(c.public.length).toBe(AUTHOR_PUBLIC_LENGTH);
expect(c.secret.length).toBe(AUTHOR_SECRET_LENGTH);
}
});
it('never prints the secret: in a template, as JSON or with util.inspect', () => {
const c = V.keys.keys[3]!;
const k = AuthorKey.fromSeed(h(c.seed));
for (const s of [String(k), `${k}`, JSON.stringify({ k }), inspect(k), inspect({ k }, { depth: 5 })]) {
expect(s).not.toContain(c.secret);
expect(s).not.toContain(c.secret.slice(21, 40));
expect(s).toContain('(hidden)');
}
});
it('refuses a seed of another length with the text of Go, and a seed that is not bytes', () => {
for (const c of V.keys.seed_errors) {
expect(() => AuthorKey.fromSeed(new Uint8Array(c.length))).toThrow(new AuthorKeyError(c.error));
}
expect(() => AuthorKey.fromSeed('seed' as unknown as Uint8Array)).toThrow(TypeError);
});
it('refuses a public key of another length with the text of Go, whose numbers are swapped', () => {
for (const c of V.keys.public_errors) {
expect(() => authorPublicString(new Uint8Array(c.length))).toThrow(new AuthorKeyError(c.error));
}
});
it('generates the key of Go from the draws of crypto/rand', () => {
for (const c of V.generate) {
const draws = c.draws.map((d) => h(d.hex));
const k = AuthorKey.generate((n) => {
const d = draws.shift()!;
expect(d.length).toBe(n);
return d;
});
expect(draws.length).toBe(0);
expect(k.secret()).toBe(c.secret);
expect(hx(k.publicKey())).toBe(c.public_key);
}
});
it('generates from crypto.getRandomValues by default, and wipes the draw', () => {
const seen: Uint8Array[] = [];
const k = AuthorKey.generate((n) => {
const b = crypto.getRandomValues(new Uint8Array(n));
seen.push(b);
return b;
});
expect(seen[0]!.every((x) => x === 0)).toBe(true);
expect(parseAuthorSecret(k.secret()).publicString()).toBe(k.publicString());
const a = AuthorKey.generate();
const b = AuthorKey.generate();
expect(a.secret()).not.toBe(b.secret());
});
it('refuses a random source that does not give bytes', () => {
expect(() => AuthorKey.generate(() => 'random' as unknown as Uint8Array)).toThrow(TypeError);
expect(() => AuthorKey.generate(() => new Uint8Array(31))).toThrow(new AuthorKeyError('authorkey: a seed has 31 bytes, not 32'));
});
it('copies its seed, and is useless once cleared', () => {
const seed = h(V.keys.keys[1]!.seed);
const k = AuthorKey.fromSeed(seed);
seed.fill(0);
expect(k.secret()).toBe(V.keys.keys[1]!.secret);
const pub = k.publicKey();
pub.fill(0);
expect(hx(k.publicKey())).toBe(V.keys.keys[1]!.public_key);
expect(k.cleared).toBe(false);
k.clear();
expect(k.cleared).toBe(true);
for (const use of [() => k.publicKey(), () => k.sign(new Uint8Array(1)), () => k.secret(), () => k.publicString()]) {
expect(use).toThrow('authorkey: the key was cleared');
}
expect(String(k)).toBe('DKAUTHOR-SECRET-KEY-1… (hidden)');
});
it('signs as crypto/ed25519', () => {
const k = AuthorKey.fromSeed(new Uint8Array(32));
// RFC 8032, 7.1, TEST 1 has another seed; the vectors of ed25519sign.test.ts cover Sign.
expect(k.sign(new Uint8Array(0)).length).toBe(64);
});
});
describe('parseAuthorPublic and parseAuthorSecret', () => {
it('give the key or the error of Go on every string of the vectors, as bytes', () => {
expect(V.public.length).toBeGreaterThan(600);
for (const c of V.public) {
if (c.text === 0) expect(hx(parseAuthorPublic(h(c.in))), c.in).toBe(c.public_key);
else expect(() => parseAuthorPublic(h(c.in)), c.in).toThrow(new AuthorKeyError(text(c.text)));
}
expect(V.secret.length).toBeGreaterThan(600);
for (const c of V.secret) {
if (c.text === 0) expect(hx(parseAuthorSecret(h(c.in)).publicKey()), c.in).toBe(c.public_key);
else expect(() => parseAuthorSecret(h(c.in)), c.in).toThrow(new AuthorKeyError(text(c.text)));
}
});
it('read a string as its UTF-8', () => {
const c = V.keys.keys[2]!;
expect(hx(parseAuthorPublic(c.public))).toBe(c.public_key);
expect(parseAuthorSecret(c.secret).publicString()).toBe(c.public);
expect(() => parseAuthorPublic(c.public.slice(0, 66) + 'é')).toThrow(/a public key has 67 characters, not 68/);
expect(() => parseAuthorPublic(42 as unknown as string)).toThrow(TypeError);
});
it('give the error of Go for a rune at each edge of the tables of Go, in the prefix and in the data', () => {
const enc = new TextEncoder();
const base = [enc.encode(V.runes.public), enc.encode(V.runes.secret)];
expect(V.runes.cases.length).toBeGreaterThan(3000);
for (const [kind, at, rune, t] of V.runes.cases) {
const e = enc.encode(String.fromCodePoint(rune));
const s = base[kind]!;
const input = concatBytes(s.subarray(0, at), e, s.subarray(at + e.length));
const parse = kind === 0 ? parseAuthorPublic : parseAuthorSecret;
expect(() => parse(input), `${kind} U+${rune.toString(16)} at ${at}`).toThrow(new AuthorKeyError(text(t)));
}
});
});
describe('readAuthorKey', () => {
it('reads every file of the vectors as Go does: the key, or the text of the error', async () => {
expect(V.read.length).toBeGreaterThan(100);
for (const c of V.read) {
const file = await fileOf(c.file);
if (c.text === 0) {
const k = await readAuthorKey(file, c.passphrase);
expect(k.secret(), c.name).toBe(c.secret);
expect(hx(k.publicKey()), c.name).toBe(c.public_key);
} else {
await expect(readAuthorKey(file, c.passphrase), c.name).rejects.toThrow(new AuthorKeyError(text(c.text)));
}
}
}, 120_000);
it('refuses a file or a passphrase of another type', async () => {
await expect(readAuthorKey('file' as unknown as Uint8Array)).rejects.toThrow(TypeError);
await expect(readAuthorKey(new Uint8Array(1), 1 as unknown as string)).rejects.toThrow(TypeError);
await expect(readAuthorKey(new Uint8Array(MAX_AUTHOR_KEY_FILE + 1))).rejects.toThrow(`authorkey: a key file of more than ${MAX_AUTHOR_KEY_FILE} bytes`);
});
it('wipes nothing of the caller, and leaves the file as it was', async () => {
const k = AuthorKey.fromSeed(h(V.keys.keys[4]!.seed));
const file = marshalAuthorKey(k);
const copy = file.slice();
expect((await readAuthorKey(file)).secret()).toBe(k.secret());
expect(hx(file)).toBe(hx(copy));
});
});
describe('encryptAuthorKey', () => {
it('writes the file of Go, byte for byte, with the draws of crypto/rand, and reads it back', async () => {
const cases = V.encrypt.filter((c) => c.error === undefined);
expect(cases.length).toBe(3);
for (const c of cases) {
const k = AuthorKey.fromSeed(h(c.key_seed!));
const left = replay(ageDraws(c.draws!));
const file = await encryptAuthorKey(k, c.passphrase);
expect(left()).toBe(0);
vi.restoreAllMocks();
expect(hx(file)).toBe(c.file);
expect(new TextDecoder().decode(file.subarray(0, 60))).toContain(`scrypt `);
expect((await readAuthorKey(file, c.passphrase)).secret()).toBe(k.secret());
}
}, 60_000);
it('uses the work factor of the specification', async () => {
const k = AuthorKey.generate();
const file = await encryptAuthorKey(k, 'una frase');
expect(new TextDecoder().decode(file)).toMatch(new RegExp(`^age-encryption\\.org/v1\\n-> scrypt [A-Za-z0-9+/]{22} ${AUTHOR_KEY_WORK_FACTOR}\\n`));
});
it('refuses an empty passphrase with the text of Go, and one that is not a string', async () => {
const c = V.encrypt.find((x) => x.error !== undefined)!;
const k = AuthorKey.generate();
await expect(encryptAuthorKey(k, c.passphrase)).rejects.toThrow(new AuthorKeyError(c.error!));
await expect(encryptAuthorKey(k, undefined as unknown as string)).rejects.toThrow(TypeError);
});
it('reads a file whose header is not the first of the file only with the bytes of Go', async () => {
// A plain file that starts like an age file without its LF is plain.
await expect(readAuthorKey(utf8Bytes('age-encryption.org/v1'))).rejects.toThrow(AuthorKeyError);
});
});

@ -0,0 +1,541 @@
// The keys of the author signature of alg 1 (spec §29.9, §29.12), as the
// package authorkey of the Go reference at spec-v0.12, with the same checks
// in the same order and the same texts: an Ed25519 seed of 32 bytes, written
// in Bech32 as DKAUTHOR-SECRET-KEY-1…, 79 characters in upper case, whose
// public key A is written dkauthor1…, 67 characters in lower case. A file of
// a secret key holds that line and, by default, is encrypted with age and a
// passphrase, scrypt with a work factor of AUTHOR_KEY_WORK_FACTOR, 16.
//
// A signature of alg 1 proves that someone with the secret key signed, not
// who holds it: whoever opens a capsule knows a public key only through
// another channel (§29.12).
//
// The strings and the lines of a key file are read as Go reads them, as
// bytes: a string is taken as its UTF-8, and a Uint8Array as the bytes of a
// Go string, which need not be UTF-8. The case of a key is that of Go's
// strings.ToLower and strings.ToUpper, and the spaces around a line those of
// strings.TrimSpace, with the tables of the package unicode of Go
// (gounicode.ts), so that every input gives the error of Go. A JavaScript
// string with a lone surrogate has no UTF-8; it is taken with U+FFFD in its
// place, as TextEncoder writes it.
//
// The age file of a key: encryptAuthorKey writes it with the Encrypter of
// age-encryption and a passphrase, which draws the file key, the salt and the
// nonce from crypto.getRandomValues, as Go's age draws them from crypto/rand.
// readAuthorKey reads its header with the parser of age.ts, checks the scrypt
// stanza as the ScryptIdentity of Go's age does, with its texts, lets
// age-encryption run scrypt and check the MAC of the header, and decrypts the
// STREAM payload itself, as Go's age does, with its texts.
//
// Key material. AuthorKey.clear wipes the seed and the public key that the
// key holds, and the functions here wipe their copies, the plaintext of a key
// file included. JavaScript cannot promise more: the engine may have copied a
// buffer, age-encryption and @noble/hashes keep their own, and a string, such
// as the one of AuthorKey.secret or a passphrase, cannot be wiped at all. Nor
// is anything here constant time, which no JavaScript engine promises (see
// ed25519sign.ts).
//
// Internal: index.ts does not re-export it. The pages do not load it.
import { chacha20poly1305 } from '@noble/ciphers/chacha.js';
import { hkdf } from '@noble/hashes/hkdf.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { Decrypter, Encrypter } from 'age-encryption';
import { parseAgeHeader } from './age.ts';
import { bech32Decode, bech32Encode } from './bech32.ts';
import { decodeRuneGo, goQuote, utf8Bytes } from './bytes.ts';
import { goBase64 } from './datekey.ts';
import { ed25519PublicKey, ed25519Sign } from './ed25519sign.ts';
import { goIsSpace, goLowerChanges, goUpperChanges } from './gounicode.ts';
/** The Bech32 prefix of a public key, in lower case. */
export const AUTHOR_PUBLIC_PREFIX = 'dkauthor';
/** The Bech32 prefix of a secret key, in upper case. */
export const AUTHOR_SECRET_PREFIX = 'DKAUTHOR-SECRET-KEY-';
/** The length of the string of a public key, dkauthor1…. */
export const AUTHOR_PUBLIC_LENGTH = 67;
/** The length of the string of a secret key, DKAUTHOR-SECRET-KEY-1…. */
export const AUTHOR_SECRET_LENGTH = 79;
/** The scrypt work factor, logN, of an encrypted key file: 64 MiB, which a phone can afford, where age's 18 would take 256 MiB (spec §29.12). */
export const AUTHOR_KEY_WORK_FACTOR = 16;
/** The largest key file, in bytes. */
export const MAX_AUTHOR_KEY_FILE = 64 << 10;
const SEED_SIZE = 32;
const PUBLIC_SIZE = 32;
/** A key string or a key file that does not read, or a passphrase that is empty, with the text of the error of Go. It carries no normative code, as in Go. */
export class AuthorKeyError extends Error {
override name = 'AuthorKeyError';
}
/** A source of random bytes: n bytes from a CSPRNG. */
export type RandomBytes = (n: number) => Uint8Array;
const cryptoBytes: RandomBytes = (n) => crypto.getRandomValues(new Uint8Array(n));
/**
* A secret key of an author, as authorkey.Key of Go: the AuthorSigner that
* encryptFiles needs to sign with alg 1.
*
* Unlike Go, a cleared key refuses to be used: Go gives the values of a key of
* zeros after Clear, and a signature that never verifies.
*/
export class AuthorKey {
readonly #seed: Uint8Array;
readonly #public: Uint8Array;
#cleared = false;
private constructor(seed: Uint8Array, pub: Uint8Array) {
this.#seed = seed;
this.#public = pub;
}
/**
* A new key, as Generate of Go: a seed of the first 32 bytes that `random`
* gives, crypto.getRandomValues by default.
*/
static generate(random: RandomBytes = cryptoBytes): AuthorKey {
const seed = random(SEED_SIZE);
try {
return AuthorKey.fromSeed(seed);
} finally {
if (seed instanceof Uint8Array) seed.fill(0);
}
}
/** The key of a seed of 32 bytes, which it copies, as NewFromSeed of Go. */
static fromSeed(seed: Uint8Array): AuthorKey {
if (!(seed instanceof Uint8Array)) throw new TypeError('authorkey: a seed is a Uint8Array');
if (seed.length !== SEED_SIZE) throw new AuthorKeyError(`authorkey: a seed has ${seed.length} bytes, not ${SEED_SIZE}`);
const s = seed.slice();
return new AuthorKey(s, ed25519PublicKey(s));
}
/** Whether clear wiped the key. */
get cleared(): boolean {
return this.#cleared;
}
#check(): void {
if (this.#cleared) throw new Error('authorkey: the key was cleared');
}
/** The public key A, 32 bytes, as Public of Go. */
publicKey(): Uint8Array {
this.#check();
return this.#public.slice();
}
/** The public key as dkauthor1…, as PublicString of Go of publicKey(). */
publicString(): string {
return authorPublicString(this.publicKey());
}
/** The Ed25519 signature of `message`, 64 bytes, as Sign of Go. */
sign(message: Uint8Array): Uint8Array {
this.#check();
return ed25519Sign(this.#seed, this.#public, message);
}
/** Wipes the key: it cannot sign afterwards, and every use throws. Copies that the engine made are out of reach. */
clear(): void {
this.#seed.fill(0);
this.#public.fill(0);
this.#cleared = true;
}
/** The secret key, DKAUTHOR-SECRET-KEY-1…, as Secret of Go. Only a key file should ever hold it; a string cannot be wiped. */
secret(): string {
this.#check();
return bech32Encode(AUTHOR_SECRET_PREFIX, this.#seed);
}
/** Hides the secret key, so that a template in a log or in an error never prints it, as String of Go; secret() returns it. */
toString(): string {
return `${AUTHOR_SECRET_PREFIX}1… (hidden)`;
}
/** JSON.stringify hides it too. */
toJSON(): string {
return this.toString();
}
/** And so does util.inspect of Node, as GoString of Go does for %#v. */
[Symbol.for('nodejs.util.inspect.custom')](): string {
return this.toString();
}
}
/**
* The public key `publicKey` as dkauthor1…, as PublicString of Go. A key of
* another length than 32 bytes is an AuthorKeyError with the text of Go, whose
* two numbers are swapped: it says that the key has 32 bytes and should have
* its length.
*/
export function authorPublicString(publicKey: Uint8Array): string {
if (publicKey.length !== PUBLIC_SIZE) throw new AuthorKeyError(`authorkey: a public key has ${PUBLIC_SIZE} bytes, not ${publicKey.length}`);
return bech32Encode(AUTHOR_PUBLIC_PREFIX, publicKey);
}
// The bytes of a Go string: a copy of a Uint8Array, the UTF-8 of a string.
function goString(s: string | Uint8Array): Uint8Array {
if (s instanceof Uint8Array) return s.slice();
if (typeof s !== 'string') throw new TypeError('authorkey: a key is a string or the bytes of one');
return utf8Bytes(s);
}
/**
* The public key of a string dkauthor1…, as ParsePublic of Go: lower case, of
* AUTHOR_PUBLIC_LENGTH characters, with the right prefix and padding, and a
* key that the strict profile could accept: canonical, a point of the curve
* and not of small order (spec §29.9). Throws an AuthorKeyError with the text
* of Go.
*/
export function parseAuthorPublic(s: string | Uint8Array): Uint8Array {
const b = goString(s);
if (b.length !== AUTHOR_PUBLIC_LENGTH) throw new AuthorKeyError(`authorkey: a public key has ${AUTHOR_PUBLIC_LENGTH} characters, not ${b.length}`);
if (changes(b, goLowerChanges)) throw new AuthorKeyError('authorkey: a public key is written in lower case');
const { hrp, data } = decode(b);
if (hrp !== AUTHOR_PUBLIC_PREFIX || data.length !== PUBLIC_SIZE) {
throw new AuthorKeyError(`authorkey: ${goQuote(b)} is not a public key ${AUTHOR_PUBLIC_PREFIX}1…`);
}
if (!canonical(data) || !onCurve(data) || smallOrder(data)) {
throw new AuthorKeyError('authorkey: the public key is not canonical, not a point of the curve or of small order: no signature would verify');
}
return data;
}
/**
* The key of a string DKAUTHOR-SECRET-KEY-1…, as ParseSecret of Go: upper
* case, of AUTHOR_SECRET_LENGTH characters, with the right prefix and
* padding. Throws an AuthorKeyError with the text of Go.
*/
export function parseAuthorSecret(s: string | Uint8Array): AuthorKey {
const b = goString(s);
try {
if (b.length !== AUTHOR_SECRET_LENGTH) throw new AuthorKeyError(`authorkey: a secret key has ${AUTHOR_SECRET_LENGTH} characters, not ${b.length}`);
if (changes(b, goUpperChanges)) throw new AuthorKeyError('authorkey: a secret key is written in upper case');
const { hrp, data } = decode(b);
try {
if (hrp !== AUTHOR_SECRET_PREFIX || data.length !== SEED_SIZE) throw new AuthorKeyError(`authorkey: not a secret key ${AUTHOR_SECRET_PREFIX}1…`);
return AuthorKey.fromSeed(data);
} finally {
data.fill(0);
}
} finally {
b.fill(0);
}
}
// Whether Go's strings.ToLower or strings.ToUpper, as `change` says of each
// rune, changes the string of the bytes b: a rune that changes, or a byte
// that is not UTF-8, which strings.Map writes as U+FFFD.
function changes(b: Uint8Array, change: (r: number) => boolean): boolean {
for (let i = 0; i < b.length; ) {
const [r, size] = decodeRuneGo(b, i);
if ((size === 1 && r === 0xfffd) || change(r)) return true;
i += size;
}
return false;
}
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
// bech32.Decode of Go on the bytes b, whose case its callers checked, so that
// its "mixed case" never applies. An ASCII string goes to bech32Decode, whose
// checks are those of Go for ASCII; any other fails in one of the checks of
// the characters, which this repeats as Go makes them, on runes.
function decode(b: Uint8Array): { hrp: string; data: Uint8Array } {
if (b.every((c) => c < 0x80)) {
try {
return bech32Decode(String.fromCharCode(...b));
} catch (err) {
throw new AuthorKeyError(`authorkey: ${(err as Error).message}`);
}
}
const pos = b.lastIndexOf(0x31);
if (pos < 1 || pos + 7 > b.length) throw new AuthorKeyError(`authorkey: separator '1' at invalid position: pos=${pos}, len=${b.length}`);
const hrp = b.subarray(0, pos);
for (let p = 0; p < hrp.length; ) {
const [c, size] = decodeRuneGo(hrp, p);
if (c < 33 || c > 126) throw new AuthorKeyError(`authorkey: invalid character human-readable part: s[${p}]=${c}`);
p += size;
}
const rest = b.subarray(pos + 1);
for (let p = 0; p < rest.length; ) {
let [c, size] = decodeRuneGo(rest, p);
if (c >= 0x41 && c <= 0x5a) c += 0x20;
if (c >= 0x80 || !CHARSET.includes(String.fromCharCode(c))) throw new AuthorKeyError(`authorkey: invalid character data part: s[${p}]=${c}`);
p += size;
}
/* v8 ignore next -- @preserve: a byte of 0x80 or more is in the HRP or in the data part */
throw new Error('authorkey: internal error: a string that is not ASCII decoded');
}
// The checks of a public key of internal/ed25519strict, on a public value:
// Canonical, OnCurve and SmallOrder.
const P = 2n ** 255n - 19n;
const D = (((-121665n * modPow(121666n, P - 2n)) % P) + P) % P;
function modPow(b: bigint, e: bigint): bigint {
let r = 1n;
let x = b % P;
for (; e > 0n; e >>= 1n) {
if (e & 1n) r = (r * x) % P;
x = (x * x) % P;
}
return r;
}
// The eight points of small order, by their canonical encodings.
const SMALL_ORDER = [
'0000000000000000000000000000000000000000000000000000000000000000',
'0000000000000000000000000000000000000000000000000000000000000080',
'0100000000000000000000000000000000000000000000000000000000000000',
'26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05',
'26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85',
'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a',
'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa',
'ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
];
// y < p, and the sign bit 0 when y is 1 or p - 1: the encoding is canonical.
function canonical(a: Uint8Array): boolean {
const y = leInt(a) & ((1n << 255n) - 1n);
if (y >= P) return false;
return (a[31]! & 0x80) === 0 || (y !== 1n && y !== P - 1n);
}
// x² = (y² - 1)/(d·y² + 1) has a root modulo p.
function onCurve(a: Uint8Array): boolean {
const y = leInt(a) & ((1n << 255n) - 1n);
const y2 = (y * y) % P;
const x2 = (((y2 - 1n + P) % P) * modPow((D * y2 + 1n) % P, P - 2n)) % P;
return x2 === 0n || modPow(x2, (P - 1n) >> 1n) === 1n;
}
function smallOrder(a: Uint8Array): boolean {
const hex = Array.from(a, (c) => c.toString(16).padStart(2, '0')).join('');
return SMALL_ORDER.includes(hex);
}
function leInt(b: Uint8Array): bigint {
let n = 0n;
for (let i = b.length - 1; i >= 0; i--) n = (n << 8n) | BigInt(b[i]!);
return n;
}
/** The file of `key` without encryption, as Marshal of Go: a comment with the public key and the line of the secret key. It holds the secret key: the caller wipes it. */
export function marshalAuthorKey(key: AuthorKey): Uint8Array {
return utf8Bytes(`# public key: ${key.publicString()}\n${key.secret()}\n`);
}
/**
* The file of `key` encrypted with age and `passphrase`, scrypt with a work
* factor of AUTHOR_KEY_WORK_FACTOR, as Encrypt of Go. age-encryption draws the
* file key, the scrypt salt and the nonce from crypto.getRandomValues, in the
* order in which Go's age draws them from crypto/rand. An empty passphrase is
* an AuthorKeyError with the text of Go.
*/
export async function encryptAuthorKey(key: AuthorKey, passphrase: string): Promise<Uint8Array> {
if (typeof passphrase !== 'string') throw new TypeError('authorkey: the passphrase is a string');
if (passphrase === '') throw new AuthorKeyError('authorkey: an empty passphrase');
const plain = marshalAuthorKey(key);
try {
const e = new Encrypter();
e.setPassphrase(passphrase);
e.setScryptWorkFactor(AUTHOR_KEY_WORK_FACTOR);
return await e.encrypt(plain);
} finally {
plain.fill(0);
}
}
const AGE_INTRO = 'age-encryption.org/v1\n';
/**
* The key of a file: encrypted with age and a passphrase, as encryptAuthorKey
* writes it, or plain, as marshalAuthorKey does, with one line of a secret key
* and, besides it, only empty lines and comments that start with '#', as Read
* of Go. The passphrase is needed only for an encrypted file; '' is none.
* Throws an AuthorKeyError with the text of Go.
*
* A file of more than MAX_AUTHOR_KEY_FILE bytes is refused. An encrypted file
* opens only with a work factor of at most AUTHOR_KEY_WORK_FACTOR: a higher
* one would let a hostile file ask for gigabytes of memory, and a lower one is
* a weaker file that the person made with another tool (§29.12 fixes only the
* default). The lines are those of Go's bufio.Scanner: a line of 64 KiB or
* more is `bufio.Scanner: token too long`.
*/
export async function readAuthorKey(file: Uint8Array, passphrase = ''): Promise<AuthorKey> {
if (!(file instanceof Uint8Array)) throw new TypeError('authorkey: a key file is a Uint8Array');
if (typeof passphrase !== 'string') throw new TypeError('authorkey: the passphrase is a string');
if (file.length > MAX_AUTHOR_KEY_FILE) throw new AuthorKeyError(`authorkey: a key file of more than ${MAX_AUTHOR_KEY_FILE} bytes`);
const b = file.slice();
try {
if (!startsWith(b, AGE_INTRO)) return parseFile(b);
if (passphrase === '') throw new AuthorKeyError('authorkey: the key file is encrypted: it needs its passphrase');
const plain = await openScrypt(b, passphrase);
try {
return parseFile(plain);
} finally {
plain.fill(0);
}
} finally {
b.fill(0);
}
}
function startsWith(b: Uint8Array, prefix: string): boolean {
if (b.length < prefix.length) return false;
for (let i = 0; i < prefix.length; i++) if (b[i] !== prefix.charCodeAt(i)) return false;
return true;
}
const fail = (msg: string): AuthorKeyError => new AuthorKeyError(`authorkey: ${msg}`);
// age.Decrypt of Go with a ScryptIdentity of maximum work factor 16, and the
// reading of all its plaintext, with the texts of Go's age after "authorkey: ".
async function openScrypt(b: Uint8Array, passphrase: string): Promise<Uint8Array> {
let header;
try {
header = parseAgeHeader(b);
} catch (err) {
throw fail(((err as Error).cause as Error).message);
}
checkScrypt(header.stanzas);
// The stanza is the one Go would unwrap: age-encryption runs scrypt on it
// and checks the MAC of the header.
const d = new Decrypter();
d.addPassphrase(passphrase);
let fileKey: Uint8Array;
try {
fileKey = await d.decryptHeader(b.subarray(0, header.length));
} catch (err) {
const msg = (err as Error).message;
if (msg === 'invalid header HMAC') throw fail('bad header MAC');
// The header parsed as Go parses it and its stanza passed the checks of
// Go: the only failure left is a passphrase that does not unwrap it.
/* v8 ignore next -- @preserve */
if (msg !== "no identity matched any of the file's recipients") throw fail(`age-encryption: ${msg}`);
throw fail('identity did not match any of the recipients: incorrect identity for recipient block: incorrect passphrase');
}
try {
const rest = b.subarray(header.length);
if (rest.length < 16) throw fail(`failed to read nonce: ${rest.length === 0 ? 'EOF' : 'unexpected EOF'}`);
const key = hkdf(sha256, fileKey, rest.subarray(0, 16), utf8Bytes('payload'), 32);
try {
return decryptStream(key, rest.subarray(16));
} finally {
key.fill(0);
}
} finally {
fileKey.fill(0);
}
}
const DIGITS = /^[1-9][0-9]*$/;
// The checks of ScryptIdentity.Unwrap of Go's age, with a maximum work factor
// of AUTHOR_KEY_WORK_FACTOR, before scrypt runs: what fails here fails in Go
// before scrypt, or, for the length of the body, with the same text after it.
function checkScrypt(stanzas: readonly { type: string; args: readonly string[]; body: Uint8Array }[]): void {
if (stanzas.some((s) => s.type === 'scrypt') && stanzas.length !== 1) throw fail('an scrypt recipient must be the only one');
const s = stanzas.find((x) => x.type === 'scrypt');
if (s === undefined) throw fail('identity did not match any of the recipients: incorrect identity for recipient block: file is not passphrase-encrypted');
if (s.args.length !== 2) throw fail('invalid scrypt recipient block');
const salt = goBase64(utf8Bytes(s.args[0]!), false, false, true);
if (typeof salt === 'number') throw fail(`failed to parse scrypt salt: illegal base64 data at input byte ${salt}`);
if (salt.length !== 16) throw fail('invalid scrypt recipient block');
const w = s.args[1]!;
if (!DIGITS.test(w)) throw fail(`scrypt work factor encoding invalid: ${goQuote(w)}`);
// strconv.Atoi: a number of more than 63 bits is out of range.
if (BigInt(w) > 2n ** 63n - 1n) throw fail(`failed to parse scrypt work factor: strconv.Atoi: parsing ${goQuote(w)}: value out of range`);
if (Number(w) > AUTHOR_KEY_WORK_FACTOR) throw fail(`scrypt work factor too large: ${w}`);
if (s.body.length !== 32) throw fail('invalid scrypt recipient block: incorrect file key size');
}
// The STREAM payload of age (stream.DecryptReader of Go's age) read to its
// end. A key file has at most 64 KiB, so its payload is shorter than a chunk
// of 64 KiB and its tag: it is one last chunk, whose nonce is the first, and
// Go's checks of an empty last chunk that is not the first, of a full-length
// last chunk and of trailing data never apply.
function decryptStream(key: Uint8Array, payload: Uint8Array): Uint8Array {
if (payload.length === 0) throw fail('unexpected EOF');
const nonce = new Uint8Array(12);
nonce[11] = 1;
const plain = open(key, nonce, payload);
if (plain === undefined) throw fail('failed to decrypt and authenticate payload chunk, file may be corrupted or tampered with');
return plain;
}
function open(key: Uint8Array, nonce: Uint8Array, chunk: Uint8Array): Uint8Array | undefined {
try {
return chacha20poly1305(key, nonce).decrypt(chunk);
} catch {
return undefined;
}
}
// The maximum token of Go's bufio.Scanner.
const MAX_TOKEN = 64 * 1024;
// parseFile of Go: the lines of bufio.Scanner and ScanLines, each trimmed with
// strings.TrimSpace.
function parseFile(b: Uint8Array): AuthorKey {
let key: AuthorKey | undefined;
try {
for (let start = 0; start < b.length; ) {
const nl = b.indexOf(0x0a, start);
const end = nl < 0 ? b.length : nl;
// The Scanner fills its buffer of 64 KiB before it finds the end of
// such a line, and gives up.
if (end - start >= MAX_TOKEN) throw new AuthorKeyError('bufio.Scanner: token too long');
const [from, to] = trimSpace(b, start, end);
start = nl < 0 ? b.length : nl + 1;
if (from === to || b[from] === 0x23) continue;
if (key !== undefined) throw new AuthorKeyError('authorkey: a key file holds one secret key');
key = parseAuthorSecret(b.subarray(from, to));
}
} catch (err) {
key?.clear();
throw err;
}
if (key === undefined) throw new AuthorKeyError('authorkey: no secret key in the file');
return key;
}
// strings.TrimSpace of Go on b[start:end]: the runes for which
// unicode.IsSpace is true, from both ends. A byte that is not UTF-8 is
// U+FFFD, which is not a space.
function trimSpace(b: Uint8Array, start: number, end: number): [number, number] {
const line = b.subarray(0, end);
let from = start;
while (from < end) {
const [r, size] = decodeRuneGo(line, from);
if (!goIsSpace(r)) break;
from += size;
}
let to = end;
while (to > from) {
const [r, size] = decodeLastRune(b, from, to);
if (!goIsSpace(r)) break;
to -= size;
}
return [from, to];
}
// utf8.DecodeLastRune of Go on b[start:end], end > start.
function decodeLastRune(b: Uint8Array, start: number, end: number): [number, number] {
let s = end - 1;
const last = b[s]!;
if (last < 0x80) return [last, 1];
const lim = Math.max(start, end - 4);
for (s--; s >= lim; s--) if ((b[s]! & 0xc0) !== 0x80) break;
if (s < start) s = start;
const [r, size] = decodeRuneGo(b.subarray(0, end), s);
if (s + size !== end) return [0xfffd, 1];
return [r, size];
}

@ -0,0 +1,99 @@
// Tests of ed25519sign.ts against crypto/ed25519 of Go: the signatures of
// src/lib/dkc/testing/authorkey-vectors.json, written by
// scripts/authorkey-go-vectors.go (sign.input of Go, RFC 8032 and seeded
// keys and messages up to 1 MiB), and the reduction of scalars modulo ℓ that
// math/big computes.
import { ed25519 } from '@noble/curves/ed25519.js';
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { ED25519_SEED_SIZE, ed25519MulAdd, ed25519PublicKey, ed25519ReduceScalar, ed25519Sign } from './ed25519sign.ts';
import { verifyStrict } from './ed25519strict.ts';
import { h, hx } from './testing/testdata.ts';
interface SignCase {
name: string;
seed: string;
public_key: string;
signature: string;
message?: string;
message_pattern?: number;
valid: boolean;
}
const V = JSON.parse(readFileSync(new URL('./testing/authorkey-vectors.json', import.meta.url), 'utf8')) as {
sign: SignCase[];
scalars: { reduce: { in: string; out: string }[]; muladd: { a: string; b: string; c: string; out: string }[]; order: string };
};
// n bytes with (31·i + 7) mod 256 as byte i.
function pattern(n: number): Uint8Array {
return Uint8Array.from({ length: n }, (_, i) => (31 * i + 7) & 0xff);
}
describe('ed25519Sign', () => {
it('gives the signature of Go on every case of the vectors, and the public key of the seed', () => {
expect(V.sign.length).toBeGreaterThan(230);
for (const c of V.sign) {
const msg = c.message_pattern === undefined ? h(c.message!) : pattern(c.message_pattern);
expect(hx(ed25519Sign(h(c.seed), h(c.public_key), msg)), c.name).toBe(c.signature);
if (c.valid) {
expect(hx(ed25519PublicKey(h(c.seed))), c.name).toBe(c.public_key);
expect(verifyStrict(h(c.public_key), msg, h(c.signature)), c.name).toBe(true);
}
}
});
it('hashes the public key it is given, as Go does: with another one the signature does not verify', () => {
const others = V.sign.filter((c) => !c.valid);
expect(others.length).toBe(4);
for (const c of others) expect(hx(ed25519PublicKey(h(c.seed))), c.name).not.toBe(c.public_key);
});
it('agrees with the public keys of noble on random seeds', () => {
for (let i = 0; i < 64; i++) {
const seed = crypto.getRandomValues(new Uint8Array(32));
expect(hx(ed25519PublicKey(seed))).toBe(hx(ed25519.getPublicKey(seed)));
const msg = crypto.getRandomValues(new Uint8Array(i * 7));
expect(hx(ed25519Sign(seed, ed25519.getPublicKey(seed), msg))).toBe(hx(ed25519.sign(msg, seed)));
}
});
it('refuses a seed, a public key or a message of another kind', () => {
const seed = new Uint8Array(ED25519_SEED_SIZE);
const pub = ed25519PublicKey(seed);
expect(() => ed25519PublicKey(new Uint8Array(31))).toThrow(RangeError);
expect(() => ed25519Sign(new Uint8Array(33), pub, new Uint8Array(0))).toThrow(/a seed has 32 bytes/);
expect(() => ed25519Sign([...seed] as unknown as Uint8Array, pub, new Uint8Array(0))).toThrow(RangeError);
expect(() => ed25519Sign(seed, pub.subarray(1), new Uint8Array(0))).toThrow(/a public key has 32 bytes/);
expect(() => ed25519Sign(seed, pub, 'message' as unknown as Uint8Array)).toThrow(TypeError);
});
});
describe('scalars modulo ℓ', () => {
it('reduces 64-byte numbers as math/big does', () => {
expect(V.scalars.reduce.length).toBeGreaterThan(200);
for (const c of V.scalars.reduce) expect(hx(ed25519ReduceScalar(h(c.in))), c.in).toBe(c.out);
});
it('computes (a·b + c) mod ℓ as math/big does, in the corners and at random', () => {
expect(V.scalars.muladd.length).toBeGreaterThan(100);
for (const c of V.scalars.muladd) expect(hx(ed25519MulAdd(h(c.a), h(c.b), h(c.c))), `${c.a} ${c.b} ${c.c}`).toBe(c.out);
});
it('agrees with BigInt on random 64-byte numbers', () => {
const order = BigInt(`0x${[...h(V.scalars.order)].reverse().map((b) => b.toString(16).padStart(2, '0')).join('')}`);
const le = (b: Uint8Array): bigint => BigInt(`0x${[...b].reverse().map((x) => x.toString(16).padStart(2, '0')).join('') || '0'}`);
for (let i = 0; i < 2000; i++) {
const x = crypto.getRandomValues(new Uint8Array(64));
if (i % 3 === 0) x.fill(0xff, 0, i % 64);
expect(le(ed25519ReduceScalar(x))).toBe(le(x) % order);
}
});
it('refuses scalars of another length', () => {
expect(() => ed25519ReduceScalar(new Uint8Array(63))).toThrow(RangeError);
expect(() => ed25519MulAdd(new Uint8Array(32), new Uint8Array(31), new Uint8Array(32))).toThrow(RangeError);
});
});

@ -0,0 +1,393 @@
// Ed25519 signing (RFC 8032, 5.1.5 and 5.1.6), for the author keys of alg 1
// (spec §29.9, §29.12): crypto_sign of TweetNaCl in its JavaScript port, with
// the SHA-512 of @noble/hashes that ed25519strict.ts and cms.ts already use.
// With the same seed and message it gives the signature of Go's
// crypto/ed25519, byte for byte: Ed25519 is deterministic. The Dart port of
// the library (ed25519_sign.dart) is the same code.
//
// The field arithmetic modulo p = 2^255 - 19 is TweetNaCl's: sixteen limbs of
// 16 bits in a Float64Array, whose products and sums stay below 2^53, so that
// every operation on them is exact. The scalars modulo ℓ are TweetNaCl's too,
// modL: 64 limbs of 8 bits in a Float64Array, whose values stay below 2^34 in
// absolute value, with carries that divide by 2^8 and round down, which is
// exact for them. Only public constants are computed with BigInt; the seed,
// the secret scalar and the nonce never meet one.
//
// The secret scalar and the nonce never meet a branch or an index that
// depends on them: the ladder swaps its points arithmetically, as X25519
// does. A JavaScript engine promises neither constant time nor that memory
// can be wiped: the buffers of @noble/hashes, the copies that the engine
// makes and the strings that hold a key are out of reach. The values that
// this module holds are wiped after use, as a best effort.
//
// Internal: index.ts does not re-export it; authorkey.ts signs with it.
import { sha512 } from '@noble/hashes/sha2.js';
/** The size of an Ed25519 seed, the secret key of RFC 8032. */
export const ED25519_SEED_SIZE = 32;
type GF = Float64Array;
// ---------------------------------------------------------------------------
// The field GF(2^255 - 19)
const P = 2n ** 255n - 19n;
function gf(init?: readonly number[]): GF {
const r = new Float64Array(16);
if (init !== undefined) for (let i = 0; i < init.length; i++) r[i] = init[i]!;
return r;
}
// The element of a public bigint in 0..p-1.
function gfOf(v: bigint): GF {
const r = new Float64Array(16);
for (let i = 0; i < 16; i++) r[i] = Number((v >> BigInt(16 * i)) & 0xffffn);
return r;
}
function set(r: GF, a: GF): void {
for (let i = 0; i < 16; i++) r[i] = a[i]!;
}
// Carries every limb into the next, and the top one back into limb 0 · 38.
function car(o: GF): void {
let c = 1;
for (let i = 0; i < 16; i++) {
const v = o[i]! + c + 65535;
c = Math.floor(v / 65536);
o[i] = v - c * 65536;
}
o[0]! += c - 1 + 37 * (c - 1);
}
// Swaps p and q when b is 1 and leaves them when it is 0, without a branch.
function sel(p: GF, q: GF, b: number): void {
for (let i = 0; i < 16; i++) {
const t = b * (p[i]! - q[i]!);
p[i]! -= t;
q[i]! += t;
}
}
// The canonical 32 little-endian bytes of n.
function pack(o: Uint8Array, n: GF): void {
const t = gf();
set(t, n);
car(t);
car(t);
car(t);
const ti = new Int32Array(16);
for (let i = 0; i < 16; i++) ti[i] = t[i]!;
const m = new Int32Array(16);
for (let j = 0; j < 2; j++) {
m[0] = ti[0]! - 0xffed;
for (let i = 1; i < 15; i++) {
m[i] = ti[i]! - 0xffff - ((m[i - 1]! >> 16) & 1);
m[i - 1]! &= 0xffff;
}
m[15] = ti[15]! - 0x7fff - ((m[14]! >> 16) & 1);
const b = (m[15]! >> 16) & 1;
m[14]! &= 0xffff;
// ti = m unless m borrowed (b = 1).
const keep = 1 - b;
for (let i = 0; i < 16; i++) ti[i] = ti[i]! + keep * (m[i]! - ti[i]!);
}
for (let i = 0; i < 16; i++) {
o[2 * i] = ti[i]! & 0xff;
o[2 * i + 1] = (ti[i]! >> 8) & 0xff;
}
t.fill(0);
ti.fill(0);
m.fill(0);
}
function par(a: GF): number {
const d = new Uint8Array(32);
pack(d, a);
const b = d[0]! & 1;
d.fill(0);
return b;
}
function add(o: GF, a: GF, b: GF): void {
for (let i = 0; i < 16; i++) o[i] = a[i]! + b[i]!;
}
function sub(o: GF, a: GF, b: GF): void {
for (let i = 0; i < 16; i++) o[i] = a[i]! - b[i]!;
}
// o = a · b, the loop of TweetNaCl: the limbs below 2^17 in absolute value,
// each product below 2^34, a sum of sixteen below 2^38 and the fold of the
// upper half (· 38) below 2^44. o may be a or b.
const T = new Float64Array(31);
function mul(o: GF, a: GF, b: GF): void {
T.fill(0);
for (let i = 0; i < 16; i++) {
const ai = a[i]!;
for (let j = 0; j < 16; j++) T[i + j]! += ai * b[j]!;
}
// 2^256 = 38 mod p.
for (let i = 0; i < 15; i++) T[i]! += 38 * T[i + 16]!;
for (let i = 0; i < 16; i++) o[i] = T[i]!;
car(o);
car(o);
T.fill(0);
}
// o = i^(p - 2) = 1/i.
function inv(o: GF, i: GF): void {
const c = gf();
set(c, i);
for (let a = 253; a >= 0; a--) {
mul(c, c, c);
if (a !== 2 && a !== 4) mul(c, c, i);
}
set(o, c);
c.fill(0);
}
// ---------------------------------------------------------------------------
// The group: edwards25519 in extended coordinates (X, Y, Z, T)
function modPow(b: bigint, e: bigint): bigint {
let r = 1n;
let x = b % P;
for (; e > 0n; e >>= 1n) {
if (e & 1n) r = (r * x) % P;
x = (x * x) % P;
}
return r;
}
const modInv = (v: bigint): bigint => modPow(((v % P) + P) % P, P - 2n);
// d = -121665/121666 and 2d, computed rather than copied.
const D = (((-121665n * modInv(121666n)) % P) + P) % P;
const D2 = gfOf((D * 2n) % P);
type Point = [GF, GF, GF, GF];
// The base point B = (x, 4/5) with x even (RFC 8032, 5.1), computed rather
// than copied: x² = (y² - 1)/(d·y² + 1), the root whose low bit is 0.
const BASE: Point = (() => {
const y = (4n * modInv(5n)) % P;
const y2 = (y * y) % P;
const x2 = (((y2 - 1n) * modInv((D * y2 + 1n) % P)) % P + P) % P;
// p = 5 mod 8: a root is x2^((p + 3)/8), times sqrt(-1) if its square is -x2.
let x = modPow(x2, (P + 3n) >> 3n);
/* v8 ignore next -- @preserve: a constant: for y = 4/5 the first candidate is already the root */
if ((x * x) % P !== x2) x = (x * modPow(2n, (P - 1n) >> 2n)) % P;
/* v8 ignore next -- @preserve: a constant: that root is odd */
if (x & 1n) x = P - x;
return [gfOf(x), gfOf(y), gf([1]), gfOf((x * y) % P)];
})();
const point = (): Point => [gf(), gf(), gf(), gf()];
function wipe(...vs: (GF | Uint8Array)[]): void {
for (const v of vs) v.fill(0);
}
// p = p + q, the unified addition of TweetNaCl (also a doubling).
function padd(p: Point, q: Point): void {
const [a, b, c, d, e, f, g, h, t] = [gf(), gf(), gf(), gf(), gf(), gf(), gf(), gf(), gf()];
sub(a, p[1], p[0]);
sub(t, q[1], q[0]);
mul(a, a, t);
add(b, p[0], p[1]);
add(t, q[0], q[1]);
mul(b, b, t);
mul(c, p[3], q[3]);
mul(c, c, D2);
mul(d, p[2], q[2]);
add(d, d, d);
sub(e, b, a);
sub(f, d, c);
add(g, d, c);
add(h, b, a);
mul(p[0], e, f);
mul(p[1], h, g);
mul(p[2], g, f);
mul(p[3], e, h);
wipe(a, b, c, d, e, f, g, h, t);
}
function cswap(p: Point, q: Point, b: number): void {
for (let i = 0; i < 4; i++) sel(p[i]!, q[i]!, b);
}
// The encoding of p: y, with the low bit of x in bit 255.
function ppack(r: Uint8Array, p: Point): void {
const [tx, ty, zi] = [gf(), gf(), gf()];
inv(zi, p[2]);
mul(tx, p[0], zi);
mul(ty, p[1], zi);
pack(r, ty);
r[31]! ^= par(tx) << 7;
wipe(tx, ty, zi);
}
// The encoding of [s]B, s 32 little-endian bytes: the ladder of TweetNaCl,
// with a conditional swap of the two points for each bit.
function scalarBase(s: Uint8Array): Uint8Array {
const p = point();
const q = point();
for (let i = 0; i < 4; i++) set(q[i]!, BASE[i]!);
p[1][0] = 1;
p[2][0] = 1;
for (let i = 255; i >= 0; i--) {
const b = (s[i >>> 3]! >>> (i & 7)) & 1;
cswap(p, q, b);
padd(q, p);
padd(p, p);
cswap(p, q, b);
}
const out = new Uint8Array(32);
ppack(out, p);
wipe(...p, ...q);
return out;
}
// ---------------------------------------------------------------------------
// Scalars modulo ℓ
/** ℓ, the order of the group of edwards25519. */
const ORDER = 2n ** 252n + 27742317777372353535851937790883648493n;
// ℓ in 32 little-endian bytes.
const L = Float64Array.from({ length: 32 }, (_, i) => Number((ORDER >> BigInt(8 * i)) & 0xffn));
// r = x mod ℓ, modL of TweetNaCl: x is 64 limbs, each a byte or a sum of
// products of bytes below 2^21, and r 32 bytes. x is consumed.
function modL(r: Uint8Array, x: Float64Array): void {
// Fold limbs 63 down to 32 into lower ones: 2^252 = -(ℓ - 2^252) mod ℓ,
// and 16 · 2^(8(i - 32)) · ℓ is subtracted for limb i.
for (let i = 63; i >= 32; --i) {
let carry = 0;
let j = i - 32;
const k = i - 12;
for (; j < k; ++j) {
x[j]! += carry - 16 * x[i]! * L[j - (i - 32)]!;
carry = Math.floor((x[j]! + 128) / 256);
x[j]! -= carry * 256;
}
x[j]! += carry;
x[i] = 0;
}
// The top four bits of limb 31, times ℓ, and the carries: TweetNaCl's
// x[31] >> 4, x[j] >> 8 and x[j] & 255, as floor divisions, which they are
// for values that fit 32 bits.
let carry = 0;
for (let j = 0; j < 32; j++) {
x[j]! += carry - Math.floor(x[31]! / 16) * L[j]!;
carry = Math.floor(x[j]! / 256);
x[j]! -= carry * 256;
}
for (let j = 0; j < 32; j++) x[j]! -= carry * L[j]!;
for (let i = 0; i < 32; i++) {
const c = Math.floor(x[i]! / 256);
x[i + 1]! += c;
r[i] = x[i]! - c * 256;
}
x.fill(0);
}
// The 64 bytes h as a scalar modulo ℓ, 32 bytes: reduce of TweetNaCl.
function reduce(h: Uint8Array): Uint8Array {
const x = Float64Array.from(h);
const r = new Uint8Array(32);
modL(r, x);
return r;
}
/** The 64 bytes `h`, little-endian, modulo ℓ, the order of the group, as 32 little-endian bytes: reduce of TweetNaCl. For the tests. */
export function ed25519ReduceScalar(h: Uint8Array): Uint8Array {
if (h.length !== 64) throw new RangeError(`ed25519: a scalar to reduce has 64 bytes, not ${h.length}`);
return reduce(h);
}
/** (a · b + c) mod ℓ for scalars of 32 little-endian bytes, each below 2^256: the S of a signature, as crypto_sign of TweetNaCl computes it. For the tests. */
export function ed25519MulAdd(a: Uint8Array, b: Uint8Array, c: Uint8Array): Uint8Array {
for (const v of [a, b, c]) if (v.length !== 32) throw new RangeError(`ed25519: a scalar has 32 bytes, not ${v.length}`);
const x = new Float64Array(64);
for (let i = 0; i < 32; i++) x[i] = c[i]!;
for (let i = 0; i < 32; i++) for (let j = 0; j < 32; j++) x[i + j]! += a[i]! * b[j]!;
const r = new Uint8Array(32);
modL(r, x);
return r;
}
// ---------------------------------------------------------------------------
// Signing
// SHA-512 of the seed, with its first half clamped: the secret scalar a and,
// in the second half, the prefix of the nonces (RFC 8032, 5.1.5).
function expand(seed: Uint8Array): Uint8Array {
const d = sha512(seed);
d[0]! &= 248;
d[31]! &= 127;
d[31]! |= 64;
return d;
}
function checkSeed(seed: Uint8Array): void {
if (!(seed instanceof Uint8Array) || seed.length !== ED25519_SEED_SIZE) {
throw new RangeError(`ed25519: a seed has ${ED25519_SEED_SIZE} bytes`);
}
}
/**
* The public key A of the Ed25519 `seed`, 32 bytes: the encoding of [a]B (RFC
* 8032, 5.1.5), as Go's ed25519.NewKeyFromSeed puts it in the second half of
* the private key.
*/
export function ed25519PublicKey(seed: Uint8Array): Uint8Array {
checkSeed(seed);
const d = expand(seed);
const pub = scalarBase(d.subarray(0, 32));
d.fill(0);
return pub;
}
/**
* The Ed25519 signature of `message` by the key of `seed`, whose public key
* is `publicKey`, 64 bytes (RFC 8032, 5.1.6), as Go's ed25519.Sign of the
* private key seed || publicKey: R = [r]B with r = SHA-512(prefix || message)
* mod ℓ, and S = (r + k · a) mod ℓ with k = SHA-512(R || publicKey || message)
* mod ℓ. Like Go, it hashes `publicKey` as it is given, without deriving it
* from the seed again: a caller that gives another one gets a signature that
* does not verify.
*/
export function ed25519Sign(seed: Uint8Array, publicKey: Uint8Array, message: Uint8Array): Uint8Array {
checkSeed(seed);
if (!(publicKey instanceof Uint8Array) || publicKey.length !== 32) throw new RangeError('ed25519: a public key has 32 bytes');
if (!(message instanceof Uint8Array)) throw new TypeError('ed25519: the message is a Uint8Array');
const d = expand(seed);
const nonceInput = new Uint8Array(32 + message.length);
nonceInput.set(d.subarray(32, 64));
nonceInput.set(message, 32);
const nonce = sha512(nonceInput);
nonceInput.fill(0, 0, 32);
const r = reduce(nonce);
nonce.fill(0);
const bigR = scalarBase(r);
const kInput = new Uint8Array(64 + message.length);
kInput.set(bigR);
kInput.set(publicKey, 32);
kInput.set(message, 64);
const k = reduce(sha512(kInput));
const x = new Float64Array(64);
for (let i = 0; i < 32; i++) x[i] = r[i]!;
for (let i = 0; i < 32; i++) for (let j = 0; j < 32; j++) x[i + j]! += k[i]! * d[j]!;
const sig = new Uint8Array(64);
sig.set(bigR);
const s = new Uint8Array(32);
modL(s, x);
sig.set(s, 32);
wipe(d, r, s);
return sig;
}

@ -0,0 +1,44 @@
// Tests of gounicode.ts: the three tables of the package unicode of Go that
// authorkey.ts reads keys with. scripts/go-unicode-tables.go writes them and
// the SHA-256 of each set, computed by Go from unicode.ToLower,
// unicode.ToUpper and unicode.IsSpace on every code point; the tests compute
// the same sets from the tables. The vectors of authorkey.test.ts check the
// edges of each set against strings.ToLower, strings.ToUpper and
// strings.TrimSpace.
import { createHash } from 'node:crypto';
import { describe, expect, it } from 'vitest';
import { GO_UNICODE_SUMS, GO_UNICODE_VERSION, goIsSpace, goLowerChanges, goUpperChanges } from './gounicode.ts';
function sum(member: (r: number) => boolean): string {
const bits = new Uint8Array(0x110000 / 8);
for (let r = 0; r < 0x110000; r++) {
if (r >= 0xd800 && r <= 0xdfff) continue;
if (member(r)) bits[r >> 3]! |= 1 << (r & 7);
}
return createHash('sha256').update(bits).digest('hex');
}
describe('the tables of the package unicode of Go', () => {
it('are those of Unicode 15.0.0, the version of Go 1.26', () => {
expect(GO_UNICODE_VERSION).toBe('15.0.0');
});
it('hold the sets that Go wrote, code point by code point', () => {
expect(sum(goLowerChanges)).toBe(GO_UNICODE_SUMS.lower);
expect(sum(goUpperChanges)).toBe(GO_UNICODE_SUMS.upper);
expect(sum(goIsSpace)).toBe(GO_UNICODE_SUMS.space);
});
it('answer as Go for a few known code points', () => {
expect(goLowerChanges(0x41)).toBe(true);
expect(goLowerChanges(0x61)).toBe(false);
expect(goUpperChanges(0x61)).toBe(true);
expect(goUpperChanges(0x41)).toBe(false);
// İ lowers to i; Dž changes both ways.
expect(goLowerChanges(0x130)).toBe(true);
expect(goLowerChanges(0x1c5) && goUpperChanges(0x1c5)).toBe(true);
for (const r of [0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x20, 0x85, 0xa0, 0x1680, 0x2000, 0x200a, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000]) expect(goIsSpace(r)).toBe(true);
for (const r of [0x00, 0x1c, 0x200b, 0xfeff, 0x180e, 0x10ffff]) expect(goIsSpace(r)).toBe(false);
});
});

@ -0,0 +1,347 @@
// Code generated by scripts/go-unicode-tables.go with Go go1.26.8, Unicode 15.0.0.
// DO NOT EDIT: regenerate it.
//
// The code points that Go's strings.ToLower, strings.ToUpper and
// strings.TrimSpace depend on, as the package unicode of Go defines them:
// authorkey.ts reads the strings of a key and the lines of a key file with
// them, so that its errors are those of Go whatever the bytes. They are not
// the case mapping of the JavaScript engine (toLowerCase, \p{…}), whose
// version of Unicode changes with the engine, nor the tables of the path
// rules (Unicode 18.0.0, spec §29.5.1). Internal: index.ts does not
// re-export it.
/** The version of Unicode of the package unicode of Go go1.26.8. */
export const GO_UNICODE_VERSION = '15.0.0';
/** The SHA-256 of the bit sets of the three tables (bit r at byte r >> 3), which the tests recompute. */
export const GO_UNICODE_SUMS = { lower: 'a73d47b07cce68eab6cd53abd7a324f65ce9885537e193717036f76d830ac8c7', upper: '55e65308a94127773a479f86ae753f226a14adad05bbd080df3816209a0c65ef', space: '7a31ef4efeb7d36ddf0d6f6f48d73ce1263ef6f4598611449d36fac589baa8e7' } as const;
// The runs [first, last, stride] of r with unicode.ToLower(r) != r.
const LOWER_CHANGES: readonly number[] = [
0x0041, 0x005a, 1,
0x00c0, 0x00d6, 1,
0x00d8, 0x00de, 1,
0x0100, 0x0136, 2,
0x0139, 0x0147, 2,
0x014a, 0x0178, 2,
0x0179, 0x017d, 2,
0x0181, 0x0182, 1,
0x0184, 0x0184, 1,
0x0186, 0x0187, 1,
0x0189, 0x018b, 1,
0x018e, 0x0191, 1,
0x0193, 0x0194, 1,
0x0196, 0x0198, 1,
0x019c, 0x019d, 1,
0x019f, 0x01a0, 1,
0x01a2, 0x01a6, 2,
0x01a7, 0x01a7, 1,
0x01a9, 0x01a9, 1,
0x01ac, 0x01ac, 1,
0x01ae, 0x01af, 1,
0x01b1, 0x01b3, 1,
0x01b5, 0x01b5, 1,
0x01b7, 0x01b8, 1,
0x01bc, 0x01bc, 1,
0x01c4, 0x01c5, 1,
0x01c7, 0x01c8, 1,
0x01ca, 0x01cb, 1,
0x01cd, 0x01db, 2,
0x01de, 0x01ee, 2,
0x01f1, 0x01f2, 1,
0x01f4, 0x01f4, 1,
0x01f6, 0x01f8, 1,
0x01fa, 0x0232, 2,
0x023a, 0x023b, 1,
0x023d, 0x023e, 1,
0x0241, 0x0241, 1,
0x0243, 0x0246, 1,
0x0248, 0x024e, 2,
0x0370, 0x0370, 1,
0x0372, 0x0372, 1,
0x0376, 0x0376, 1,
0x037f, 0x037f, 1,
0x0386, 0x0386, 1,
0x0388, 0x038a, 1,
0x038c, 0x038c, 1,
0x038e, 0x038f, 1,
0x0391, 0x03a1, 1,
0x03a3, 0x03ab, 1,
0x03cf, 0x03cf, 1,
0x03d8, 0x03ee, 2,
0x03f4, 0x03f4, 1,
0x03f7, 0x03f7, 1,
0x03f9, 0x03fa, 1,
0x03fd, 0x042f, 1,
0x0460, 0x0480, 2,
0x048a, 0x04c0, 2,
0x04c1, 0x04cd, 2,
0x04d0, 0x052e, 2,
0x0531, 0x0556, 1,
0x10a0, 0x10c5, 1,
0x10c7, 0x10c7, 1,
0x10cd, 0x10cd, 1,
0x13a0, 0x13f5, 1,
0x1c90, 0x1cba, 1,
0x1cbd, 0x1cbf, 1,
0x1e00, 0x1e94, 2,
0x1e9e, 0x1efe, 2,
0x1f08, 0x1f0f, 1,
0x1f18, 0x1f1d, 1,
0x1f28, 0x1f2f, 1,
0x1f38, 0x1f3f, 1,
0x1f48, 0x1f4d, 1,
0x1f59, 0x1f5f, 2,
0x1f68, 0x1f6f, 1,
0x1f88, 0x1f8f, 1,
0x1f98, 0x1f9f, 1,
0x1fa8, 0x1faf, 1,
0x1fb8, 0x1fbc, 1,
0x1fc8, 0x1fcc, 1,
0x1fd8, 0x1fdb, 1,
0x1fe8, 0x1fec, 1,
0x1ff8, 0x1ffc, 1,
0x2126, 0x2126, 1,
0x212a, 0x212b, 1,
0x2132, 0x2132, 1,
0x2160, 0x216f, 1,
0x2183, 0x2183, 1,
0x24b6, 0x24cf, 1,
0x2c00, 0x2c2f, 1,
0x2c60, 0x2c60, 1,
0x2c62, 0x2c64, 1,
0x2c67, 0x2c6d, 2,
0x2c6e, 0x2c70, 1,
0x2c72, 0x2c72, 1,
0x2c75, 0x2c75, 1,
0x2c7e, 0x2c80, 1,
0x2c82, 0x2ce2, 2,
0x2ceb, 0x2ceb, 1,
0x2ced, 0x2ced, 1,
0x2cf2, 0x2cf2, 1,
0xa640, 0xa66c, 2,
0xa680, 0xa69a, 2,
0xa722, 0xa72e, 2,
0xa732, 0xa76e, 2,
0xa779, 0xa77d, 2,
0xa77e, 0xa786, 2,
0xa78b, 0xa78b, 1,
0xa78d, 0xa78d, 1,
0xa790, 0xa790, 1,
0xa792, 0xa792, 1,
0xa796, 0xa7aa, 2,
0xa7ab, 0xa7ae, 1,
0xa7b0, 0xa7b4, 1,
0xa7b6, 0xa7c4, 2,
0xa7c5, 0xa7c7, 1,
0xa7c9, 0xa7c9, 1,
0xa7d0, 0xa7d0, 1,
0xa7d6, 0xa7d6, 1,
0xa7d8, 0xa7d8, 1,
0xa7f5, 0xa7f5, 1,
0xff21, 0xff3a, 1,
0x10400, 0x10427, 1,
0x104b0, 0x104d3, 1,
0x10570, 0x1057a, 1,
0x1057c, 0x1058a, 1,
0x1058c, 0x10592, 1,
0x10594, 0x10595, 1,
0x10c80, 0x10cb2, 1,
0x118a0, 0x118bf, 1,
0x16e40, 0x16e5f, 1,
0x1e900, 0x1e921, 1,
];
// The runs [first, last, stride] of r with unicode.ToUpper(r) != r.
const UPPER_CHANGES: readonly number[] = [
0x0061, 0x007a, 1,
0x00b5, 0x00b5, 1,
0x00e0, 0x00f6, 1,
0x00f8, 0x00ff, 1,
0x0101, 0x0137, 2,
0x013a, 0x0148, 2,
0x014b, 0x0177, 2,
0x017a, 0x017e, 2,
0x017f, 0x0180, 1,
0x0183, 0x0183, 1,
0x0185, 0x0185, 1,
0x0188, 0x0188, 1,
0x018c, 0x018c, 1,
0x0192, 0x0192, 1,
0x0195, 0x0195, 1,
0x0199, 0x019a, 1,
0x019e, 0x019e, 1,
0x01a1, 0x01a5, 2,
0x01a8, 0x01a8, 1,
0x01ad, 0x01ad, 1,
0x01b0, 0x01b0, 1,
0x01b4, 0x01b4, 1,
0x01b6, 0x01b6, 1,
0x01b9, 0x01b9, 1,
0x01bd, 0x01bd, 1,
0x01bf, 0x01bf, 1,
0x01c5, 0x01c6, 1,
0x01c8, 0x01c9, 1,
0x01cb, 0x01cc, 1,
0x01ce, 0x01dc, 2,
0x01dd, 0x01ef, 2,
0x01f2, 0x01f3, 1,
0x01f5, 0x01f5, 1,
0x01f9, 0x021f, 2,
0x0223, 0x0233, 2,
0x023c, 0x023c, 1,
0x023f, 0x0240, 1,
0x0242, 0x0242, 1,
0x0247, 0x024f, 2,
0x0250, 0x0254, 1,
0x0256, 0x0257, 1,
0x0259, 0x0259, 1,
0x025b, 0x025c, 1,
0x0260, 0x0261, 1,
0x0263, 0x0263, 1,
0x0265, 0x0266, 1,
0x0268, 0x026c, 1,
0x026f, 0x026f, 1,
0x0271, 0x0272, 1,
0x0275, 0x0275, 1,
0x027d, 0x027d, 1,
0x0280, 0x0280, 1,
0x0282, 0x0283, 1,
0x0287, 0x028c, 1,
0x0292, 0x0292, 1,
0x029d, 0x029e, 1,
0x0345, 0x0345, 1,
0x0371, 0x0371, 1,
0x0373, 0x0373, 1,
0x0377, 0x0377, 1,
0x037b, 0x037d, 1,
0x03ac, 0x03af, 1,
0x03b1, 0x03ce, 1,
0x03d0, 0x03d1, 1,
0x03d5, 0x03d7, 1,
0x03d9, 0x03ef, 2,
0x03f0, 0x03f3, 1,
0x03f5, 0x03f5, 1,
0x03f8, 0x03f8, 1,
0x03fb, 0x03fb, 1,
0x0430, 0x045f, 1,
0x0461, 0x0481, 2,
0x048b, 0x04bf, 2,
0x04c2, 0x04ce, 2,
0x04cf, 0x052f, 2,
0x0561, 0x0586, 1,
0x10d0, 0x10fa, 1,
0x10fd, 0x10ff, 1,
0x13f8, 0x13fd, 1,
0x1c80, 0x1c88, 1,
0x1d79, 0x1d79, 1,
0x1d7d, 0x1d7d, 1,
0x1d8e, 0x1d8e, 1,
0x1e01, 0x1e95, 2,
0x1e9b, 0x1e9b, 1,
0x1ea1, 0x1eff, 2,
0x1f00, 0x1f07, 1,
0x1f10, 0x1f15, 1,
0x1f20, 0x1f27, 1,
0x1f30, 0x1f37, 1,
0x1f40, 0x1f45, 1,
0x1f51, 0x1f57, 2,
0x1f60, 0x1f67, 1,
0x1f70, 0x1f7d, 1,
0x1f80, 0x1f87, 1,
0x1f90, 0x1f97, 1,
0x1fa0, 0x1fa7, 1,
0x1fb0, 0x1fb1, 1,
0x1fb3, 0x1fb3, 1,
0x1fbe, 0x1fbe, 1,
0x1fc3, 0x1fc3, 1,
0x1fd0, 0x1fd1, 1,
0x1fe0, 0x1fe1, 1,
0x1fe5, 0x1fe5, 1,
0x1ff3, 0x1ff3, 1,
0x214e, 0x214e, 1,
0x2170, 0x217f, 1,
0x2184, 0x2184, 1,
0x24d0, 0x24e9, 1,
0x2c30, 0x2c5f, 1,
0x2c61, 0x2c61, 1,
0x2c65, 0x2c66, 1,
0x2c68, 0x2c6c, 2,
0x2c73, 0x2c73, 1,
0x2c76, 0x2c76, 1,
0x2c81, 0x2ce3, 2,
0x2cec, 0x2cec, 1,
0x2cee, 0x2cee, 1,
0x2cf3, 0x2cf3, 1,
0x2d00, 0x2d25, 1,
0x2d27, 0x2d27, 1,
0x2d2d, 0x2d2d, 1,
0xa641, 0xa66d, 2,
0xa681, 0xa69b, 2,
0xa723, 0xa72f, 2,
0xa733, 0xa76f, 2,
0xa77a, 0xa77a, 1,
0xa77c, 0xa77c, 1,
0xa77f, 0xa787, 2,
0xa78c, 0xa78c, 1,
0xa791, 0xa791, 1,
0xa793, 0xa794, 1,
0xa797, 0xa7a9, 2,
0xa7b5, 0xa7c3, 2,
0xa7c8, 0xa7c8, 1,
0xa7ca, 0xa7ca, 1,
0xa7d1, 0xa7d1, 1,
0xa7d7, 0xa7d7, 1,
0xa7d9, 0xa7d9, 1,
0xa7f6, 0xa7f6, 1,
0xab53, 0xab53, 1,
0xab70, 0xabbf, 1,
0xff41, 0xff5a, 1,
0x10428, 0x1044f, 1,
0x104d8, 0x104fb, 1,
0x10597, 0x105a1, 1,
0x105a3, 0x105b1, 1,
0x105b3, 0x105b9, 1,
0x105bb, 0x105bc, 1,
0x10cc0, 0x10cf2, 1,
0x118c0, 0x118df, 1,
0x16e60, 0x16e7f, 1,
0x1e922, 0x1e943, 1,
];
// The runs [first, last, stride] of r with unicode.IsSpace(r).
const SPACES: readonly number[] = [
0x0009, 0x000d, 1,
0x0020, 0x0020, 1,
0x0085, 0x0085, 1,
0x00a0, 0x00a0, 1,
0x1680, 0x1680, 1,
0x2000, 0x200a, 1,
0x2028, 0x2029, 1,
0x202f, 0x202f, 1,
0x205f, 0x205f, 1,
0x3000, 0x3000, 1,
];
// Whether r is in the runs of table, by binary search on the last points.
function inRuns(table: readonly number[], r: number): boolean {
let lo = 0;
let hi = table.length / 3;
while (lo < hi) {
const m = (lo + hi) >>> 1;
if (table[3 * m + 1]! < r) lo = m + 1;
else hi = m;
}
if (lo === table.length / 3) return false;
const first = table[3 * lo]!;
return r >= first && (r - first) % table[3 * lo + 2]! === 0;
}
/** Whether Go's unicode.ToLower changes the code point r. */
export const goLowerChanges = (r: number): boolean => inRuns(LOWER_CHANGES, r);
/** Whether Go's unicode.ToUpper changes the code point r. */
export const goUpperChanges = (r: number): boolean => inRuns(UPPER_CHANGES, r);
/** Whether r is a space for Go's unicode.IsSpace. */
export const goIsSpace = (r: number): boolean => inRuns(SPACES, r);

File diff suppressed because one or more lines are too long

@ -74,6 +74,10 @@ export default defineConfig({
'src/lib/dkc/ipaddr.ts': { 100: true },
'src/lib/dkc/ageio.ts': { 100: true },
'src/lib/dkc/envelope.ts': { 100: true },
// The author keys, their signing and the tables of Go they read with.
'src/lib/dkc/authorkey.ts': { 100: true },
'src/lib/dkc/ed25519sign.ts': { 100: true },
'src/lib/dkc/gounicode.ts': { 100: true },
'src/lib/dkc/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 },
// The page model and helpers of the inspector (plan §8, phase 1).
'src/lib/inspector/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 },

Loading…
Cancel
Save

Powered by TurnKey Linux.