diff --git a/CHANGELOG.md b/CHANGELOG.md index 28797b5..098434b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ Cambios notables de la librería TypeScript y de la página. El proyecto usa ver ### `testdata` en `spec-v0.11` (01-10-2026) - `testdata` se sincroniza con el tag `spec-v0.11` de `datekeys-go` (`ae33434`), y `SPEC_VERSION` pasa a `0.11`. Trae tres fixtures (`format3_signed`, con firma de clave propia; `format3_signed_cms`, con dos certificados sellados; `format3_sealed`, con firma y sello RFC 3161) y tres ficheros de vectores (`ed25519_strict.json`, `security_cms.json` y `locator.json`). El corpus de mutaciones pasa a 210 casos, con uno fuera del §64: una firma de `alg` 1 que no verifica, F2. `ibe-vectors.json` añade los tres fixtures y rehace los de `format3_signature_unsupported` y `format3_seal_unsupported`; `mutation-texts.json` se rehace con el `capsule.Open` de esa referencia. -- **Lo que esta biblioteca no hace todavía:** lee el área de seguridad como un lector de la v0.10, así que una firma o un sello que la referencia comprueba da F1 o S1 aquí, y la referencia da F2 a F6 y S3 a S5. El tipo `Verdict` y los textos ya los conocen. Los tests lo dicen en lugar de ocultarlo: `testing/pending.ts` da lo que esta biblioteca devuelve donde la referencia registra una verificación, y un bloque de `vectors.test.ts` comprueba la estructura de los vectores que aún no se portan. Portar la verificación (§29.8 a §29.11 y el localizador del §44.1) hace esas funciones la identidad. +- **La firma de clave propia, `alg` 1** (§29.8, §29.9), portada: `ed25519strict.ts` comprueba las cuatro condiciones del perfil estricto con la aritmética de `@noble/curves` (que solo ofrece la ecuación con cofactor) y da la respuesta de Go en los 18 vectores de `ed25519_strict.json`; `author.ts` calcula `payload_commit`, `control_commit`, `head_digest`, `signers_digest`, `AUTHOR_MESSAGE` y su código, y los registros de `format3_signed` los confirman. `evaluateSecurity(área, contexto)` da F2, F3 y F4, `open` pasa el contexto del control y del head, y `OpenOptions.authorKeys` son las claves que la persona guardó. Los dos módulos usan solo `@noble/curves` y `@noble/hashes`, que ya iban en el bundle: ningún paquete nuevo, y entran en la lista de quien puede importar noble. +- **Lo que esta biblioteca no hace todavía:** la firma con certificados (`alg` 2: F5, F6) y el sello RFC 3161 (S3 a S5), que lee como un lector de la v0.10 y dan F1 o S1 aquí; y el localizador del §44.1. El tipo `Verdict` y los textos ya los conocen. Los tests lo dicen en lugar de ocultarlo: `testing/pending.ts` da lo que esta biblioteca devuelve donde la referencia registra esas verificaciones, y un bloque de `vectors.test.ts` comprueba la estructura de los vectores que aún no se portan. Portarlas hace esas funciones la identidad. El formato 3 de la especificación 0.10, según `PLAN_formato3_ts.md` (en `../docs`). La versión que lo publique la decide el autor. diff --git a/README.md b/README.md index 94db07a..a8d1dc3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # datekeys-ts -Implementación en TypeScript del protocolo DateKeys (formato 3 de la v0.10; con los datos de prueba de la v0.11, cuya verificación de firmas y sellos está pendiente) y página de prueba en el navegador. Sustituye al prototipo, archivado en `../archive/prototype` (API Quicknet en Go, CLI tlock y cliente Svelte, commit `4d2b0a1`). +Implementación en TypeScript del protocolo DateKeys (formato 3 de la v0.10; de la v0.11, la firma de clave propia; la firma con certificados, el sello y el localizador están pendientes) y página de prueba en el navegador. Sustituye al prototipo, archivado en `../archive/prototype` (API Quicknet en Go, CLI tlock y cliente Svelte, commit `4d2b0a1`). La implementación de referencia es la librería Go `g.activething.com/go/DateKeys`, en `../datekeys-go`. Los planes y el estado del trabajo están en `../docs`, el repositorio privado de documentación del proyecto. diff --git a/src/lib/dependencies.test.ts b/src/lib/dependencies.test.ts index 11415b5..5b7610e 100644 --- a/src/lib/dependencies.test.ts +++ b/src/lib/dependencies.test.ts @@ -15,8 +15,8 @@ // 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 digest.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 author.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, // @noble/hashes or @noble/ciphers, the root copies that those files // resolve to. // @@ -46,7 +46,14 @@ const NOBLE = ['@noble/ciphers', '@noble/curves', '@noble/hashes']; // and releases are verified locally (plan decisions 1 and 4). const FORBIDDEN = ['tlock-js', 'drand-client']; // The only files besides the tests that may import noble. -const NOBLE_IMPORTERS = ['src/lib/dkc/digest.ts', 'src/lib/dkc/ibe.ts', 'src/lib/dkc/release.ts', 'src/lib/dkc/x25519.ts']; +const NOBLE_IMPORTERS = [ + 'src/lib/dkc/author.ts', + 'src/lib/dkc/digest.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']; @@ -61,8 +68,10 @@ const WRITER_IMPORTER_DIRS = ['src/lib/dkc/testing/']; // paths, whose Unicode tables load with the opening and the writer. const NOT_IN_INDEX = [ 'agefile.ts', + 'author.ts', 'bech32.ts', 'digest.ts', + 'ed25519strict.ts', 'encrypt.ts', 'head.ts', 'ibe.ts', @@ -205,7 +214,7 @@ describe('runtime dependencies', () => { } }); - it('only digest.ts, ibe.ts, release.ts, x25519.ts and the tests import noble, only from @noble/curves, @noble/hashes and @noble/ciphers, and nothing imports tlock-js or drand-client', () => { + it('only author.ts, digest.ts, ed25519strict.ts, ibe.ts, release.ts, x25519.ts and the tests import noble, only from @noble/curves, @noble/hashes and @noble/ciphers, and nothing imports tlock-js or drand-client', () => { expect(importProblems(sources())).toEqual([]); }); diff --git a/src/lib/dkc/author.test.ts b/src/lib/dkc/author.test.ts new file mode 100644 index 0000000..02abbf7 --- /dev/null +++ b/src/lib/dkc/author.test.ts @@ -0,0 +1,135 @@ +// Tests of author.ts and of the verdicts of a signature of alg 1 (spec v0.11, +// §29.7 to §29.9) against format3_signed, the fixture of the Go reference whose +// record gives every commitment, the message and the key. + +import { describe, expect, it } from 'vitest'; +import { ALG_CMS, ALG_ED25519, AUTHOR_MESSAGE_SIZE, authorCode, authorMessage, controlCommit, headDigest, payloadCommit, signersDigest } from './author.ts'; +import { bech32Decode, bech32Encode } from './bech32.ts'; +import { Encoder } from './cbor.ts'; +import { type Control, decodeControl, encodeControl } from './control.ts'; +import { FORMAT_3 } from './framing.ts'; +import { encodeSecurity, evaluateSecurity, type SecurityContext, verdictLines } from './security.ts'; +import { h, hx, readJSON } from './testing/testdata.ts'; + +interface Record { + control_cbor: string; + head_cbor: string; + security_cbor: string; + signature: { + control_commit: string; + head_digest: string; + signers_digest: string; + author_message: string; + author_code: string; + author_key: string; + signature: string; + }; + verdicts: { lines: string[] }; +} + +const fx = readJSON('fixtures/format3_signed.json'); +const control = decodeControl(h(fx.control_cbor), FORMAT_3); +const context = (): SecurityContext => ({ controlCommit: controlCommit(control, FORMAT_3), headDigest: headDigest(h(fx.head_cbor)) }); +const security = h(fx.security_cbor); +const key = bech32Decode(fx.signature.author_key).data; +const value = h(fx.signature.signature); + +// SECURITY_CBOR with an author-signature of alg `alg`, the key and the signature value given. +function area(alg: number, k: Uint8Array, v: Uint8Array): Uint8Array { + const c = new Encoder(); + c.map(3); + c.uint(0); + c.uint(alg); + c.uint(1); + c.bstr(k); + c.uint(2); + c.bstr(v); + return withKey2(c.out()); +} + +function withKey2(content: Uint8Array): Uint8Array { + const e = new Encoder(); + e.map(3); + e.uint(0); + e.text('datekeys-security'); + e.uint(1); + e.uint(1); + e.uint(2); + e.bstr(content); + return e.out(); +} + +describe('what is signed', () => { + it('computes the commitments, the message and the code that the reference records', () => { + const cc = controlCommit(control, FORMAT_3); + const hd = headDigest(h(fx.head_cbor)); + const sd = signersDigest(ALG_ED25519); + const message = authorMessage(cc, hd, sd); + expect([hx(cc), hx(hd), hx(sd), new TextDecoder().decode(message), authorCode(message)]).toEqual([ + fx.signature.control_commit, + fx.signature.head_digest, + fx.signature.signers_digest, + fx.signature.author_message, + fx.signature.author_code, + ]); + expect(message.length).toBe(AUTHOR_MESSAGE_SIZE); + expect(AUTHOR_MESSAGE_SIZE).toBe(99); + expect(authorCode(message.subarray(1))).toBe(''); + }); + + it('binds alg and the list of signers, and leaves L out and I_PAYLOAD hidden', () => { + expect(hx(signersDigest(ALG_ED25519))).not.toBe(hx(signersDigest(ALG_CMS))); + expect(hx(signersDigest(ALG_CMS, Uint8Array.of(1)))).not.toBe(hx(signersDigest(ALG_CMS, Uint8Array.of(2)))); + const base = controlCommit(control, FORMAT_3); + // L does not enter control_commit, so an area can grow after signing. + expect(hx(controlCommit({ ...control, payloadLength: 1 << 30 }, FORMAT_3))).toBe(hx(base)); + // I_PAYLOAD does, through its commitment. + const other: Control = { ...control, payloadIdentity: control.payloadIdentity.map((b) => b ^ 1) }; + expect(hx(controlCommit(other, FORMAT_3))).not.toBe(hx(base)); + expect(hx(payloadCommit(control.payloadIdentity))).not.toBe(hx(control.payloadIdentity)); + expect(encodeControl(control, FORMAT_3)).toHaveLength(encodeControl(other, FORMAT_3).length); + }); +}); + +describe('the verdicts of a signature of alg 1', () => { + it('gives F4 with the key of the signature, and F3 when the person saved it', () => { + const v = evaluateSecurity(security, context()); + expect([v.signature, v.seal, hx(v.authorKey!)]).toEqual(['F4', 'S0', hx(key)]); + expect(verdictLines(v)).toEqual(fx.verdicts.lines); + + const saved = evaluateSecurity(security, { ...context(), authorKeys: new Map([[fx.signature.author_key, 'Ana']]) }); + expect([saved.signature, saved.authorLabel]).toEqual(['F3', 'Ana']); + expect(verdictLines(saved)).toEqual(['Firmado con la clave que guardaste como Ana.']); + // Another saved key is not this one. + const luis = new Map([[bech32Encode('dkauthor', new Uint8Array(32).fill(7)), 'Luis']]); + expect(evaluateSecurity(security, { ...context(), authorKeys: luis }).signature).toBe('F4'); + }); + + it('gives F2 in another capsule, and F1 without a context or for what it does not check', () => { + expect(evaluateSecurity(security, { ...context(), headDigest: headDigest(Uint8Array.of(1)) }).signature).toBe('F2'); + expect(evaluateSecurity(security, { ...context(), controlCommit: new Uint8Array(32) }).signature).toBe('F2'); + expect(evaluateSecurity(security).signature).toBe('F1'); + + expect(evaluateSecurity(area(ALG_ED25519, key, value), context()).signature).toBe('F4'); + const flipped = value.slice(); + flipped[5] = flipped[5]! ^ 1; + expect(evaluateSecurity(area(ALG_ED25519, key, flipped), context()).signature).toBe('F2'); + expect(evaluateSecurity(area(ALG_CMS, key, value), context()).signature).toBe('F1'); + expect(evaluateSecurity(area(4294967295, key, value), context()).signature).toBe('F1'); + expect(evaluateSecurity(area(ALG_ED25519, key.subarray(1), value), context()).signature).toBe('F1'); + expect(evaluateSecurity(area(ALG_ED25519, key, value.subarray(1)), context()).signature).toBe('F1'); + // Content that does not decode, or whose map has a key that no version defines. + expect(evaluateSecurity(withKey2(Uint8Array.of(0xff)), context()).signature).toBe('F1'); + const unknown = new Encoder(); + unknown.map(3); + unknown.uint(0); + unknown.uint(ALG_ED25519); + unknown.uint(1); + unknown.bstr(key); + unknown.uint(3); + unknown.bstr(value); + expect(evaluateSecurity(withKey2(unknown.out()), context()).signature).toBe('F1'); + // No signature: F0, whatever the context. + expect(evaluateSecurity(encodeSecurity(), context()).signature).toBe('F0'); + }); +}); diff --git a/src/lib/dkc/author.ts b/src/lib/dkc/author.ts new file mode 100644 index 0000000..2f6426a --- /dev/null +++ b/src/lib/dkc/author.ts @@ -0,0 +1,77 @@ +// What an author signs and a seal seals (spec v0.11, §29.8, §29.11), as the Go +// package capsule computes it (signature.go): payload_commit, control_commit +// over CONTROL_SIG, head_digest, signers_digest, and AUTHOR_MESSAGE, the ASCII +// text of 99 bytes that is signed, with its code. Every value is recomputed +// from the capsule once it is open; none is stored. Internal: index.ts does +// not re-export it. + +import { sha256 } from '@noble/hashes/sha2.js'; +import { concatBytes, toHex } from './bytes.ts'; +import { type Control, encodeControl } from './control.ts'; +import type { Format } from './framing.ts'; + +const PAYLOAD_COMMIT_PREFIX = 'datekeys:dkc3:payload:v1'; +const CONTROL_COMMIT_PREFIX = 'datekeys:dkc3:control:v1'; +const HEAD_DIGEST_PREFIX = 'datekeys:dkc3:head:v1'; +const SIGNERS_DIGEST_PREFIX = 'datekeys:dkc3:signers:v1'; +/** The first line of AUTHOR_MESSAGE. */ +export const AUTHOR_MESSAGE_PREFIX = 'datekeys:dkc3:author-signature:v1'; +/** The length of AUTHOR_MESSAGE: the prefix, a line feed, the 64 hexadecimal digits of its digest and a line feed. */ +export const AUTHOR_MESSAGE_SIZE = AUTHOR_MESSAGE_PREFIX.length + 1 + 64 + 1; + +/** The values of alg that spec v0.11 defines (§29.3). */ +export const ALG_ED25519 = 1; +export const ALG_CMS = 2; + +const text = new TextEncoder(); + +// SHA-256(prefix || 0x00 || parts...): each prefix is ASCII and is followed by a byte 0x00. +function domainHash(prefix: string, ...parts: Uint8Array[]): Uint8Array { + return sha256(concatBytes(text.encode(prefix), Uint8Array.of(0), ...parts)); +} + +/** The commitment to I_PAYLOAD that replaces it in what is signed (spec §29.8). */ +export function payloadCommit(identity: Uint8Array): Uint8Array { + return domainHash(PAYLOAD_COMMIT_PREFIX, identity); +} + +/** + * control_commit: the hash of CONTROL_SIG, the control of a capsule of format + * 3 with payload_commit in place of I_PAYLOAD and the eight bytes of L at + * zero (spec §29.8). It does not depend on L, so the area can grow after + * signing. The encoded control holds the commitment and not I_PAYLOAD, and + * is wiped. + */ +export function controlCommit(c: Control, format: Format): Uint8Array { + const b = encodeControl({ ...c, payloadIdentity: payloadCommit(c.payloadIdentity), payloadLength: 0 }, format); + try { + return domainHash(CONTROL_COMMIT_PREFIX, b); + } finally { + b.fill(0); + } +} + +/** head_digest, the hash of HEAD_CBOR (spec §29.8). The salt of the head makes it a commitment that hides the files. */ +export function headDigest(head: Uint8Array): Uint8Array { + return domainHash(HEAD_DIGEST_PREFIX, head); +} + +/** signers_digest for alg and the exact content of key 1 of a signature of alg 2, `signers`; none with alg 1 (spec §29.8). */ +export function signersDigest(alg: number, signers?: Uint8Array): Uint8Array { + const a = new Uint8Array(4); + new DataView(a.buffer).setUint32(0, alg); + return domainHash(SIGNERS_DIGEST_PREFIX, a, signers ?? new Uint8Array(0)); +} + +/** AUTHOR_MESSAGE: the prefix, a line feed, the hexadecimal digest D of the three commitments and a line feed (spec §29.8). */ +export function authorMessage(controlCommitment: Uint8Array, headDigestValue: Uint8Array, signersDigestValue: Uint8Array): Uint8Array { + const d = sha256(concatBytes(controlCommitment, headDigestValue, signersDigestValue)); + return text.encode(`${AUTHOR_MESSAGE_PREFIX}\n${toHex(d)}\n`); +} + +/** The code of AUTHOR_MESSAGE that a person compares before signing: the first 8 hexadecimal digits of its digest, in two groups of 4. */ +export function authorCode(message: Uint8Array): string { + if (message.length !== AUTHOR_MESSAGE_SIZE) return ''; + const d = new TextDecoder().decode(message.subarray(AUTHOR_MESSAGE_PREFIX.length + 1)); + return `${d.slice(0, 4)}-${d.slice(4, 8)}`; +} diff --git a/src/lib/dkc/ed25519strict.test.ts b/src/lib/dkc/ed25519strict.test.ts new file mode 100644 index 0000000..5bbd281 --- /dev/null +++ b/src/lib/dkc/ed25519strict.test.ts @@ -0,0 +1,48 @@ +// Tests of ed25519strict.ts against the vectors of the Go reference: the +// strict profile of the author signature (spec v0.11, §29.9). + +import { describe, expect, it } from 'vitest'; +import { verifyStrict } from './ed25519strict.ts'; +import { h, readJSON } from './testing/testdata.ts'; + +interface Vector { + name: string; + message: string; + public_key: string; + signature: string; + valid: boolean; + stdlib: boolean; +} + +describe('verifyStrict', () => { + const file = readJSON<{ vectors: Vector[] }>('vectors/ed25519_strict.json'); + + it('gives the answer of the reference on every vector of ed25519_strict.json', () => { + expect(file.vectors.length).toBeGreaterThanOrEqual(18); + for (const v of file.vectors) { + expect(verifyStrict(h(v.public_key), h(v.message), h(v.signature)), v.name).toBe(v.valid); + } + }); + + it('refuses what a looser verifier accepts: the cases where the standard library and the profile differ are in the file', () => { + const loose = file.vectors.filter((v) => v.stdlib && !v.valid); + expect(loose.length).toBeGreaterThan(0); + for (const v of loose) expect(verifyStrict(h(v.public_key), h(v.message), h(v.signature)), v.name).toBe(false); + }); + + it('refuses a key or a signature of another length', () => { + const v = file.vectors[0]!; + expect(verifyStrict(h(v.public_key).subarray(1), h(v.message), h(v.signature))).toBe(false); + expect(verifyStrict(h(v.public_key), h(v.message), h(v.signature).subarray(1))).toBe(false); + expect(verifyStrict(h(v.public_key), h(v.message), h(v.signature))).toBe(true); + // A bit of the message, of R and of S. + const bad = (i: number): Uint8Array => { + const s = h(v.signature); + s[i] = s[i]! ^ 1; + return s; + }; + expect(verifyStrict(h(v.public_key), Uint8Array.of(1), h(v.signature))).toBe(false); + expect(verifyStrict(h(v.public_key), h(v.message), bad(0))).toBe(false); + expect(verifyStrict(h(v.public_key), h(v.message), bad(40))).toBe(false); + }); +}); diff --git a/src/lib/dkc/ed25519strict.ts b/src/lib/dkc/ed25519strict.ts new file mode 100644 index 0000000..7195aa8 --- /dev/null +++ b/src/lib/dkc/ed25519strict.ts @@ -0,0 +1,63 @@ +// The strict verification of the author signature of alg 1 (spec v0.11, +// §29.9), as the Go package internal/ed25519strict does it. A signature is +// valid if and only if all four conditions hold: +// +// 1. A is the canonical encoding of a point: its y is less than p, and if y +// is 1 or p - 1 the sign bit is 0; +// 2. A decodes to a point that is not of small order; +// 3. sig[63] & 0xE0 is 0, and S, as a little-endian integer, is less than +// the order of the group, l; +// 4. [S]B - [k]A encodes exactly R, with k = SHA-512(R || A || message) mod +// l: the equation of RFC 8032 without the cofactor. +// +// @noble/curves 2.4.0 `verify` always uses the equation with the cofactor and +// accepts what condition 1 and the check of the encoding of R refuse, so the +// checks are made here and noble only does the arithmetic of the group. The +// vectors are testdata/vectors/ed25519_strict.json. Internal: index.ts does +// not re-export it. + +import { ed25519 } from '@noble/curves/ed25519.js'; +import { sha512 } from '@noble/hashes/sha2.js'; +import { concatBytes, equalBytes } from './bytes.ts'; + +const P = 2n ** 255n - 19n; +const L = 2n ** 252n + 27742317777372353535851937790883648493n; +const MASK_255 = (1n << 255n) - 1n; + +function leToBigint(b: Uint8Array): bigint { + let n = 0n; + for (let i = b.length - 1; i >= 0; i--) n = (n << 8n) | BigInt(b[i]!); + return n; +} + +/** Whether `sig` is a valid Ed25519 signature of `message` by the key `publicKey` under the strict profile of spec §29.9. */ +export function verifyStrict(publicKey: Uint8Array, message: Uint8Array, sig: Uint8Array): boolean { + if (publicKey.length !== 32 || sig.length !== 64) return false; + + // 1. A is canonical. + const y = leToBigint(publicKey) & MASK_255; + const sign = publicKey[31]! >> 7; + if (y >= P) return false; + if ((y === 1n || y === P - 1n) && sign !== 0) return false; + + // 2. A decodes, and is not of small order. + let a; + try { + a = ed25519.Point.fromBytes(publicKey, false); + } catch { + return false; + } + if (a.isSmallOrder()) return false; + + // 3. S is canonical. + if ((sig[63]! & 0xe0) !== 0) return false; + const s = leToBigint(sig.subarray(32)); + if (s >= L) return false; + + // 4. [S]B - [k]A encodes exactly R. + const r = sig.subarray(0, 32); + const k = leToBigint(sha512(concatBytes(r, publicKey, message))) % L; + const sb = s === 0n ? ed25519.Point.ZERO : ed25519.Point.BASE.multiplyUnsafe(s); + const ka = k === 0n ? ed25519.Point.ZERO : a.multiplyUnsafe(k); + return equalBytes(sb.subtract(ka).toBytes(), r); +} diff --git a/src/lib/dkc/fixtures.test.ts b/src/lib/dkc/fixtures.test.ts index a2d774a..e93a54a 100644 --- a/src/lib/dkc/fixtures.test.ts +++ b/src/lib/dkc/fixtures.test.ts @@ -9,6 +9,7 @@ import { decodeAccessKey, encodeAccessKey } from './accesskey.ts'; import { ACCESS_SLOTS } from './age.ts'; import { bodyFrameBytes, checkArea, contentLength, parseBodyFrame } from './body.ts'; import { sha256 } from './bytes.ts'; +import { ALG_CMS, authorCode, authorMessage, controlCommit, headDigest, signersDigest } from './author.ts'; import { decodeControl } from './control.ts'; import type { Extension } from './extension.ts'; import { type Format, FORMAT_1, FORMAT_2, FORMAT_3, headerBinding, isPadded, parsePrelude, payloadOffset } from './framing.ts'; @@ -16,7 +17,7 @@ import { checkHeadEnd, decodeHead, encodeHead } from './head.ts'; import { inspect, inspectView } from './inspect.ts'; import { paddedLength, type Padding, payloadAgeLength } from './padding.ts'; import { evaluateSecurity, type Verdict, verdictLines } from './security.ts'; -import { isPending, ported } from './testing/pending.ts'; +import { isPending, kinds, ported } from './testing/pending.ts'; import { h, hx, listTestdata, readBytes, readJSON } from './testing/testdata.ts'; interface FixtureExt { @@ -66,6 +67,16 @@ interface DkcFixture { content_offset?: number; files?: { path: string; size: number; start: number; end: number; sha256: string; mtime?: number }[]; verdicts?: { signature: Verdict; seal: Verdict; lines: string[] }; + /** The record of a signature (spec v0.11, §29.8): the commitments and the message that the reference computed. */ + signature?: { + alg: number; + signers?: string; + control_commit: string; + head_digest: string; + signers_digest: string; + author_message: string; + author_code: string; + }; } interface DkkFixture { @@ -207,11 +218,27 @@ describe.each(dkcFixtures)('$json.file', ({ json: fx }) => { expect(hx(await sha256(bytes)), f.path).toBe(hx(f.sha256)); } expect(hx(encodeHead(decoded))).toBe(fx.head_cbor); - const verdicts = evaluateSecurity(security); - // Where the reference checks a signature or a seal, this library gives what + // The signature is checked in the context of the capsule: its control, which the record holds, and its head. + const control = decodeControl(h(fx.control_cbor), FORMAT_3); + const verdicts = evaluateSecurity(security, { controlCommit: controlCommit(control, FORMAT_3), headDigest: headDigest(head) }); + if (fx.signature !== undefined) { + // What is signed, recomputed from the control and the head of the fixture as the reference recomputes it. + const cc = controlCommit(control, FORMAT_3); + const hd = headDigest(head); + const sd = signersDigest(fx.signature.alg, fx.signature.alg === ALG_CMS ? h(fx.signature.signers!) : undefined); + const message = authorMessage(cc, hd, sd); + expect({ cc: hx(cc), hd: hx(hd), sd: hx(sd), message: new TextDecoder().decode(message), code: authorCode(message) }).toEqual({ + cc: fx.signature.control_commit, + hd: fx.signature.head_digest, + sd: fx.signature.signers_digest, + message: fx.signature.author_message, + code: fx.signature.author_code, + }); + } + // Where the reference checks a signature of alg 2 or a seal, this library gives what // a reader of v0.10 gives, until it ports the verification (testing/pending.ts). const want = ported(fx.verdicts!); - expect(verdicts).toEqual(want); + expect(kinds(verdicts)).toEqual(want); if (!isPending(fx.verdicts!)) expect(verdictLines(verdicts)).toEqual(fx.verdicts!.lines); }); }); diff --git a/src/lib/dkc/open.test.ts b/src/lib/dkc/open.test.ts index 7ca1547..3cf96a9 100644 --- a/src/lib/dkc/open.test.ts +++ b/src/lib/dkc/open.test.ts @@ -26,7 +26,7 @@ import { type Release, type ReleaseSource, suppliedRelease } from './release.ts' import { type Verdict, verdictLines } from './security.ts'; import { MemorySink, type Sink } from './sink.ts'; import { frame, split } from './testing/capsule.ts'; -import { isPending, ported } from './testing/pending.ts'; +import { isPending, kinds, ported } from './testing/pending.ts'; import { h, hx, listTestdata, readBytes, readJSON } from './testing/testdata.ts'; import { applyEdits, edits } from './testing/vectors.ts'; import { parseX25519Identity, unwrapX25519, x25519PublicKey } from './x25519.ts'; @@ -102,7 +102,7 @@ function expectFiles(f: Fixture, r: Opened, sink: MemorySink): void { f.name, ).toEqual(f.side.files ?? []); expect(opened.files.map(hx), f.name).toEqual(r.head!.files.map((x) => hx(f.plaintext.subarray(f.side.content_offset! + x.start, f.side.content_offset! + x.end)))); - expect(r.verdicts, f.name).toEqual(ported(f.side.verdicts!)); + expect(kinds(r.verdicts!), f.name).toEqual(ported(f.side.verdicts!)); if (!isPending(f.side.verdicts!)) expect(verdictLines(r.verdicts!), f.name).toEqual(f.side.verdicts!.lines); expect(r.areaLen, f.name).toBe(f.side.area_len); expect([r.plaintext, r.unusableHeadExtensions], f.name).toEqual([undefined, []]); @@ -146,6 +146,18 @@ function reseal(sealed: Uint8Array, key: Uint8Array, plaintext: Uint8Array): Uin } describe('open', () => { + // Spec v0.11 §29.7: a capsule signed with alg 1 is F4 with its key, and F3 with the label of that key when the person saved it. + it('gives F4 to a capsule signed with an author key, and F3 with the key the person saved', async () => { + const f = fixture('format3_signed'); + const key = (f.side as unknown as { signature: { author_key: string } }).signature.author_key; + const plain = await open(f.dkc, options(f, { sink: new MemorySink() })); + expect([plain.error, plain.verdicts?.signature, plain.verdicts?.seal]).toEqual([undefined, 'F4', 'S0']); + expect(verdictLines(plain.verdicts!)).toEqual(f.side.verdicts!.lines); + const saved = await open(f.dkc, options(f, { sink: new MemorySink(), authorKeys: new Map([[key, 'Ana']]) })); + expect([saved.error, saved.verdicts?.signature, saved.verdicts?.authorLabel]).toEqual([undefined, 'F3', 'Ana']); + expect(verdictLines(saved.verdicts!)).toEqual(['Firmado con la clave que guardaste como Ana.']); + }); + it('opens every official fixture to its content, with the checks of the reference', async () => { expect(NAMES.length).toBeGreaterThanOrEqual(21); for (const name of NAMES) { diff --git a/src/lib/dkc/open.ts b/src/lib/dkc/open.ts index 0efc6d2..3f38179 100644 --- a/src/lib/dkc/open.ts +++ b/src/lib/dkc/open.ts @@ -34,6 +34,7 @@ import { type Identity, type Stanza as AgeStanza } from 'age-encryption'; import { type AccessKey, checkAccessKeyMaterial, decodeAccessKey, wipeAccessKey } from './accesskey.ts'; import { ACCESS_SLOTS, ageStanzas, checkAccessStanzas, checkPayloadStanzas, checkTimeStanzas, type Stanza } from './age.ts'; import { chunked, classify, decrypt, decryptAll, STREAM_FAILURE } from './agefile.ts'; +import { controlCommit } from './author.ts'; import { equalBytes, sha256, toHex } from './bytes.ts'; import { type Control, decodeControl } from './control.ts'; import { compareInstants, formatRFC3339, type Instant } from './datekey.ts'; @@ -56,6 +57,12 @@ import { MalformedX25519Stanza, unwrapX25519 } from './x25519.ts'; /** Options of open. */ export interface OpenOptions { + /** + * The author keys that the person saved, by their dkauthor1… string, with + * the label she gave each: a valid signature of alg 1 with one of them is F3 + * and not F4 (spec v0.11, §29.7). + */ + readonly authorKeys?: ReadonlyMap; /** The locally pinned profiles; the default registry (Quicknet) when omitted. */ readonly registry?: ProfileRegistry; /** The extensions the application implements; see InspectOptions. */ @@ -411,7 +418,10 @@ async function openCapsule( try { const plain = await decrypt(payload, payloadIdentity(control.payloadIdentity), 'PAYLOAD_AGE'); if (format === FORMAT_3) { - body = await openBody(plain, padded!.l, padded!.p, opts.sink!, opts.extensions); + body = await openBody(plain, padded!.l, padded!.p, opts.sink!, opts.extensions, { + controlCommit: controlCommit(control, format), + ...(opts.authorKeys === undefined ? {} : { authorKeys: opts.authorKeys }), + }); } else if (opts.output === undefined) { // The content is never longer than PAYLOAD_AGE. const buf = new Uint8Array(padded === undefined ? ciphertextLength : Math.min(padded.l, ciphertextLength)); diff --git a/src/lib/dkc/open3.ts b/src/lib/dkc/open3.ts index 7c440c5..5b7485e 100644 --- a/src/lib/dkc/open3.ts +++ b/src/lib/dkc/open3.ts @@ -27,6 +27,7 @@ import { sha256Hasher } from './digest.ts'; import { DateKeysError } from './errors.ts'; import { checkNoncritical, type ExtensionRegistry, type Unusable } from './extension.ts'; import { checkHeadEnd, decodeHead, type Head } from './head.ts'; +import { headDigest } from './author.ts'; import { evaluateSecurity, type Verdicts } from './security.ts'; import type { Sink } from './sink.ts'; @@ -179,6 +180,7 @@ export async function openBody( p: number, sink: Sink, reg: ExtensionRegistry | undefined, + security: { readonly controlCommit: Uint8Array; readonly authorKeys?: ReadonlyMap }, ): Promise { const r = new Plaintext(plain, p); let begun = false; @@ -189,12 +191,18 @@ export async function openBody( checkArea(area, frame.securityLen); // 17.3 and 17.6: the security area never fails; its verdicts are shown - // after step 18 only. - const verdicts = evaluateSecurity(area.subarray(0, frame.securityLen)); + // after step 18 only. They need the digest of the head (spec §29.8), so + // they are evaluated once its bytes are read. + const securityBytes = area.subarray(0, frame.securityLen); // 17.4: the head. Its codes other than ERR_INTEGRITY are reported only // after reading to the end. const hb = await r.readN(frame.headLen); + const verdicts = evaluateSecurity(securityBytes, { + controlCommit: security.controlCommit, + headDigest: headDigest(hb), + ...(security.authorKeys === undefined ? {} : { authorKeys: security.authorKeys }), + }); let head: Head; try { head = decodeHead(hb, reg); diff --git a/src/lib/dkc/security.ts b/src/lib/dkc/security.ts index 7437de6..b130a92 100644 --- a/src/lib/dkc/security.ts +++ b/src/lib/dkc/security.ts @@ -7,8 +7,11 @@ // security area never decides the opening, and its verdicts carry no error // code. Internal: index.ts does not re-export it. +import { ALG_ED25519, authorMessage, signersDigest } from './author.ts'; +import { bech32Encode } from './bech32.ts'; import { utf8Length } from './bytes.ts'; import { type Decoder, Encoder, peek, unmarshal } from './cbor.ts'; +import { verifyStrict } from './ed25519strict.ts'; import { DateKeysError } from './errors.ts'; import { fieldOf, requireKeys } from './schema.ts'; @@ -36,6 +39,23 @@ export type Verdict = 'X' | 'F0' | 'F1' | 'F2' | 'F3' | 'F4' | 'F5' | 'F6' | 'S0 export interface Verdicts { readonly signature: Verdict; readonly seal: Verdict; + /** The public key of a valid signature of alg 1 (F3, F4). */ + readonly authorKey?: Uint8Array; + /** The label of the saved key that signed (F3). */ + readonly authorLabel?: string; +} + +/** + * What the verdicts of a signature need besides SECURITY_CBOR (spec v0.11, + * §29.7): the commitments of the capsule, and the author keys that the person + * saved, by their dkauthor1… string, with the label she gave each (F3). A + * reader builds it at step 17.6. Without it the area is read as v0.10 reads + * it, and any signature is F1. + */ +export interface SecurityContext { + readonly controlCommit: Uint8Array; + readonly headDigest: Uint8Array; + readonly authorKeys?: ReadonlyMap; } /** The text of a verdict that the official SDK shows, in Spanish (spec §29.7), and '' for S0, which shows nothing. */ @@ -72,8 +92,12 @@ export function verdictText(v: Verdict): string { /** The verdicts as the official SDK shows them, in order: X alone, or the signature and then the seal, when it shows something. */ export function verdictLines(v: Verdicts): string[] { if (v.signature === 'X') return [verdictText('X')]; + let signature = verdictText(v.signature); + // F3 and F4 name a key (spec §29.7). + if (v.signature === 'F3') signature = `Firmado con la clave que guardaste como ${v.authorLabel!}.`; + if (v.signature === 'F4') signature = `Firmado con la clave ${bech32Encode('dkauthor', v.authorKey!)}. No prueba quién la tiene.`; const seal = verdictText(v.seal); - return seal === '' ? [verdictText(v.signature)] : [verdictText(v.signature), seal]; + return seal === '' ? [signature] : [signature, seal]; } // The outer map of SECURITY_CBOR: keys 2 and 3, undefined when absent. @@ -135,6 +159,51 @@ export function encodeSecurity(): Uint8Array { return e.out(); } +// The content of key 2: {0: alg, 1: key, 2: signature}. +interface AuthorSignature { + alg: number; + key: Uint8Array; + value: Uint8Array; +} + +function decodeAuthorSignature(d: Decoder): AuthorSignature { + const a: AuthorSignature = { alg: 0, key: new Uint8Array(0), value: new Uint8Array(0) }; + const pairs = d.map(3); + const seen = new Set(); + for (let i = 0; i < pairs; i++) { + const k = d.key(); + fieldOf(k)(() => { + switch (k) { + case 0: + a.alg = decodeAlg(d); + break; + case 1: + a.key = d.bstr(0, MAX_SECURITY_ITEM); + break; + case 2: + a.value = d.bstr(0, MAX_SECURITY_ITEM); + break; + default: + throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `key ${k} is not defined`); + } + }); + seen.add(Number(k)); + } + requireKeys(seen, 3); + d.endMap(); + return a; +} + +function encodeAuthorSignature(e: Encoder, a: AuthorSignature): void { + e.map(3); + e.uint(0); + e.uint(a.alg); + e.uint(1); + e.bstr(a.key); + e.uint(2); + e.bstr(a.value); +} + // The content of key 3: {0: seal_type, 1: token}. interface Seal { sealType: number; @@ -201,7 +270,7 @@ function attempt(decode: () => T): T | undefined { * decides: alg and seal_type are read only from content that decodes and * meets its schema. */ -export function evaluateSecurity(b: Uint8Array): Verdicts { +export function evaluateSecurity(b: Uint8Array, context?: SecurityContext): Verdicts { const w = attempt(() => { const h = peek(b); return h.typeTag === SECURITY_TYPE_TAG && h.version === SECURITY_VERSION ? unmarshal(b, decodeWire, encodeWire) : undefined; @@ -209,9 +278,23 @@ export function evaluateSecurity(b: Uint8Array): Verdicts { if (w === undefined) return { signature: 'X', seal: 'X' }; const { signature, seal } = w; return { - // A content that does not decode and an alg this version does not - // implement give the same verdict, and this version implements none. - signature: signature === undefined ? 'F0' : 'F1', + ...(signature === undefined ? { signature: 'F0' as const } : evaluateSignature(signature, context)), seal: seal === undefined ? 'S0' : attempt(() => unmarshal(seal, decodeSeal, encodeSeal)) === undefined ? 'S2' : 'S1', }; } + +// The verdict of the content of key 2 (spec §29.7, §29.9): F1 for content +// that does not decode, an alg this reader does not implement, or a key or a +// signature of another length, and without the context of a capsule, as in +// v0.10; F2 when the signature does not verify with the strict profile; F3 +// or F4 when it does. This version implements alg 1 only. +function evaluateSignature(content: Uint8Array, context: SecurityContext | undefined): Pick { + const unchecked = { signature: 'F1' } as const; + if (context === undefined) return unchecked; + const a = attempt(() => unmarshal(content, decodeAuthorSignature, encodeAuthorSignature)); + if (a === undefined || a.alg !== ALG_ED25519 || a.key.length !== 32 || a.value.length !== 64) return unchecked; + const message = authorMessage(context.controlCommit, context.headDigest, signersDigest(ALG_ED25519)); + if (!verifyStrict(a.key, message, a.value)) return { signature: 'F2' }; + const label = context.authorKeys?.get(bech32Encode('dkauthor', a.key)); + return label === undefined ? { signature: 'F4', authorKey: a.key } : { signature: 'F3', authorKey: a.key, authorLabel: label }; +} diff --git a/src/lib/dkc/testing/pending.ts b/src/lib/dkc/testing/pending.ts index 0abc9d3..ed75c9e 100644 --- a/src/lib/dkc/testing/pending.ts +++ b/src/lib/dkc/testing/pending.ts @@ -1,6 +1,7 @@ -// What this library does not do yet of spec v0.11: it reads the security area -// of a format 3 capsule as a reader of v0.10 does, so a signature or a seal that -// the reference checks gives F1 or S1 here. The shared fixtures and vectors +// What this library does not do yet of spec v0.11: it checks the signature of +// alg 1 (F2 to F4) but not that of alg 2 with certificates (F5, F6) nor the +// time seal (S3 to S5), which it reads as a reader of v0.10 does: they give F1 +// or S1 here. The shared fixtures and vectors // already record the verdicts of the reference (F2 to F6, S3 to S5), and these // helpers say what this library gives meanwhile, so that the tests state the // gap instead of hiding it. Porting the verification (spec §29.8 to §29.11) @@ -9,7 +10,7 @@ import type { Verdict } from '../security'; /** Verdicts that a reader of v0.10 cannot reach: those of a signature or a seal that is checked. */ -const SIGNATURE_CHECKED: readonly Verdict[] = ['F2', 'F3', 'F4', 'F5', 'F6']; +const SIGNATURE_CHECKED: readonly Verdict[] = ['F5', 'F6']; const SEAL_CHECKED: readonly Verdict[] = ['S3', 'S4', 'S5']; export interface Recorded { @@ -17,6 +18,11 @@ export interface Recorded { readonly seal: Verdict; } +/** The two verdicts of `v`, without the key or the label that a signature of alg 1 adds. */ +export function kinds(v: Recorded): Recorded { + return { signature: v.signature, seal: v.seal }; +} + /** The verdicts that this library gives where the reference records `recorded`. */ export function ported(recorded: Recorded): Recorded { return { diff --git a/src/lib/dkc/vectors.test.ts b/src/lib/dkc/vectors.test.ts index a8dd15f..0ba35d3 100644 --- a/src/lib/dkc/vectors.test.ts +++ b/src/lib/dkc/vectors.test.ts @@ -61,7 +61,7 @@ import { import type { Release, ReleaseSource } from './release.ts'; import { evaluateSecurity, type Verdict, verdictLines } from './security.ts'; import { MemorySink } from './sink.ts'; -import { isPending, ported } from './testing/pending.ts'; +import { isPending, kinds, ported } from './testing/pending.ts'; import { hasTestdata, hx, listTestdata, readBytes, readJSON } from './testing/testdata.ts'; import { applyEdits, @@ -681,7 +681,7 @@ describe('vectors/mutations.json', () => { expect(r.error?.message ?? 'ok', 'the text of capsule.Open').toBe(TEXTS.cases[c.index]!.text); if (c.error === 'ok') { expect({ step: last.step, ok: last.ok, error: r.error }).toEqual({ step: 18, ok: true, error: undefined }); - expect(r.verdicts).toEqual(ported(c.verdicts!)); + expect(kinds(r.verdicts!)).toEqual(ported(c.verdicts!)); if (!isPending(c.verdicts!)) expect(verdictLines(r.verdicts!)).toEqual(c.verdicts!.lines); expect(sink.opened?.head).toBe(r.head); } else {