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
parent
28f0c3f524
commit
96614d5e79
@ -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)
|
||||||
|
}
|
||||||
@ -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,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
Loading…
Reference in new issue