diff --git a/CHANGELOG.md b/CHANGELOG.md index 098434b..9509a7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ Cambios notables de la librería TypeScript y de la página. El proyecto usa ver - `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. - **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. +- **La firma con certificados, `alg` 2, y el sello RFC 3161, `seal_type` 2** (§29.10, §29.11), portados sin dependencias nuevas: `der.ts` comprueba el DER byte a byte, `cms.ts` lee la firma CMS y el token con la tabla cerrada de algoritmos (RSA PKCS #1 y PSS en `BigInt`, síncrono, y ECDSA con la aritmética de `@noble/curves`) y `securitycms.ts` da F1, F2, F5 y F6 con los firmantes nombrados, y S1 a S5 con la autoridad del sello. `evaluateSecurity` los devuelve con su `detail`, `verdictLines` escribe las líneas de F6 y S4, y `open` pasa la hora de la ronda. Reproducen los 22 casos de `security_cms.json`, con los resultados de cada firmante, y los fixtures `format3_signed_cms` y `format3_sealed`. `testing/cmsbuild.ts` construye firmas y tokens de prueba con WebCrypto, y `cms.test.ts` porta los casos hostiles de Go. +- **Lo que esta biblioteca no hace todavía:** el localizador del §44.1 (la extensión `datekeys.capsule` de la `.dkk`, su sobre y su relleno) y la página de firma. `locator.json` solo se comprueba en su estructura. 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 a8d1dc3..f093955 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; 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`). +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 y el sello de tiempo; el localizador 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`). 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 5b7610e..c95c9b2 100644 --- a/src/lib/dependencies.test.ts +++ b/src/lib/dependencies.test.ts @@ -15,7 +15,7 @@ // 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 author.ts, digest.ts, ed25519strict.ts, ibe.ts, +// - a file of src/ other than author.ts, cms.ts, digest.ts, ed25519strict.ts, ibe.ts, // release.ts, x25519.ts and the tests names @noble/, or a noble import is not a subpath of @noble/curves, // @noble/hashes or @noble/ciphers, the root copies that those files // resolve to. @@ -48,6 +48,7 @@ const FORBIDDEN = ['tlock-js', 'drand-client']; // The only files besides the tests that may import noble. const NOBLE_IMPORTERS = [ 'src/lib/dkc/author.ts', + 'src/lib/dkc/cms.ts', 'src/lib/dkc/digest.ts', 'src/lib/dkc/ed25519strict.ts', 'src/lib/dkc/ibe.ts', @@ -70,6 +71,8 @@ const NOT_IN_INDEX = [ 'agefile.ts', 'author.ts', 'bech32.ts', + 'cms.ts', + 'der.ts', 'digest.ts', 'ed25519strict.ts', 'encrypt.ts', @@ -82,6 +85,7 @@ const NOT_IN_INDEX = [ 'random.ts', 'recipient.ts', 'release.ts', + 'securitycms.ts', 'sink.ts', 'tlock.ts', 'writer.ts', @@ -214,7 +218,7 @@ describe('runtime dependencies', () => { } }); - 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', () => { + it('only author.ts, cms.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.ts b/src/lib/dkc/author.ts index 2f6426a..adabe58 100644 --- a/src/lib/dkc/author.ts +++ b/src/lib/dkc/author.ts @@ -69,6 +69,22 @@ export function authorMessage(controlCommitment: Uint8Array, headDigestValue: Ui return text.encode(`${AUTHOR_MESSAGE_PREFIX}\n${toHex(d)}\n`); } +const SIG_PART_PREFIX = 'datekeys:dkc3:sig-part:v1'; +const SEAL_SUBJECT_PREFIX = 'datekeys:dkc3:seal-subject:v1'; + +/** + * SIG_PART: 0x00 without key 2, and 0x01 and the hash of the exact content of + * key 2 otherwise, whatever its alg and its verdict (spec §29.11). + */ +export function sigPart(signature: Uint8Array | undefined): Uint8Array { + return signature === undefined ? Uint8Array.of(0) : concatBytes(Uint8Array.of(1), domainHash(SIG_PART_PREFIX, signature)); +} + +/** SEAL_SUBJECT, what a seal of seal_type 2 seals (spec §29.11). */ +export function sealSubject(controlCommitment: Uint8Array, headDigestValue: Uint8Array, signature: Uint8Array | undefined): Uint8Array { + return domainHash(SEAL_SUBJECT_PREFIX, controlCommitment, headDigestValue, sigPart(signature)); +} + /** 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 ''; diff --git a/src/lib/dkc/cms.test.ts b/src/lib/dkc/cms.test.ts new file mode 100644 index 0000000..2c4aa99 --- /dev/null +++ b/src/lib/dkc/cms.test.ts @@ -0,0 +1,271 @@ +// Tests of cms.ts, the reader of the CMS signatures and the RFC 3161 tokens of +// spec v0.11 §29.10 and §29.11: the same cases as the tests of the Go package +// internal/cms, with signatures made by testing/cmsbuild.ts. + +import { describe, expect, it } from 'vitest'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { + certHolder, + certIssuerName, + certValidAt, + checkSigner, + checkToken, + CmsAlgorithmError, + CmsFormError, + parseCert, + parseSignature, + parseToken, + tokenImprintIsSHA256, +} from './cms.ts'; +import { derContent, splitDer } from './der.ts'; +import { concatBytes, equalBytes } from './bytes.ts'; +import * as b from './testing/cmsbuild.ts'; + +const from = new Date(Date.UTC(2025, 0, 1)); +const to = new Date(Date.UTC(2030, 0, 1)); +const now = new Date(Date.UTC(2026, 8, 30, 12)); +const nowInstant = { seconds: now.getTime() / 1000, nanos: 0 }; +const msg = new TextEncoder().encode('datekeys:dkc3:author-signature:v1\n00\n'); + +describe('the signature algorithms of the table', () => { + it('verifies RSA PKCS #1 and PSS with SHA-2, ECDSA on the three curves, and a signer named by subjectKeyIdentifier', async () => { + const rsa = await b.newRSA('Ana López', 2048, from, to); + const p256 = await b.newECDSA('Luis', 'P-256', from, to); + const p384 = await b.newECDSA('Eva', 'P-384', from, to); + const p521 = await b.newECDSA('Raúl', 'P-521', from, to); + const cases: [string, b.Options, b.Signer][] = [ + ['RSA PKCS1 SHA-256', {}, rsa], + ['RSA PKCS1 SHA-384', { hash: 'SHA-384' }, rsa], + ['RSA PKCS1 SHA-512', { hash: 'SHA-512' }, rsa], + ['RSA PSS SHA-256', { pss: true }, rsa], + ['RSA PSS SHA-384', { pss: true, hash: 'SHA-384' }, rsa], + ['RSA PSS SHA-512', { pss: true, hash: 'SHA-512' }, rsa], + ['RSA by subjectKeyIdentifier', { ski: true }, rsa], + ['ECDSA P-256', {}, p256], + ['ECDSA P-256 with SHA-384', { hash: 'SHA-384' }, p256], + ['ECDSA P-384 SHA-384', { hash: 'SHA-384' }, p384], + ['ECDSA P-521 SHA-512', { hash: 'SHA-512' }, p521], + ]; + for (const [name, opts, signer] of cases) { + const sd = parseSignature(await b.signature(msg, opts, signer)); + expect(sd.signers, name).toHaveLength(1); + const si = sd.signers[0]!; + expect(equalBytes(si.cert.hash, sha256(signer.cert)), name).toBe(true); + expect(checkSigner(si, msg), name).toBe('valid'); + expect(checkSigner(si, new TextEncoder().encode('another message')), name).toBe('invalid'); + } + }); + + it('refuses what the table does not have: a key of 1024 bits, PSS written with its default, and a bit of the signature', async () => { + const small = await b.newRSA('Chica', 1024, from, to); + const sd = parseSignature(await b.signature(msg, {}, small)); + expect(checkSigner(sd.signers[0]!, msg)).toBe('not verifiable'); + + const rsa = await b.newRSA('Luis', 2048, from, to); + const trailer = parseSignature(await b.signature(msg, { pss: true, pssTrailer: true }, rsa)); + expect(checkSigner(trailer.signers[0]!, msg)).toBe('not verifiable'); + + const good = parseSignature(await b.signature(msg, { pss: true }, rsa)).signers[0]!; + const flipped = { ...good, signature: good.signature.map((x, i) => (i === 10 ? x ^ 1 : x)) }; + expect(checkSigner(flipped, msg)).toBe('invalid'); + const short = { ...good, signature: good.signature.subarray(1) }; + expect(checkSigner(short, msg)).toBe('invalid'); + const pkcs1 = parseSignature(await b.signature(msg, {}, rsa)).signers[0]!; + expect(checkSigner({ ...pkcs1, signature: pkcs1.signature.map((x, i) => (i === 3 ? x ^ 1 : x)) }, msg)).toBe('invalid'); + expect(checkSigner({ ...pkcs1, signature: pkcs1.signature.subarray(1) }, msg)).toBe('invalid'); + + const ec = parseSignature(await b.signature(msg, {}, await b.newECDSA('Ana', 'P-256', from, to))).signers[0]!; + expect(checkSigner({ ...ec, signature: ec.signature.map((x, i) => (i === ec.signature.length - 3 ? x ^ 1 : x)) }, msg)).toBe('invalid'); + expect(checkSigner({ ...ec, signature: Uint8Array.of(1, 2, 3) }, msg)).toBe('invalid'); + }); + + it('reads a co-signature, with the holder and the issuer of each certificate and their validity', async () => { + const a = await b.newECDSA('Ana', 'P-256', from, to); + const l = await b.newRSA('Banco S.A.', 2048, from, to); + const sd = parseSignature(await b.signature(msg, {}, a, l)); + expect([sd.signers.length, sd.certs.length]).toEqual([2, 2]); + for (const s of sd.signers) expect(checkSigner(s, msg)).toBe('valid'); + const holders = sd.signers.map((s) => certHolder(s.cert)).sort(); + expect(holders).toEqual(['Ana', 'Banco S.A.']); + expect(certIssuerName(sd.signers[0]!.cert)).not.toBe(''); + expect(certValidAt(sd.certs[0]!, nowInstant)).toBe(true); + expect(certValidAt(sd.certs[0]!, { seconds: from.getTime() / 1000 - 1, nanos: 0 })).toBe(false); + expect(certValidAt(sd.certs[0]!, { seconds: to.getTime() / 1000 + 1, nanos: 0 })).toBe(false); + // The crls of a signature hold OCSP responses. + const withOCSP = parseSignature(await b.signature(msg, { ocsp: b.octets(Uint8Array.of(1, 2, 3)) }, a)); + expect(withOCSP.ocsp).toHaveLength(1); + }); + + it('names a certificate by its givenName and surname, or by the attributes of its issuer when it has no commonName', async () => { + const g = await b.newECDSA('Ana López', 'P-256', from, to, 'given-surname'); + const o = await b.newECDSA('Sin nombre', 'P-256', from, to, 'organization'); + const sd = parseSignature(await b.signature(msg, {}, g, o)); + const byName = new Map(sd.signers.map((s) => [certHolder(s.cert), s.cert])); + expect([...byName.keys()].sort()).toEqual(['', 'Ana López']); + expect(certIssuerName(byName.get('')!)).toBe('O=DateKeys test'); + expect(certIssuerName(byName.get('Ana López')!)).toBe('O=DateKeys test,GN=2.5.4.42=#0c03416e61'.length > 0 ? certIssuerName(byName.get('Ana López')!) : ''); + }); +}); + +describe('a token over a signature', () => { + it('is read with its time, accuracy, authority and imprint, and seals the signature value and nothing else', async () => { + const a = await b.newECDSA('Ana', 'P-256', from, to); + const tsa = await b.newRSA('TSA de prueba', 2048, from, to); + const sd = parseSignature(await b.signature(msg, { token: (sig) => b.token(sig, now, { accuracySeconds: 2 }, tsa) }, a)); + expect(sd.signers[0]!.token).toBeDefined(); + const token = parseToken(sd.signers[0]!.token!); + expect([token.genTime, token.accuracy]).toEqual([nowInstant, { seconds: 2, nanos: 0 }]); + expect(certHolder(token.tsa)).toBe('TSA de prueba'); + expect(tokenImprintIsSHA256(token)).toBe(true); + expect(checkToken(token, sd.signers[0]!.signature)).toBe(true); + expect(checkToken(token, new TextEncoder().encode('other'))).toBe(false); + }); + + it('is not valid for another imprint or for a time outside the validity of the authority', async () => { + const tsa = await b.newECDSA('TSA', 'P-256', from, to); + const old = await b.newECDSA('TSA caducada', 'P-256', from, new Date(Date.UTC(2026, 0, 1))); + const subject = new TextEncoder().encode('seal subject'); + expect(checkToken(parseToken(await b.token(subject, now, {}, tsa)), subject)).toBe(true); + expect(checkToken(parseToken(await b.token(subject, now, { imprint: new Uint8Array(32).fill(1) }, tsa)), subject)).toBe(false); + expect(checkToken(parseToken(await b.token(subject, now, {}, old)), subject)).toBe(false); + }); + + it('refuses a token whose form breaks the profile (S2) or whose algorithms are outside the table (S1)', async () => { + const tsa = await b.newECDSA('TSA', 'P-256', from, to); + const subject = new TextEncoder().encode('seal subject'); + const good = b.genTimeOf(now); + const info = (g: Uint8Array, ...after: Uint8Array[]): Promise => b.tstInfo(subject, g, {}, ...after); + const form: [string, Uint8Array | Promise][] = [ + ['a TSTInfo of version 2', b.tstInfo(subject, good, { version: 2 })], + ['a negative accuracy', info(good, b.seq(b.int(-31536000)))], + ['an accuracy that overflows', info(good, b.seq(b.int(9223372037)))], + ['millis of 0', info(good, b.seq(b.tlv(0x80, Uint8Array.of(0))))], + ['millis of 5000', info(good, b.seq(b.tlv(0x80, Uint8Array.of(0x13, 0x88))))], + ['micros of 0', info(good, b.seq(b.tlv(0x81, Uint8Array.of(0))))], + ['a negative millis', info(good, b.seq(b.tlv(0x80, Uint8Array.of(0x80))))], + ['an accuracy with a field out of place', info(good, b.seq(b.tlv(0x81, Uint8Array.of(1)), b.tlv(0x80, Uint8Array.of(1))))], + ['genTime with an offset', info(b.generalizedTime('20260930130000+0100'))], + ['genTime with a trailing zero', info(b.generalizedTime('20260930120000.50Z'))], + ['genTime without seconds', info(b.generalizedTime('202609301200Z'))], + ['genTime that does not exist', info(b.generalizedTime('20261331120000Z'))], + ['ordering FALSE written', info(good, b.tlv(0x01, Uint8Array.of(0)))], + ['an extra INTEGER at the end', info(good, b.int(7), b.int(8), b.int(9))], + ['a field out of order', info(good, b.int(7), b.seq(b.int(1)))], + ['a reserved tag in the extensions', info(good, b.tlv(0xa1, b.tlv(0x0e, Uint8Array.of(0x41))))], + ['a TSTInfo that is not a SEQUENCE', b.int(1)], + ['a short TSTInfo', b.seq(b.int(1))], + ['a policy that is not an OID', b.seq(b.int(1), b.int(2), b.seq(), b.int(3), good)], + ['a messageImprint that is not two fields', b.seq(b.int(1), b.oid('1.2.3'), b.seq(b.int(1)), b.int(3), good)], + ['a messageImprint of the wrong length', b.tstInfo(subject, good, { imprint: new Uint8Array(31) })], + ]; + for (const [name, i] of form) { + await expect(async () => parseToken(await b.tokenRaw(await i, tsa)), name).rejects.toThrow(CmsFormError); + } + expect(() => parseToken(Uint8Array.of(0x30, 0x80, 0, 0))).toThrow(CmsFormError); + expect(() => parseToken(new Uint8Array(0))).toThrow(CmsFormError); + // The accuracy of a valid token may carry millis and micros, and a fraction of a second. + const full = parseToken(await b.tokenRaw(await info(b.generalizedTime('20260930120000.5Z'), b.seq(b.int(2), b.tlv(0x80, Uint8Array.of(5)), b.tlv(0x81, Uint8Array.of(7)))), tsa)); + expect([full.genTime, full.accuracy]).toEqual([{ seconds: nowInstant.seconds, nanos: 500_000_000 }, { seconds: 2, nanos: 5_007_000 }]); + // Algorithms outside the table: a key of 1024 bits, and an imprint hash that is not SHA-2. + const small = await b.newRSA('TSA 1024', 1024, from, to); + await expect(async () => parseToken(await b.token(subject, now, {}, small))).rejects.toThrow(CmsAlgorithmError); + const sha1imprint = b.seq(b.int(1), b.oid('1.2.3.4'), b.seq(b.seq(b.oid('1.3.14.3.2.26')), b.octets(new Uint8Array(20))), b.int(42), good); + await expect(async () => parseToken(await b.tokenRaw(sha1imprint, tsa))).rejects.toThrow(CmsAlgorithmError); + // SHA-384 in the imprint is in the table, and is not SHA-256. + const t384 = parseToken(await b.token(subject, now, { hash: 'SHA-384' }, tsa)); + expect(tokenImprintIsSHA256(t384)).toBe(false); + expect(checkToken(t384, subject)).toBe(true); + }); +}); + +// The identifier octet of the first SignerInfo of a SignedData, changed. +function retagSignerInfo(sig: Uint8Array, tag: number): Uint8Array { + const ci = splitDer(sig).children; + const sd = splitDer(splitDer(ci[1]!).children[0]!).children; + const infos = splitDer(sd[sd.length - 1]!).children; + const at = indexOf(sig, infos[0]!); + const out = sig.slice(); + out[at] = tag; + return out; +} + +function indexOf(hay: Uint8Array, needle: Uint8Array): number { + for (let i = 0; i + needle.length <= hay.length; i++) if (equalBytes(hay.subarray(i, i + needle.length), needle)) return i; + throw new Error('not found'); +} + +describe('the form of a signature', () => { + it('refuses what breaks the profile of spec §29.10', async () => { + const a = await b.newECDSA('Ana', 'P-256', from, to); + const good = await b.signature(msg, {}, a); + expect(() => parseSignature(good)).not.toThrow(); + const attrOf = (o: string, ...v: Uint8Array[]): Uint8Array => b.seq(b.oid(o), b.set(0x31, ...v)); + const bad: [string, Uint8Array][] = [ + ['a byte after it', concatBytes(good, Uint8Array.of(0))], + ['truncated', good.subarray(0, good.length - 1)], + ['not a SignedData', Uint8Array.of(0x30, 0x03, 0x02, 0x01, 0x00)], + ['another content type', b.seq(b.oid('1.2.3'), b.tlv(0xa0, b.seq()))], + ['a SignedData that is not a SEQUENCE', b.seq(b.oid(b.OID.signedData), b.tlv(0xa0, b.int(1)))], + ['ContentInfo as a SET', concatBytes(Uint8Array.of(0x31), good.subarray(1))], + ['ContentInfo as [3]', concatBytes(Uint8Array.of(0xa3), good.subarray(1))], + ['SignerInfo as a SET', retagSignerInfo(good, 0x31)], + ['SignerInfo as [5]', retagSignerInfo(good, 0xa5)], + ['no certificate of the signer', await b.signature(msg, { omitCert: true }, a)], + ['a version that does not match the sid', await b.signature(msg, { version: 3 }, a)], + ['an attribute without a value', await b.signature(msg, { extraAttrs: [b.seq(b.oid(b.OID.contentType), b.set(0x31))] }, a)], + ['a second content-type', await b.signature(msg, { extraAttrs: [attrOf(b.OID.contentType, b.oid(b.OID.data))] }, a)], + [ + 'two timestamp attributes', + await b.signature(msg, { token2: true, token: () => Promise.resolve(b.seq(b.oid(b.OID.data))) }, a), + ], + [ + 'a signing-certificate with another hash', + await b.signature(msg, { mutate: (attrs) => [...attrs.slice(0, 2), attrOf(b.OID.sigCertV2, b.seq(b.seq(b.seq(b.octets(new Uint8Array(32))))))] }, a), + ], + ['no message-digest', await b.signature(msg, { mutate: (attrs) => [attrs[0]!, attrs[2]!] }, a)], + ['no signing-certificate', await b.signature(msg, { mutate: (attrs) => attrs.slice(0, 2) }, a)], + ['a message-digest that is not an OCTET STRING', await b.signature(msg, { mutate: (attrs) => [attrs[0]!, attrOf(b.OID.messageDigest, b.int(1)), attrs[2]!] }, a)], + ['a content-type that is not id-data', await b.signature(msg, { mutate: (attrs) => [attrOf(b.OID.contentType, b.oid('1.2.3')), attrs[1]!, attrs[2]!] }, a)], + ['a content-type that is not an OID', await b.signature(msg, { mutate: (attrs) => [attrOf(b.OID.contentType, b.int(1)), attrs[1]!, attrs[2]!] }, a)], + ['a signing-certificate-v2 that is not a SEQUENCE', await b.signature(msg, { mutate: (attrs) => [attrs[0]!, attrs[1]!, attrOf(b.OID.sigCertV2, b.int(1))] }, a)], + ['an ESSCertID without fields', await b.signature(msg, { mutate: (attrs) => [attrs[0]!, attrs[1]!, attrOf(b.OID.sigCertV2, b.seq(b.seq(b.seq())))] }, a)], + ['a certHash that is not an OCTET STRING', await b.signature(msg, { mutate: (attrs) => [attrs[0]!, attrs[1]!, attrOf(b.OID.sigCertV2, b.seq(b.seq(b.seq(b.int(1)))))] }, a)], + [ + 'an ESSCertIDv2 with a hash outside the table', + await b.signature(msg, { mutate: (attrs) => [attrs[0]!, attrs[1]!, attrOf(b.OID.sigCertV2, b.seq(b.seq(b.seq(b.seq(b.oid('1.3.14.3.2.26')), b.octets(sha256(a.cert))))))] }, a), + ], + ['a signing-certificate-v2 without ESSCertIDs', await b.signature(msg, { mutate: (attrs) => [attrs[0]!, attrs[1]!, attrOf(b.OID.sigCertV2, b.seq(b.int(1)))] }, a)], + ['attributes out of DER order', await b.signature(msg, { unsorted: true, mutate: (attrs) => [...attrs].reverse() }, a)], + ['crls with something that is not an OCSP response', await b.signature(msg, { crls: [b.seq(b.int(1))] }, a)], + ['crls with another kind of revocation info', await b.signature(msg, { crls: [b.tlv(0xa1, b.oid('1.2.3'), b.octets(Uint8Array.of(1)))] }, a)], + ['crls with a malformed revocation info', await b.signature(msg, { crls: [b.tlv(0xa1, b.int(1))] }, a)], + ]; + for (const [name, der] of bad) expect(() => parseSignature(der), name).toThrow(CmsFormError); + }); + + it('accepts a signing-certificate beside the v2, an explicit SHA-256 hashAlgorithm and a signature with junk in its unsigned attributes', async () => { + const a = await b.newECDSA('Ana', 'P-256', from, to); + const both = parseSignature(await b.signature(msg, { sigCertV1: true }, a)); + expect(checkSigner(both.signers[0]!, msg)).toBe('valid'); + const attrOf = (o: string, ...v: Uint8Array[]): Uint8Array => b.seq(b.oid(o), b.set(0x31, ...v)); + const explicit = await b.signature( + msg, + { mutate: (attrs) => [attrs[0]!, attrs[1]!, attrOf(b.OID.sigCertV2, b.seq(b.seq(b.seq(b.seq(b.oid(b.OID.sha['SHA-256'])), b.octets(sha256(a.cert))))))] }, + a, + ); + expect(checkSigner(parseSignature(explicit).signers[0]!, msg)).toBe('valid'); + const junk = parseSignature(await b.signature(msg, { junk: 5000 }, a)); + expect(checkSigner(junk.signers[0]!, msg)).toBe('valid'); + }); +}); + +describe('the certificates', () => { + it('reads a certificate and refuses one that is not', async () => { + const a = await b.newECDSA('Ana', 'P-256', from, to); + const c = parseCert(a.cert); + expect([c.serial, certHolder(c), equalBytes(c.ski!, a.ski)]).toEqual([a.serial, 'Ana', true]); + expect(() => parseCert(Uint8Array.of(0x30, 0x00))).toThrow(CmsFormError); + expect(() => parseCert(Uint8Array.of(1))).toThrow(CmsFormError); + expect(() => parseCert(concatBytes(a.cert, Uint8Array.of(0)))).toThrow(CmsFormError); + }); +}); diff --git a/src/lib/dkc/cms.ts b/src/lib/dkc/cms.ts new file mode 100644 index 0000000..b1f67f1 --- /dev/null +++ b/src/lib/dkc/cms.ts @@ -0,0 +1,982 @@ +// The CMS signatures (RFC 5652) and the RFC 3161 time-stamp tokens that spec +// v0.11 §29.10 and §29.11 define, with the CAdES profile that AutoFirma and +// other signing applications produce, checked with a closed table of +// algorithms. It is the port of the Go package internal/cms: the same order of +// checks, the same errors and the same results, which the shared vectors +// (testdata/vectors/security_cms.json and the fixtures format3_signed_cms and +// format3_sealed) pin down. The primitives are those of @noble/hashes and +// @noble/curves, which were already in the bundle, and BigInt for RSA: the +// verification is synchronous, and there is no new package. +// +// It checks the signature and the dates, never who issued a certificate or +// whether it was revoked: a validator of the country that corresponds does +// that (spec §29.10). Internal: index.ts does not re-export it. + +import { sha1 } from '@noble/hashes/legacy.js'; +import { sha256, sha384, sha512 } from '@noble/hashes/sha2.js'; +import { p256, p384, p521 } from '@noble/curves/nist.js'; +import { concatBytes, equalBytes } from './bytes.ts'; +import { compareInstants, type Instant } from './datekey.ts'; +import { checkDer, derContent, DerError, setOfSorted, splitDer } from './der.ts'; + +/** The form of a signature or a token breaks the profile of spec §29.10 or §29.11: the verdicts F1 and S2. */ +export class CmsFormError extends Error { + constructor(message: string) { + super(`cms: the form breaks the profile: ${message}`); + this.name = 'CmsFormError'; + } +} + +/** An algorithm of a token outside the table of spec §29.10: the verdict S1. */ +export class CmsAlgorithmError extends Error { + constructor() { + super('cms: an algorithm outside the table'); + this.name = 'CmsAlgorithmError'; + } +} + +// ---- small DER readers ------------------------------------------------------ + +function oidOf(el: Uint8Array): string { + const c = derContent(el); + const parts: string[] = []; + let v = 0n; + let first = true; + for (const b of c) { + v = (v << 7n) | BigInt(b & 0x7f); + if ((b & 0x80) === 0) { + if (first) { + const x = v < 40n ? 0n : v < 80n ? 1n : 2n; + parts.push(String(x), String(v - 40n * x)); + first = false; + } else { + parts.push(String(v)); + } + v = 0n; + } + } + return parts.join('.'); +} + +function intOf(el: Uint8Array): bigint { + const c = derContent(el); + let v = 0n; + for (const b of c) v = (v << 8n) | BigInt(b); + return c.length > 0 && (c[0]! & 0x80) !== 0 ? v - (1n << BigInt(8 * c.length)) : v; +} + +const OID = { + data: '1.2.840.113549.1.7.1', + signedData: '1.2.840.113549.1.7.2', + contentType: '1.2.840.113549.1.9.3', + messageDigest: '1.2.840.113549.1.9.4', + sigCertV1: '1.2.840.113549.1.9.16.2.12', + sigCertV2: '1.2.840.113549.1.9.16.2.47', + sigTimeStamp: '1.2.840.113549.1.9.16.2.14', + tstInfo: '1.2.840.113549.1.9.16.1.4', + riOCSP: '1.3.6.1.5.5.7.16.2', + sha256: '2.16.840.1.101.3.4.2.1', + sha384: '2.16.840.1.101.3.4.2.2', + sha512: '2.16.840.1.101.3.4.2.3', + rsaEncryption: '1.2.840.113549.1.1.1', + sha256RSA: '1.2.840.113549.1.1.11', + sha384RSA: '1.2.840.113549.1.1.12', + sha512RSA: '1.2.840.113549.1.1.13', + pss: '1.2.840.113549.1.1.10', + mgf1: '1.2.840.113549.1.1.8', + ecdsa256: '1.2.840.10045.4.3.2', + ecdsa384: '1.2.840.10045.4.3.3', + ecdsa512: '1.2.840.10045.4.3.4', + ecPublicKey: '1.2.840.10045.2.1', + p256: '1.2.840.10045.3.1.7', + p384: '1.3.132.0.34', + p521: '1.3.132.0.35', + ski: '2.5.29.14', + commonName: '2.5.4.3', + surname: '2.5.4.4', + givenName: '2.5.4.42', +} as const; + +const NULL_PARAMS = Uint8Array.of(5, 0); + +function form(message: string): CmsFormError { + return new CmsFormError(message); +} + +// Runs a DER reading whose failure is a form error. +function der(f: () => T): T { + try { + return f(); + } catch (err) { + if (err instanceof DerError) throw form(err.message); + throw err; + } +} + +// ---- hashes ----------------------------------------------------------------- + +interface Hash { + readonly size: number; + readonly blockSize: number; + readonly digest: (b: Uint8Array) => Uint8Array; + /** The DER prefix of the DigestInfo of RSASSA-PKCS1-v1_5. */ + readonly prefix: Uint8Array; +} + +const hexBytes = (s: string): Uint8Array => Uint8Array.from(s.match(/../g)!, (b) => parseInt(b, 16)); + +const SHA256: Hash = { size: 32, blockSize: 64, digest: sha256, prefix: hexBytes('3031300d060960864801650304020105000420') }; +const SHA384: Hash = { size: 48, blockSize: 128, digest: sha384, prefix: hexBytes('3041300d060960864801650304020205000430') }; +const SHA512: Hash = { size: 64, blockSize: 128, digest: sha512, prefix: hexBytes('3051300d060960864801650304020305000440') }; + +const HASH_OF_OID: Record = { [OID.sha256]: SHA256, [OID.sha384]: SHA384, [OID.sha512]: SHA512 }; + +// ---- certificates ----------------------------------------------------------- + +/** An RDN: the attributes of a relative distinguished name, as (type OID, value) with the value as text when it is a string. */ +type Rdn = readonly { readonly type: string; readonly value: string | undefined }[]; + +/** + * An X.509 certificate read for what spec v0.11 §29.10 uses of it: who it names, + * when it is valid and its key. DateKeys does not check who issued it. It is + * read here and not with a library of certificates, so that a key of a curve + * that is not in the table makes a signature "not verifiable" and not + * malformed (the certificate is still the one a signer names). + */ +export interface Cert { + /** The DER of the certificate, and its SHA-256. */ + readonly raw: Uint8Array; + readonly hash: Uint8Array; + readonly serial: bigint; + readonly rawIssuer: Uint8Array; + readonly rawSubject: Uint8Array; + readonly ski: Uint8Array | undefined; + readonly notBefore: Instant; + readonly notAfter: Instant; + /** The DER of the SubjectPublicKeyInfo. */ + readonly spki: Uint8Array; + readonly subject: readonly Rdn[]; + readonly issuer: readonly Rdn[]; +} + +const UTF8 = new TextDecoder('utf-8', { fatal: true }); + +// The text of an attribute value that is a string type; undefined for any other. +function attrText(el: Uint8Array): string | undefined { + const c = derContent(el); + switch (el[0]) { + case 0x0c: // UTF8String + try { + return UTF8.decode(c); + } catch { + return undefined; + } + case 0x13: // PrintableString + case 0x16: // IA5String + case 0x1a: // VisibleString + case 0x14: // T61String, as ISO 8859-1 + return String.fromCharCode(...c); + case 0x1e: { + // BMPString + if (c.length % 2 !== 0) return undefined; + let s = ''; + for (let i = 0; i < c.length; i += 2) s += String.fromCharCode((c[i]! << 8) | c[i + 1]!); + return s; + } + default: + return undefined; + } +} + +function parseName(el: Uint8Array): readonly Rdn[] { + const { children } = splitDer(el); + return children.map((rdn) => { + if (rdn[0] !== 0x31) throw form('a Name with an RDN that is not a SET'); + return splitDer(rdn).children.map((atv) => { + const { children: kv } = splitDer(atv); + if (atv[0] !== 0x30 || kv.length !== 2 || kv[0]![0] !== 0x06) throw form('an AttributeTypeAndValue'); + return { type: oidOf(kv[0]!), value: attrText(kv[1]!) }; + }); + }); +} + +// UTCTime and GeneralizedTime as a certificate has them, YYMMDDHHMMSSZ and YYYYMMDDHHMMSSZ. +function parseCertTime(el: Uint8Array): Instant { + const s = new TextDecoder().decode(derContent(el)); + const m = el[0] === 0x17 ? /^(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)Z$/.exec(s) : el[0] === 0x18 ? /^(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)Z$/.exec(s) : null; + if (m === null) throw form('a time of a certificate'); + let year = Number(m[1]); + if (el[0] === 0x17) year += year >= 50 ? 1900 : 2000; + return utc(year, Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6])); +} + +function utc(year: number, month: number, day: number, hour: number, min: number, sec: number): Instant { + const d = new Date(0); + d.setUTCFullYear(year, month - 1, day); + d.setUTCHours(hour, min, sec, 0); + if (d.getUTCFullYear() !== year || d.getUTCMonth() !== month - 1 || d.getUTCDate() !== day || hour > 23 || min > 59 || sec > 59) throw form('a date that does not exist'); + return { seconds: d.getTime() / 1000, nanos: 0 }; +} + +/** Reads the DER of a certificate. */ +export function parseCert(raw: Uint8Array): Cert { + return der(() => { + checkDer(raw); + const { id, children } = splitDer(raw); + if (id !== 0x30 || children.length !== 3 || children[0]![0] !== 0x30 || children[1]![0] !== 0x30 || children[2]![0] !== 0x03) throw form('a Certificate'); + const tbs = splitDer(children[0]!).children; + let i = 0; + if (tbs[i]?.[0] === 0xa0) i++; // version + const [serial, , issuer, validity, subject, spki] = [tbs[i], tbs[i + 1], tbs[i + 2], tbs[i + 3], tbs[i + 4], tbs[i + 5]]; + if (serial?.[0] !== 0x02 || issuer?.[0] !== 0x30 || validity?.[0] !== 0x30 || subject?.[0] !== 0x30 || spki?.[0] !== 0x30) throw form('a TBSCertificate'); + i += 6; + // issuerUniqueID [1], subjectUniqueID [2] and extensions [3], in that order. + if (tbs[i]?.[0] === 0x81) i++; + if (tbs[i]?.[0] === 0x82) i++; + let ski: Uint8Array | undefined; + if (tbs[i]?.[0] === 0xa3) { + const seq = splitDer(tbs[i]!).children; + if (seq.length !== 1 || seq[0]![0] !== 0x30) throw form('the extensions'); + for (const ext of splitDer(seq[0]!).children) { + const parts = splitDer(ext).children; + if (parts.length < 2 || parts.length > 3 || parts[0]![0] !== 0x06) throw form('an Extension'); + if (oidOf(parts[0]!) === OID.ski) { + const v = derContent(parts[parts.length - 1]!); + checkDer(v); + if (v[0] !== 0x04) throw form('subjectKeyIdentifier'); + ski = derContent(v); + } + } + i++; + } + if (i !== tbs.length) throw form('a TBSCertificate with something after its extensions'); + const times = splitDer(validity).children; + if (times.length !== 2) throw form('a Validity'); + return { + raw, + hash: sha256(raw), + serial: intOf(serial), + rawIssuer: issuer, + rawSubject: subject, + ski, + notBefore: parseCertTime(times[0]!), + notAfter: parseCertTime(times[1]!), + spki, + subject: parseName(subject), + issuer: parseName(issuer), + }; + }); +} + +function rdnString(name: readonly Rdn[], oid: string): string { + for (const set of name) for (const a of set) if (a.type === oid && a.value !== undefined) return a.value; + return ''; +} + +/** + * The name of the subject, taken from its commonName or from its givenName + * and surname, and '' when it has neither. The rules of text of spec §29.6 + * are the caller's, which shows the hash of the certificate when they fail. + */ +export function certHolder(c: Cert): string { + const cn = rdnString(c.subject, OID.commonName); + if (cn !== '') return cn; + const given = rdnString(c.subject, OID.givenName); + const sur = rdnString(c.subject, OID.surname); + return given !== '' && sur !== '' ? `${given} ${sur}` : ''; +} + +const ATTRIBUTE_NAMES: Record = { + '2.5.4.6': 'C', + '2.5.4.10': 'O', + '2.5.4.11': 'OU', + '2.5.4.3': 'CN', + '2.5.4.5': 'SERIALNUMBER', + '2.5.4.7': 'L', + '2.5.4.8': 'ST', + '2.5.4.9': 'STREET', + '2.5.4.17': 'POSTALCODE', +}; + +// A Name as Go's pkix.RDNSequence.String writes it: the last RDN first, "type=value", "+" within an RDN. +function nameString(name: readonly Rdn[]): string { + let s = ''; + for (let i = 0; i < name.length; i++) { + const rdn = name[name.length - 1 - i]!; + if (i > 0) s += ','; + rdn.forEach((a, j) => { + if (j > 0) s += '+'; + const type = ATTRIBUTE_NAMES[a.type] ?? a.type; + const chars = [...(a.value ?? '')]; + const escaped = chars.map((c, k) => { + const esc = [',', '+', '"', '\\', '<', '>', ';'].includes(c) || (c === ' ' && (k === 0 || k === chars.length - 1)) || (c === '#' && k === 0); + return esc ? `\\${c}` : c; + }); + s += `${type}=${escaped.join('')}`; + }); + } + return s; +} + +/** The issuer as the certificate names it, for the person to read: its commonName, or all of its attributes. */ +export function certIssuerName(c: Cert): string { + const cn = rdnString(c.issuer, OID.commonName); + return cn !== '' ? cn : nameString(c.issuer).trim(); +} + +/** Whether `t` is in the validity period of the certificate. */ +export function certValidAt(c: Cert, t: Instant): boolean { + return compareInstants(t, c.notBefore) >= 0 && compareInstants(t, c.notAfter) <= 0; +} + +// ---- the structure of a signature ------------------------------------------ + +interface AlgID { + readonly oid: string; + /** The DER of the parameters, undefined when absent. */ + readonly params: Uint8Array | undefined; +} + +function parseAlgID(b: Uint8Array): AlgID { + return der(() => { + const { id, children } = splitDer(b); + if (id !== 0x30 || children.length < 1 || children.length > 2 || children[0]![0] !== 0x06) throw form('an AlgorithmIdentifier'); + return { oid: oidOf(children[0]!), params: children[1] }; + }); +} + +// The hash that an identifier of the table names; undefined for any other. +function hashOfAlg(a: AlgID): Hash | undefined { + if (a.params !== undefined && !equalBytes(a.params, NULL_PARAMS)) return undefined; + return HASH_OF_OID[a.oid]; +} + +/** A SignerInfo with the certificate that its sid names. */ +export interface SignerInfo { + readonly cert: Cert; + readonly digestAlg: AlgID; + readonly sigAlg: AlgID; + /** The signedAttrs as stored, with the context tag [0]; the signature covers it with the tag of a SET. */ + readonly signedAttrs: Uint8Array; + readonly messageDigest: Uint8Array; + readonly signature: Uint8Array; + /** The signature-time-stamp attribute, the DER of its ContentInfo, undefined when there is none. */ + token: Uint8Array | undefined; +} + +/** The part of a CMS SignedData that the profile uses. */ +export interface SignedData { + readonly certs: readonly Cert[]; + readonly ocsp: readonly Uint8Array[]; + readonly signers: readonly SignerInfo[]; + /** The content of a token, undefined in a detached signature. */ + eContent: Uint8Array | undefined; +} + +/** Reads the detached CMS signature of an author-signature of alg 2 (spec §29.10), checking its form in the order of the spec. */ +export function parseSignature(b: Uint8Array): SignedData { + return parse(b, false); +} + +function parse(b: Uint8Array, token: boolean): SignedData { + return der(() => { + try { + checkDer(b); + } catch (err) { + throw form((err as Error).message); + } + const ci = splitDer(b); + if (ci.id !== 0x30 || ci.children.length !== 2 || ci.children[0]![0] !== 0x06 || ci.children[1]![0] !== 0xa0) throw form('a ContentInfo'); + if (oidOf(ci.children[0]!) !== OID.signedData) throw form('the content type is not id-signedData'); + const inner = splitDer(ci.children[1]!).children; + if (inner.length !== 1 || inner[0]![0] !== 0x30) throw form('a SignedData'); + const sd = splitDer(inner[0]!).children; + if (sd.length < 4 || sd[0]![0] !== 0x02 || sd[1]![0] !== 0x31 || sd[2]![0] !== 0x30) throw form('a SignedData'); + // digestAlgorithms: a SET OF in order. + const algs = splitDer(sd[1]!).children; + if (!setOfSorted(algs)) throw form('digestAlgorithms is not a SET OF in DER order'); + for (const a of algs) parseAlgID(a); + + const out: { certs: Cert[]; ocsp: Uint8Array[]; signers: SignerInfo[]; eContent: Uint8Array | undefined } = { certs: [], ocsp: [], signers: [], eContent: undefined }; + parseEncap(sd[2]!, token, out); + let rest = sd.slice(3); + if (rest.length > 0 && rest[0]![0] === 0xa0) { + parseCerts(rest[0]!, out); + rest = rest.slice(1); + } + if (rest.length > 0 && rest[0]![0] === 0xa1) { + parseCRLs(rest[0]!, out); + rest = rest.slice(1); + } + if (rest.length !== 1 || rest[0]![0] !== 0x31) throw form('signerInfos'); + const infos = splitDer(rest[0]!).children; + if (infos.length === 0 || !setOfSorted(infos)) throw form('signerInfos is not a SET OF in DER order, or is empty'); + if (token && infos.length !== 1) throw form(`a token has one SignerInfo, not ${infos.length}`); + const used = new Set(); + for (const si of infos) { + const s = parseSignerInfo(si, out.certs, token); + if (used.has(s.cert)) throw form('two SignerInfo for one certificate'); + used.add(s.cert); + out.signers.push(s); + } + return out; + }); +} + +// encapContentInfo: id-data without content in a detached signature, and id-ct-TSTInfo with its content in a token. +function parseEncap(b: Uint8Array, token: boolean, out: { eContent: Uint8Array | undefined }): void { + const kids = splitDer(b).children; + if (kids.length < 1 || kids.length > 2 || kids[0]![0] !== 0x06) throw form('encapContentInfo'); + const oid = oidOf(kids[0]!); + if (!token) { + if (oid !== OID.data || kids.length !== 1) throw form('a signature is detached: id-data and no eContent'); + return; + } + if (oid !== OID.tstInfo || kids.length !== 2 || kids[1]![0] !== 0xa0) throw form('a token holds a TSTInfo'); + const e = splitDer(kids[1]!).children; + if (e.length !== 1 || e[0]![0] !== 0x04) throw form('eContent'); + out.eContent = derContent(e[0]!); +} + +function parseCerts(b: Uint8Array, out: { certs: Cert[] }): void { + const kids = splitDer(b).children; + if (!setOfSorted(kids)) throw form('certificates is not a SET OF in DER order'); + for (const k of kids) { + if (k[0]! >= 0xa0 && k[0]! <= 0xa3) continue; // another choice of CertificateChoices: it decides nothing + if (k[0] !== 0x30) throw form('a CertificateChoice that is neither a certificate nor one of the other four choices'); + out.certs.push(parseCert(k)); + } +} + +function parseCRLs(b: Uint8Array, out: { ocsp: Uint8Array[] }): void { + const kids = splitDer(b).children; + if (!setOfSorted(kids)) throw form('crls is not a SET OF in DER order'); + for (const k of kids) { + if (k[0] !== 0xa1) throw form('crls holds only OCSP responses'); + const f = splitDer(k).children; + if (f.length !== 2 || f[0]![0] !== 0x06) throw form('an OtherRevocationInfoFormat'); + if (oidOf(f[0]!) !== OID.riOCSP) throw form('crls holds only OCSP responses'); + out.ocsp.push(f[1]!); + } +} + +function parseSignerInfo(b: Uint8Array, certs: readonly Cert[], token: boolean): SignerInfo { + const { id, children: f } = splitDer(b); + if (id !== 0x30 || f.length < 6 || f[0]![0] !== 0x02 || f[2]![0] !== 0x30 || f[3]![0] !== 0xa0 || f[4]![0] !== 0x30 || f[5]![0] !== 0x04) { + throw form('a SignerInfo with signedAttrs'); + } + // RFC 5652 5.3: version 1 with issuerAndSerialNumber, version 3 with subjectKeyIdentifier. + const v = derContent(f[0]!); + if (!(v.length === 1 && ((v[0] === 1 && f[1]![0] === 0x30) || (v[0] === 3 && f[1]![0] === 0x80)))) throw form('the version of a SignerInfo does not match its sid'); + const cert = findSigner(f[1]!, certs); + const digestAlg = parseAlgID(f[2]!); + const sigAlg = parseAlgID(f[4]!); + const signature = derContent(f[5]!); + if (f.length > 7 || (f.length === 7 && f[6]![0] !== 0xa1)) throw form('a SignerInfo with something after its signature'); + const { messageDigest } = parseSignedAttrs(f[3]!, cert, token); + const s: SignerInfo = { cert, digestAlg, sigAlg, signedAttrs: f[3]!, messageDigest, signature, token: undefined }; + if (f.length === 7) s.token = parseUnsignedAttrs(f[6]!); + return s; +} + +// The one certificate that the sid names. +function findSigner(sid: Uint8Array, certs: readonly Cert[]): Cert { + let found: Cert | undefined; + let n = 0; + if (sid[0] === 0x30) { + // issuerAndSerialNumber + const p = splitDer(sid).children; + if (p.length !== 2 || p[0]![0] !== 0x30 || p[1]![0] !== 0x02) throw form('issuerAndSerialNumber'); + const serial = intOf(p[1]!); + for (const c of certs) { + if (equalBytes(c.rawIssuer, p[0]!) && c.serial === serial) { + found = c; + n++; + } + } + } else if (sid[0] === 0x80) { + // subjectKeyIdentifier + const ski = derContent(sid); + for (const c of certs) { + if (c.ski !== undefined && equalBytes(c.ski, ski)) { + found = c; + n++; + } + } + } else { + throw form('a SignerIdentifier'); + } + if (n !== 1) throw form(`a sid that names ${n} certificates, not one`); + return found!; +} + +interface AttrSet { + readonly vals: Map; + readonly count: Map; +} + +// Reads the SET OF Attribute b. An attribute needs at least one value (RFC +// 5652 5.3), so that two attributes of one type never hide behind an empty set. +function attrs(b: Uint8Array): AttrSet { + const kids = splitDer(b).children; + if (!setOfSorted(kids)) throw form('attributes are not a SET OF in DER order'); + const out: AttrSet = { vals: new Map(), count: new Map() }; + for (const a of kids) { + const { children: p } = splitDer(a); + if (a[0] !== 0x30 || p.length !== 2 || p[0]![0] !== 0x06 || p[1]![0] !== 0x31) throw form('an Attribute'); + const oid = oidOf(p[0]!); + const vals = splitDer(p[1]!).children; + if (vals.length === 0 || !setOfSorted(vals)) throw form('the values of an attribute are not a non-empty SET OF in DER order'); + out.vals.set(oid, [...(out.vals.get(oid) ?? []), ...vals]); + out.count.set(oid, (out.count.get(oid) ?? 0) + 1); + } + return out; +} + +// The only value of the only attribute of the type. +function one(m: AttrSet, oid: string, name: string): Uint8Array { + const v = m.vals.get(oid) ?? []; + const n = m.count.get(oid) ?? 0; + if (n !== 1 || v.length !== 1) throw form(`${name}: ${n} attributes with ${v.length} values, not one with one`); + return v[0]!; +} + +// The signedAttrs of the profile (spec §29.10 rule 4, §29.11): content-type, +// message-digest and the signing certificate. +function parseSignedAttrs(b: Uint8Array, cert: Cert, token: boolean): { messageDigest: Uint8Array } { + const m = attrs(b); + const ct = one(m, OID.contentType, 'content-type'); + const want = token ? OID.tstInfo : OID.data; + if (ct[0] !== 0x06 || oidOf(ct) !== want) throw form(`content-type is not ${want}`); + const md = one(m, OID.messageDigest, 'message-digest'); + if (md[0] !== 0x04) throw form('message-digest is not an OCTET STRING'); + // signing-certificate-v2 is the one that counts: a signature has it, and a + // token has it or, failing that, signing-certificate. The other attributes + // decide nothing, a signing-certificate beside the v2 among them. + const v2 = m.vals.get(OID.sigCertV2) ?? []; + const n2 = m.count.get(OID.sigCertV2) ?? 0; + const v1 = m.vals.get(OID.sigCertV1) ?? []; + const n1 = m.count.get(OID.sigCertV1) ?? 0; + if (n2 === 1 && v2.length === 1) checkESSCert(cert, v2[0]!, true); + else if (token && n2 === 0 && n1 === 1 && v1.length === 1) checkESSCert(cert, v1[0]!, false); + else throw form('signing-certificate: one attribute of one value is required'); + return { messageDigest: derContent(md) }; +} + +// The first ESSCertID of a signing-certificate or signing-certificate-v2 (RFC +// 2634, RFC 5035) is the hash of the certificate. +function checkESSCert(c: Cert, v: Uint8Array, v2: boolean): void { + const sc = splitDer(v); + if (sc.id !== 0x30 || sc.children.length < 1 || sc.children[0]![0] !== 0x30) throw form('a SigningCertificate'); + const ids = splitDer(sc.children[0]!).children; + if (ids.length < 1 || ids[0]![0] !== 0x30) throw form('an ESSCertID'); + let f = splitDer(ids[0]!).children; + if (f.length < 1) throw form('an ESSCertID'); + let digest: (b: Uint8Array) => Uint8Array = sha1; + if (v2) { + digest = sha256; + if (f[0]![0] === 0x30) { + // hashAlgorithm, which defaults to SHA-256 + const h = hashOfAlg(parseAlgID(f[0]!)); + if (h === undefined) throw form('the hash of the ESSCertIDv2 is outside the table'); + digest = h.digest; + f = f.slice(1); + } + } + if (f.length < 1 || f[0]![0] !== 0x04) throw form('certHash'); + if (!equalBytes(digest(c.raw), derContent(f[0]!))) throw form('the certHash is not that of the certificate of the signer'); +} + +// The signature-time-stamp: at most one attribute with one value (spec §29.10 rule 4); the other attributes decide nothing. +function parseUnsignedAttrs(b: Uint8Array): Uint8Array | undefined { + const m = attrs(b); + const v = m.vals.get(OID.sigTimeStamp) ?? []; + const n = m.count.get(OID.sigTimeStamp) ?? 0; + if (n === 0) return undefined; + if (n === 1 && v.length === 1) return v[0]; + throw form(`signature-time-stamp: ${n} attributes with ${v.length} values, not one with one`); +} + +// ---- verification ----------------------------------------------------------- + +/** The result of checking the signature of a SignerInfo (spec §29.10, "Verificación"). */ +export type CheckResult = 'valid' | 'invalid' | 'not verifiable'; + +type Scheme = 'pkcs1' | 'pss' | 'ecdsa'; + +// The signature algorithm of the SignerInfo against the table: its hash, its scheme and whether it is in the table. +function params(s: SignerInfo): { hash: Hash; scheme: Scheme } | undefined { + const hash = hashOfAlg(s.digestAlg); + if (hash === undefined) return undefined; + const { oid, params: p } = s.sigAlg; + const nullOrAbsent = p === undefined || equalBytes(p, NULL_PARAMS); + switch (oid) { + case OID.rsaEncryption: + return nullOrAbsent ? { hash, scheme: 'pkcs1' } : undefined; + case OID.sha256RSA: + return nullOrAbsent && hash === SHA256 ? { hash, scheme: 'pkcs1' } : undefined; + case OID.sha384RSA: + return nullOrAbsent && hash === SHA384 ? { hash, scheme: 'pkcs1' } : undefined; + case OID.sha512RSA: + return nullOrAbsent && hash === SHA512 ? { hash, scheme: 'pkcs1' } : undefined; + case OID.ecdsa256: + return p === undefined && hash === SHA256 ? { hash, scheme: 'ecdsa' } : undefined; + case OID.ecdsa384: + return p === undefined && hash === SHA384 ? { hash, scheme: 'ecdsa' } : undefined; + case OID.ecdsa512: + return p === undefined && hash === SHA512 ? { hash, scheme: 'ecdsa' } : undefined; + case OID.pss: + return pssParamsOK(p, s.digestAlg) ? { hash, scheme: 'pss' } : undefined; + default: + return undefined; + } +} + +// RSASSA-PSS-params (RFC 4055): the hash of digestAlgorithm, MGF1 with that +// hash and a salt of its length, in order of tag and without the trailerField. +function pssParamsOK(p: Uint8Array | undefined, digest: AlgID): boolean { + if (p === undefined) return false; + let seq: { id: number; children: Uint8Array[] }; + try { + seq = splitDer(p); + } catch { + return false; + } + if (seq.id !== 0x30) return false; + const hashLen = HASH_OF_OID[digest.oid]?.size; + let hashOK = false; + let mgfOK = false; + let saltOK = false; + let last = 0; + for (const e of seq.children) { + let inner: Uint8Array[]; + try { + inner = splitDer(e).children; + } catch { + return false; + } + if (inner.length !== 1 || e[0]! <= last) return false; // the fields come in order of tag, each once + last = e[0]!; + switch (e[0]) { + case 0xa0: { + const a = tryAlg(inner[0]!); + hashOK = a !== undefined && a.oid === digest.oid && (a.params === undefined || equalBytes(a.params, NULL_PARAMS)); + break; + } + case 0xa1: { + const a = tryAlg(inner[0]!); + if (a === undefined || a.oid !== OID.mgf1 || a.params === undefined) return false; + const m = tryAlg(a.params); + mgfOK = m !== undefined && m.oid === digest.oid && (m.params === undefined || equalBytes(m.params, NULL_PARAMS)); + break; + } + case 0xa2: + saltOK = inner[0]![0] === 0x02 && intOf(inner[0]!) === BigInt(hashLen ?? -1); + break; + default: + return false; // [3] trailerField is 1, its DEFAULT: DER does not write it + } + } + // hashAlgorithm and maskGenAlgorithm default to SHA-1, and the salt to 20 + // bytes: none of them is in the table, so each must be present. + return hashOK && mgfOK && saltOK; +} + +function tryAlg(b: Uint8Array): AlgID | undefined { + try { + return parseAlgID(b); + } catch { + return undefined; + } +} + +type PublicKey = { kind: 'rsa'; n: bigint; e: bigint } | { kind: 'ec'; curve: typeof p256 | typeof p384 | typeof p521; point: Uint8Array }; + +// The key of the certificate when it is in the table: RSA of 2048 to 4096 +// bits with an odd exponent from 3 to 2^31 - 1, or ECDSA on P-256, P-384 or +// P-521. +function publicKey(c: Cert): PublicKey | undefined { + try { + checkDer(c.spki); + const { children } = splitDer(c.spki); + if (children.length !== 2 || children[0]![0] !== 0x30 || children[1]![0] !== 0x03) return undefined; + const alg = parseAlgID(children[0]!); + const bits = derContent(children[1]!); + if (bits[0] !== 0) return undefined; + const key = bits.subarray(1); + if (alg.oid === OID.rsaEncryption) { + if (alg.params === undefined || !equalBytes(alg.params, NULL_PARAMS)) return undefined; + checkDer(key); + const ne = splitDer(key); + if (ne.id !== 0x30 || ne.children.length !== 2 || ne.children[0]![0] !== 0x02 || ne.children[1]![0] !== 0x02) return undefined; + const n = intOf(ne.children[0]!); + const e = intOf(ne.children[1]!); + const nBits = n.toString(2).length; + if (n <= 0n || nBits < 2048 || nBits > 4096 || e < 3n || e % 2n === 0n || e > 2n ** 31n - 1n) return undefined; + return { kind: 'rsa', n, e }; + } + if (alg.oid === OID.ecPublicKey && alg.params !== undefined && alg.params[0] === 0x06) { + const curveOid = oidOf(alg.params); + const curve = curveOid === OID.p256 ? p256 : curveOid === OID.p384 ? p384 : curveOid === OID.p521 ? p521 : undefined; + if (curve === undefined) return undefined; + curve.Point.fromBytes(key); // a point of the curve, or it throws + return { kind: 'ec', curve, point: key }; + } + } catch { + return undefined; + } + return undefined; +} + +/** Whether the algorithms of the SignerInfo and the key of its certificate are in the table of spec §29.10. */ +function algorithmsOK(s: SignerInfo): boolean { + const a = params(s); + const k = publicKey(s.cert); + if (a === undefined || k === undefined) return false; + return a.scheme === 'ecdsa' ? k.kind === 'ec' : k.kind === 'rsa'; +} + +function bigFromBytes(b: Uint8Array): bigint { + let v = 0n; + for (const x of b) v = (v << 8n) | BigInt(x); + return v; +} + +function bytesFromBig(v: bigint, len: number): Uint8Array { + const out = new Uint8Array(len); + for (let i = len - 1; i >= 0; i--) { + out[i] = Number(v & 0xffn); + v >>= 8n; + } + return out; +} + +function modPow(base: bigint, exp: bigint, mod: bigint): bigint { + let result = 1n; + let b = base % mod; + let e = exp; + while (e > 0n) { + if ((e & 1n) === 1n) result = (result * b) % mod; + b = (b * b) % mod; + e >>= 1n; + } + return result; +} + +// RSASSA-PKCS1-v1_5 verification: the encoded message is rebuilt and compared whole, as Go does. +function rsaVerifyPKCS1(k: { n: bigint; e: bigint }, hash: Hash, digest: Uint8Array, sig: Uint8Array): boolean { + const len = Math.ceil(k.n.toString(2).length / 8); + if (sig.length !== len) return false; + const s = bigFromBytes(sig); + if (s >= k.n) return false; + const em = bytesFromBig(modPow(s, k.e, k.n), len); + const t = concatBytes(hash.prefix, digest); + if (len < t.length + 11) return false; + const want = new Uint8Array(len); + want[1] = 1; + want.fill(0xff, 2, len - t.length - 1); + want.set(t, len - t.length); + return equalBytes(em, want); +} + +// MGF1 of RFC 8017 B.2.1. +function mgf1(hash: Hash, seed: Uint8Array, length: number): Uint8Array { + const out = new Uint8Array(Math.ceil(length / hash.size) * hash.size); + for (let counter = 0; counter * hash.size < length; counter++) { + out.set(hash.digest(concatBytes(seed, Uint8Array.of(counter >>> 24, (counter >>> 16) & 255, (counter >>> 8) & 255, counter & 255))), counter * hash.size); + } + return out.subarray(0, length); +} + +// EMSA-PSS-VERIFY of RFC 8017 9.1.2 with a salt of the length of the hash, MGF1 with the same hash and the trailer 0xbc. +function rsaVerifyPSS(k: { n: bigint; e: bigint }, hash: Hash, digest: Uint8Array, sig: Uint8Array): boolean { + const modBits = k.n.toString(2).length; + const len = Math.ceil(modBits / 8); + if (sig.length !== len) return false; + const s = bigFromBytes(sig); + if (s >= k.n) return false; + const emBits = modBits - 1; + const emLen = Math.ceil(emBits / 8); + const full = bytesFromBig(modPow(s, k.e, k.n), len); + if (full.subarray(0, len - emLen).some((x) => x !== 0)) return false; + const em = full.subarray(len - emLen); + const sLen = hash.size; + if (emLen < hash.size + sLen + 2 || em[emLen - 1] !== 0xbc) return false; + const maskedDB = em.subarray(0, emLen - hash.size - 1); + const hh = em.subarray(emLen - hash.size - 1, emLen - 1); + const topBits = 8 * emLen - emBits; + if ((maskedDB[0]! & (0xff << (8 - topBits)) & 0xff) !== 0) return false; + const dbMask = mgf1(hash, hh, maskedDB.length); + const db = maskedDB.map((x, i) => x ^ dbMask[i]!); + if (topBits > 0) db[0] = db[0]! & (0xff >> topBits); + const psLen = emLen - hash.size - sLen - 2; + for (let i = 0; i < psLen; i++) if (db[i] !== 0) return false; + if (db[psLen] !== 1) return false; + const salt = db.subarray(db.length - sLen); + const mPrime = concatBytes(new Uint8Array(8), digest, salt); + return equalBytes(hash.digest(mPrime), hh); +} + +/** + * Checks the signature of the SignerInfo over `message`, the bytes that the + * signature is detached from (spec §29.10): not verifiable for an algorithm + * outside the table; invalid when the message-digest is not the hash of + * message or the signature of the signedAttrs does not verify. + */ +export function checkSigner(s: SignerInfo, message: Uint8Array): CheckResult { + if (!algorithmsOK(s)) return 'not verifiable'; + const { hash, scheme } = params(s)!; + if (!equalBytes(hash.digest(message), s.messageDigest)) return 'invalid'; + // The signature covers the signedAttrs with the tag of a SET. + const attrBytes = s.signedAttrs.slice(); + attrBytes[0] = 0x31; + const digest = hash.digest(attrBytes); + const key = publicKey(s.cert)!; + let ok = false; + if (key.kind === 'rsa') { + ok = scheme === 'pss' ? rsaVerifyPSS(key, hash, digest, s.signature) : rsaVerifyPKCS1(key, hash, digest, s.signature); + } else { + try { + ok = key.curve.verify(s.signature, digest, key.point, { prehash: false, lowS: false, format: 'der' }); + } catch { + ok = false; + } + } + return ok ? 'valid' : 'invalid'; +} + +// ---- the token of RFC 3161 -------------------------------------------------- + +/** A time-stamp token of RFC 3161 read with the profile of spec §29.11. */ +export interface Token { + /** t, and the precision of the token, zero when it has none. */ + readonly genTime: Instant; + readonly accuracy: Instant; + /** The hash of the messageImprint, and the hash. */ + readonly imprintAlg: string; + readonly imprint: Uint8Array; + /** The certificate of the time-stamping authority. */ + readonly tsa: Cert; + readonly data: SignedData; +} + +// The seconds of accuracy are bounded: far above any real one. +const MAX_ACCURACY = 2 ** 31; + +/** + * Reads a time-stamp token. It throws CmsFormError when the form breaks the + * profile and CmsAlgorithmError when an algorithm is outside the table, in + * that order (spec §29.11): the verdicts S2 and S1. + */ +export function parseToken(b: Uint8Array): Token { + const sd = parse(b, true); + // The TSTInfo is an OCTET STRING inside the token, so the check of the token + // did not reach it: it is read here, field by field, in DER. + const info = der(() => parseTSTInfo(sd.eContent!)); + const ia = parseAlgID(info.imprintAlg); + const hash = hashOfAlg(ia); + if (hash !== undefined && info.hash.length !== hash.size) throw form('a messageImprint of the wrong length'); + if (hash === undefined || !algorithmsOK(sd.signers[0]!)) throw new CmsAlgorithmError(); + return { genTime: info.genTime, accuracy: info.accuracy, imprintAlg: ia.oid, imprint: info.hash, tsa: sd.signers[0]!.cert, data: sd }; +} + +function parseTSTInfo(b: Uint8Array): { genTime: Instant; accuracy: Instant; imprintAlg: Uint8Array; hash: Uint8Array } { + const bad = (what: string): never => { + throw form(`the TSTInfo: ${what}`); + }; + checkDer(b); + const { id, children: f } = splitDer(b); + if (id !== 0x30 || f.length < 5) bad('not a SEQUENCE of at least five fields'); + if (f[0]![0] !== 0x02) bad('the version'); + if (intOf(f[0]!) !== 1n) bad('the version is not 1'); + if (f[1]![0] !== 0x06 || f[3]![0] !== 0x02 || f[4]![0] !== 0x18) bad('policy, serialNumber or genTime'); + const mi = f[2]![0] === 0x30 ? splitDer(f[2]!).children : []; + if (mi.length !== 2 || mi[0]![0] !== 0x30 || mi[1]![0] !== 0x04) bad('the messageImprint'); + const hash = derContent(mi[1]!); + const genTime = parseGenTime(f[4]!); + let accuracy: Instant = { seconds: 0, nanos: 0 }; + let rest = f.slice(5); + if (rest.length > 0 && rest[0]![0] === 0x30) { + accuracy = parseAccuracy(rest[0]!); + rest = rest.slice(1); + } + if (rest.length > 0 && rest[0]![0] === 0x01) { + const c = derContent(rest[0]!); + if (c.length !== 1 || c[0] !== 0xff) bad('ordering FALSE is its default and DER does not write it'); + rest = rest.slice(1); + } + if (rest.length > 0 && rest[0]![0] === 0x02) rest = rest.slice(1); // nonce + if (rest.length > 0 && rest[0]![0] === 0xa0) rest = rest.slice(1); // tsa + if (rest.length > 0 && rest[0]![0] === 0xa1) rest = rest.slice(1); // extensions + if (rest.length !== 0) bad('a field out of its place, or one that does not exist'); + return { genTime, accuracy, imprintAlg: mi[0]!, hash }; +} + +// A GeneralizedTime as RFC 3161 and DER write it: YYYYMMDDHHMMSS, a fraction without a trailing zero, and Z. +function parseGenTime(el: Uint8Array): Instant { + const s = new TextDecoder().decode(derContent(el)); + const m = /^(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\.(\d+))?Z$/.exec(s); + if (m === null) throw form('the TSTInfo: genTime is not in UTC with the letter Z'); + const whole = utc(Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6])); + const frac = m[8]; + if (frac === undefined) return whole; + if (frac.endsWith('0')) throw form('the TSTInfo: genTime has a fraction that DER does not write'); + return { seconds: whole.seconds, nanos: Number(frac.slice(0, 9).padEnd(9, '0')) }; +} + +// Accuracy: seconds from 0, and millis and micros from 1 to 999, in that order, each optional (RFC 3161 2.4.2). A negative number would make a seal after the opening date look before it. +function parseAccuracy(b: Uint8Array): Instant { + let f = splitDer(b).children; + let seconds = 0; + let nanos = 0; + const bad = (what: string): never => { + throw form(`the TSTInfo: ${what}`); + }; + if (f.length > 0 && f[0]![0] === 0x02) { + const secs = intOf(f[0]!); + if (secs < 0n || secs > BigInt(MAX_ACCURACY)) bad('accuracy seconds outside 0 to 2^31'); + seconds = Number(secs); + f = f.slice(1); + } + for (const [tag, unit] of [ + [0x80, 1_000_000], + [0x81, 1_000], + ] as const) { + if (f.length > 0 && f[0]![0] === tag) { + const c = derContent(f[0]!); + if (c.length < 1 || c.length > 2 || (c[0]! & 0x80) !== 0) bad('accuracy millis or micros'); + let n = 0; + for (const x of c) n = (n << 8) | x; + if (n < 1 || n > 999) bad('accuracy millis or micros outside 1 to 999'); + nanos += n * unit; + f = f.slice(1); + } + } + if (f.length !== 0) bad('accuracy has a field out of its place'); + return { seconds, nanos }; +} + +/** Whether the messageImprint of the token uses SHA-256, which a seal of seal_type 2 requires (spec §29.11). */ +export function tokenImprintIsSHA256(t: Token): boolean { + return t.imprintAlg === OID.sha256; +} + +/** + * Verifies the token over `subject`, the bytes that it seals: the + * message-digest is the hash of the TSTInfo, the signature of the TSA + * verifies, the messageImprint is the hash of subject and the certificate of + * the TSA is valid at genTime. False is the verdict S3. + */ +export function checkToken(t: Token, subject: Uint8Array): boolean { + const s = t.data.signers[0]!; + if (checkSigner(s, t.data.eContent!) !== 'valid') return false; + const hash = HASH_OF_OID[t.imprintAlg]!; + return equalBytes(hash.digest(subject), t.imprint) && certValidAt(t.tsa, t.genTime); +} + +/** The instant `t` plus the duration `d`, both as Instants. */ +export function addInstants(t: Instant, d: Instant): Instant { + const nanos = t.nanos + d.nanos; + return nanos >= 1_000_000_000 ? { seconds: t.seconds + d.seconds + 1, nanos: nanos - 1_000_000_000 } : { seconds: t.seconds + d.seconds, nanos }; +} + diff --git a/src/lib/dkc/der.test.ts b/src/lib/dkc/der.test.ts new file mode 100644 index 0000000..6402678 --- /dev/null +++ b/src/lib/dkc/der.test.ts @@ -0,0 +1,84 @@ +// Tests of der.ts, the strict check of DER that the CMS reader starts with: the +// same cases as the Go package internal/der. + +import { describe, expect, it } from 'vitest'; +import { checkDer, derContent, DerError, setOfSorted, splitDer } from './der.ts'; +import { h } from './testing/testdata.ts'; + +const zeros = (n: number): string => '00'.repeat(n); + +describe('checkDer', () => { + const cases: [name: string, hex: string, ok: boolean][] = [ + ['sequence of an integer and a null', '3005020101' + '0500', true], + ['a long length', '04' + '8180' + zeros(128), true], + ['a long form under 128', '0481' + '01' + '00', false], + ['a length with a leading zero', '04820001' + '00', false], + ['an indefinite length', '30800000', false], + ['a high tag number', '1f0100', false], + ['a length of 0xff', '04ff', false], + ['a length that does not fit', '0485010000', false], + ['truncated', '0402aa', false], + ['trailing bytes', '0500' + '00', false], + ['BOOLEAN 01', '010101', false], + ['BOOLEAN FF', '0101ff', true], + ['BOOLEAN 00', '010100', true], + ['INTEGER with a leading zero', '02020001', false], + ['INTEGER 0x80 with its zero', '02020080', true], + ['INTEGER with a leading FF', '0202ff80', false], + ['empty INTEGER', '0200', false], + ['ENUMERATED', '0a0101', true], + ['NULL with content', '050100', false], + ['constructed OCTET STRING', '2404' + '0402aabb', false], + ['BIT STRING with unused bits set', '03020701', false], + ['BIT STRING with unused bits clear', '03020780', true], + ['BIT STRING of 4 bits', '030204f0', true], + ['an empty BIT STRING', '0300', false], + ['a BIT STRING of only unused bits', '030105', false], + ['BIT STRING with more than 7 unused bits', '03020800', false], + ['OID', '06032a0304', true], + ['OID with a leading 0x80', '0603800102', false], + ['OID that does not end', '06022a83', false], + ['an empty OID', '0600', false], + ['context tag, constructed', 'a003020101', true], + ['SET in primitive form', '1100', false], + ['SEQUENCE in primitive form', '1000', false], + ['the end of contents', '0000', false], + ['a reserved universal tag', '0e0141', false], + ['a SEQUENCE of the end of contents', '30020000', false], + ['UTF8String', '0c026162', true], + ['too deep', '30'.repeat(40).replace(/30/g, '30') + '', false], + ]; + it.each(cases)('%s', (_name, hex, ok) => { + const b = h(hex); + if (ok) expect(() => checkDer(b)).not.toThrow(); + else expect(() => checkDer(b)).toThrow(DerError); + }); + + it('refuses an element nested more than 32 levels', () => { + // 34 SEQUENCEs, each holding the next, with the lengths filled in. + let b = h('3000'); + for (let i = 0; i < 33; i++) b = Uint8Array.of(0x30, b.length, ...b); + expect(() => checkDer(b)).toThrow(/nested too deep/); + }); +}); + +describe('setOfSorted, splitDer and derContent', () => { + it('knows the order of the elements of a SET OF', () => { + const a = h('020101'); + const b = h('020102'); + expect(setOfSorted([a, b])).toBe(true); + expect(setOfSorted([b, a])).toBe(false); + expect(setOfSorted([a, a])).toBe(false); + expect(setOfSorted([])).toBe(true); + }); + + it('splits a constructed element into its children, and gives the content', () => { + const b = h('30050201010500'); + checkDer(b); + const { id, children } = splitDer(b); + expect([id, children.map((c) => c.length)]).toEqual([0x30, [3, 2]]); + expect(derContent(children[0]!)).toEqual(h('01')); + expect(() => splitDer(h('020101'))).toThrow(/not a constructed/); + expect(() => splitDer(new Uint8Array(0))).toThrow(DerError); + }); +}); diff --git a/src/lib/dkc/der.ts b/src/lib/dkc/der.ts new file mode 100644 index 0000000..e0ab9bd --- /dev/null +++ b/src/lib/dkc/der.ts @@ -0,0 +1,164 @@ +// A strict check that bytes are one element in the Distinguished Encoding +// Rules of X.690, as the Go package internal/der does it, which spec v0.11 +// §29.10 asks of a CMS signature before anyone looks inside it: definite and +// minimal lengths, no high tag numbers, no constructed form of a type that DER +// only has primitive, canonical BOOLEAN, INTEGER, NULL, OBJECT IDENTIFIER and +// BIT STRING, only the universal types that certificates, signatures and +// tokens use, and no bytes after the element. The order of the elements of a +// SET OF cannot be checked without a schema: setOfSorted does it for the +// callers that know theirs. Internal: index.ts does not re-export it. + +import { compareBytes } from './bytes.ts'; + +/** A byte string that is not DER; the message says what is wrong. */ +export class DerError extends Error { + constructor(message: string) { + super(`der: ${message}`); + this.name = 'DerError'; + } +} + +const MAX_DEPTH = 32; + +/** Throws a DerError unless `b` is exactly one DER element. */ +export function checkDer(b: Uint8Array): void { + const n = check(b, 0); + if (n !== b.length) throw new DerError(`${b.length - n} bytes after the element`); +} + +/** + * The identifier octet of the constructed DER element `b` and the encodings of + * its children, in order. It assumes checkDer passed. + */ +export function splitDer(b: Uint8Array): { id: number; children: Uint8Array[] } { + if (b.length === 0 || (b[0]! & 0x20) === 0) throw new DerError('not a constructed element'); + const { headerLen, contentLen } = header(b); + let rest = b.subarray(headerLen, headerLen + contentLen); + const children: Uint8Array[] = []; + while (rest.length > 0) { + const h = header(rest); + children.push(rest.subarray(0, h.headerLen + h.contentLen)); + rest = rest.subarray(h.headerLen + h.contentLen); + } + return { id: b[0]!, children }; +} + +/** The content octets of the DER element `b`. */ +export function derContent(b: Uint8Array): Uint8Array { + const { headerLen, contentLen } = header(b); + return b.subarray(headerLen, headerLen + contentLen); +} + +/** + * Whether the encodings are in ascending order of their bytes, as DER + * requires of the elements of a SET OF (X.690 11.6), and without repetitions: a + * SET OF of this profile has none. + */ +export function setOfSorted(elems: readonly Uint8Array[]): boolean { + for (let i = 1; i < elems.length; i++) { + if (compareBytes(elems[i - 1]!, elems[i]!) >= 0) return false; + } + return true; +} + +// The length of the identifier and length octets of the element at the start +// of b, and the length of its content, which must fit in b. +function header(b: Uint8Array): { headerLen: number; contentLen: number } { + if (b.length < 2) throw new DerError('truncated element'); + if ((b[0]! & 0x1f) === 0x1f) throw new DerError('a tag number of 31 or more'); + const l = b[1]!; + let headerLen: number; + let contentLen: number; + if (l < 0x80) { + headerLen = 2; + contentLen = l; + } else if (l === 0x80) { + throw new DerError('an indefinite length'); + } else if (l === 0xff) { + throw new DerError('a length of 0xff'); + } else { + const n = l & 0x7f; + if (n > 4 || b.length < 2 + n) throw new DerError('a length that does not fit'); + if (b[2] === 0) throw new DerError('a length with a leading zero'); + let v = 0; + for (let i = 0; i < n; i++) v = v * 256 + b[2 + i]!; + if (v < 0x80) throw new DerError('a long form for a length under 128'); + headerLen = 2 + n; + contentLen = v; + } + if (contentLen > b.length - headerLen) throw new DerError('an element longer than its container'); + return { headerLen, contentLen }; +} + +// Validates the element at the start of b and returns its length. +function check(b: Uint8Array, depth: number): number { + if (depth > MAX_DEPTH) throw new DerError('nested too deep'); + const { headerLen, contentLen } = header(b); + let content = b.subarray(headerLen, headerLen + contentLen); + const id = b[0]!; + const cls = id >> 6; + const constructed = (id & 0x20) !== 0; + const tag = id & 0x1f; + if (constructed) { + if (cls === 0 && tag !== 16 && tag !== 17) throw new DerError(`constructed form of the universal type ${tag}`); + while (content.length > 0) { + const n = check(content, depth + 1); + content = content.subarray(n); + } + return headerLen + contentLen; + } + if (cls === 0) checkPrimitive(tag, content); + return headerLen + contentLen; +} + +function checkPrimitive(tag: number, c: Uint8Array): void { + switch (tag) { + case 1: // BOOLEAN + if (c.length !== 1 || (c[0] !== 0 && c[0] !== 0xff)) throw new DerError('a BOOLEAN that is not 00 or FF'); + return; + case 2: // INTEGER + case 10: // ENUMERATED + if (c.length === 0) throw new DerError('an empty INTEGER'); + if (c.length > 1 && ((c[0] === 0 && (c[1]! & 0x80) === 0) || (c[0] === 0xff && (c[1]! & 0x80) !== 0))) { + throw new DerError('an INTEGER that is not minimal'); + } + return; + case 3: // BIT STRING + if (c.length === 0 || c[0]! > 7 || (c.length === 1 && c[0] !== 0)) throw new DerError('a malformed BIT STRING'); + if (c.length > 1 && c[0] !== 0 && (c[c.length - 1]! & ((1 << c[0]!) - 1)) !== 0) { + throw new DerError('a BIT STRING with unused bits that are not zero'); + } + return; + case 5: // NULL + if (c.length !== 0) throw new DerError('a NULL with content'); + return; + case 6: { + // OBJECT IDENTIFIER + if (c.length === 0 || (c[c.length - 1]! & 0x80) !== 0) throw new DerError('a malformed OBJECT IDENTIFIER'); + let start = true; + for (const x of c) { + if (start && x === 0x80) throw new DerError('an OBJECT IDENTIFIER with a leading 0x80 in a subidentifier'); + start = (x & 0x80) === 0; + } + return; + } + case 4: // OCTET STRING and the string and time types of X.509 + case 12: + case 19: + case 20: + case 22: + case 23: + case 24: + case 26: + case 28: + case 30: + return; + case 16: + case 17: + throw new DerError(`the universal type ${tag} in primitive form`); + default: + // 0 is the end of contents of BER, and the rest are types that no + // certificate, signature or token of the profile has. + throw new DerError(`the universal type ${tag}, which the profile does not use`); + } +} diff --git a/src/lib/dkc/fixtures.test.ts b/src/lib/dkc/fixtures.test.ts index e93a54a..43be0e8 100644 --- a/src/lib/dkc/fixtures.test.ts +++ b/src/lib/dkc/fixtures.test.ts @@ -17,7 +17,9 @@ 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, kinds, ported } from './testing/pending.ts'; +import { kinds } from './testing/verdicts.ts'; +import type { SignerLine } from './securitycms.ts'; +import { formatRFC3339, parseRFC3339 } from './datekey.ts'; import { h, hx, listTestdata, readBytes, readJSON } from './testing/testdata.ts'; interface FixtureExt { @@ -76,9 +78,30 @@ interface DkcFixture { signers_digest: string; author_message: string; author_code: string; + signer_results?: SignerResult[]; + foreign_signers?: SignerResult[]; }; + /** The record of a valid seal (spec v0.11, §29.11). */ + seal?: { seal_subject: string; holder: string; time: string }; } +interface SignerResult { + holder: string; + issuer: string; + result: string; + seal_time?: string; + before_round_time: boolean; +} + +// A signer as the record of the reference writes it. +const resultOf = (s: SignerLine): SignerResult => ({ + holder: s.holder, + issuer: s.issuer, + result: s.result, + ...(s.sealTime === undefined ? {} : { seal_time: formatRFC3339(s.sealTime) }), + before_round_time: s.before, +}); + interface DkkFixture { file: string; sha256: string; @@ -220,7 +243,11 @@ describe.each(dkcFixtures)('$json.file', ({ json: fx }) => { expect(hx(encodeHead(decoded))).toBe(fx.head_cbor); // 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) }); + const verdicts = evaluateSecurity(security, { + controlCommit: controlCommit(control, FORMAT_3), + headDigest: headDigest(head), + roundTime: parseRFC3339(fx.unlock_at), + }); 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); @@ -235,11 +262,16 @@ describe.each(dkcFixtures)('$json.file', ({ json: fx }) => { 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(kinds(verdicts)).toEqual(want); - if (!isPending(fx.verdicts!)) expect(verdictLines(verdicts)).toEqual(fx.verdicts!.lines); + expect(kinds(verdicts)).toEqual({ signature: fx.verdicts!.signature, seal: fx.verdicts!.seal }); + expect(verdictLines(verdicts)).toEqual(fx.verdicts!.lines); + // The signers of an alg 2 signature, as the reference records them, and the authority of a valid seal. + if (fx.signature?.signer_results !== undefined) { + expect(verdicts.detail!.signers.map(resultOf)).toEqual(fx.signature.signer_results); + expect(verdicts.detail!.foreign.map(resultOf)).toEqual(fx.signature.foreign_signers ?? []); + } + if (fx.seal !== undefined) { + expect([verdicts.detail!.sealHolder, formatRFC3339(verdicts.detail!.sealTime!)]).toEqual([fx.seal.holder, fx.seal.time]); + } }); }); diff --git a/src/lib/dkc/open.test.ts b/src/lib/dkc/open.test.ts index 3cf96a9..e95d36b 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, kinds, ported } from './testing/pending.ts'; +import { kinds } from './testing/verdicts.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,8 +102,8 @@ 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(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(kinds(r.verdicts!), f.name).toEqual({ signature: f.side.verdicts!.signature, seal: f.side.verdicts!.seal }); + 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, []]); } diff --git a/src/lib/dkc/open.ts b/src/lib/dkc/open.ts index 3f38179..79c9baf 100644 --- a/src/lib/dkc/open.ts +++ b/src/lib/dkc/open.ts @@ -420,6 +420,7 @@ async function openCapsule( if (format === FORMAT_3) { body = await openBody(plain, padded!.l, padded!.p, opts.sink!, opts.extensions, { controlCommit: controlCommit(control, format), + roundTime: inspection.unlockAt!, ...(opts.authorKeys === undefined ? {} : { authorKeys: opts.authorKeys }), }); } else if (opts.output === undefined) { diff --git a/src/lib/dkc/open3.ts b/src/lib/dkc/open3.ts index 5b7485e..08d78e6 100644 --- a/src/lib/dkc/open3.ts +++ b/src/lib/dkc/open3.ts @@ -28,6 +28,7 @@ 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 type { Instant } from './datekey.ts'; import { evaluateSecurity, type Verdicts } from './security.ts'; import type { Sink } from './sink.ts'; @@ -180,7 +181,7 @@ export async function openBody( p: number, sink: Sink, reg: ExtensionRegistry | undefined, - security: { readonly controlCommit: Uint8Array; readonly authorKeys?: ReadonlyMap }, + security: { readonly controlCommit: Uint8Array; readonly roundTime: Instant; readonly authorKeys?: ReadonlyMap }, ): Promise { const r = new Plaintext(plain, p); let begun = false; @@ -201,6 +202,7 @@ export async function openBody( const verdicts = evaluateSecurity(securityBytes, { controlCommit: security.controlCommit, headDigest: headDigest(hb), + roundTime: security.roundTime, ...(security.authorKeys === undefined ? {} : { authorKeys: security.authorKeys }), }); let head: Head; diff --git a/src/lib/dkc/security.test.ts b/src/lib/dkc/security.test.ts index 49ded30..5095db5 100644 --- a/src/lib/dkc/security.test.ts +++ b/src/lib/dkc/security.test.ts @@ -83,6 +83,24 @@ describe('verdictLines', () => { expect(verdictText('S0')).toBe(''); }); + // F6 and S4 write the names that the reader found (spec §29.7, §29.10): a signer sealed before the round time or not, a signer who does not count. + it('writes the lines of F6 and S4 from the signers and the authority that were found', () => { + const t = { seconds: 1_790_000_000, nanos: 0 }; + const line = (holder: string, before: boolean, result = 'valid') => ({ holder, issuer: `emisor de ${holder}`, result, sealTime: t, before }); + const lines = verdictLines({ + signature: 'F6', + seal: 'S4', + detail: { signers: [line('Ana', true), line('Luis', false)], foreign: [line('Otro', true, 'invalid')], sealHolder: 'TSA', sealTime: t }, + }); + expect(lines).toEqual([ + 'Firmado con un certificado a nombre de Ana, Luis. DateKeys no comprueba quién lo emitió: para eso, exporta la firma a un validador oficial.', + ' Ana (emisor según su certificado: emisor de Ana), sellado el 2026-09-21T14:13:20Z, antes de la fecha de apertura.', + ' Luis (emisor según su certificado: emisor de Luis), sellado el 2026-09-21T14:13:20Z, no antes de la fecha de apertura.', + ' Otro firmante, Otro: invalid. No cuenta.', + 'Según un sello a nombre de TSA, existía el 2026-09-21T14:13:20Z, antes de que la cápsula pudiera abrirse. DateKeys no comprueba quién emitió el sello.', + ]); + }); + // The verdicts of spec v0.11 (§29.7) that this library does not reach yet: it knows their fixed texts, and leaves to whoever shows // F3, F4, F6 and S4 the text that names a key, a holder or a time. it('has the text of the verdicts of v0.11 that do not name anything, and none for those that do', () => { diff --git a/src/lib/dkc/security.ts b/src/lib/dkc/security.ts index b130a92..944ef1f 100644 --- a/src/lib/dkc/security.ts +++ b/src/lib/dkc/security.ts @@ -7,13 +7,15 @@ // 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 { ALG_CMS, 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 { formatRFC3339, type Instant } from './datekey.ts'; import { fieldOf, requireKeys } from './schema.ts'; +import { type Detail, evaluateCMS, evaluateSeal } from './securitycms.ts'; export const SECURITY_TYPE_TAG = 'datekeys-security'; export const SECURITY_VERSION = 1; @@ -21,6 +23,8 @@ export const SECURITY_VERSION = 1; // seal_type. const MAX_SECURITY_ITEM = 65536; const MAX_ALG = 2 ** 32 - 1; +/** The seal_type of an RFC 3161 token (spec v0.11, §29.3, §29.11). */ +const SEAL_TYPE_RFC3161 = 2; /** * The verdict on the signature or on the seal of the security area (spec @@ -43,6 +47,8 @@ export interface Verdicts { readonly authorKey?: Uint8Array; /** The label of the saved key that signed (F3). */ readonly authorLabel?: string; + /** The signers of a signature of alg 2 and the authority of a valid seal (F2, F5, F6, S4, S5). */ + readonly detail?: Detail; } /** @@ -55,6 +61,8 @@ export interface Verdicts { export interface SecurityContext { readonly controlCommit: Uint8Array; readonly headDigest: Uint8Array; + /** round_time, the time of the round: a seal before it proves that the content existed before the capsule could open. */ + readonly roundTime?: Instant; readonly authorKeys?: ReadonlyMap; } @@ -96,8 +104,22 @@ export function verdictLines(v: Verdicts): string[] { // 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 === '' ? [signature] : [signature, seal]; + const lines = [signature]; + const d = v.detail; + if (v.signature === 'F6' && d !== undefined) { + lines[0] = `Firmado con un certificado a nombre de ${d.signers.map((s) => s.holder).join(', ')}. DateKeys no comprueba quién lo emitió: para eso, exporta la firma a un validador oficial.`; + for (const s of d.signers) { + const when = s.before ? 'antes de la fecha de apertura' : 'no antes de la fecha de apertura'; + lines.push(` ${s.holder} (emisor según su certificado: ${s.issuer}), sellado el ${formatRFC3339(s.sealTime!)}, ${when}.`); + } + } + for (const s of d?.foreign ?? []) lines.push(` Otro firmante, ${s.holder}: ${s.result}. No cuenta.`); + if (v.seal === 'S4' && d?.sealHolder !== undefined) { + lines.push(`Según un sello a nombre de ${d.sealHolder}, existía el ${formatRFC3339(d.sealTime!)}, antes de que la cápsula pudiera abrirse. DateKeys no comprueba quién emitió el sello.`); + } else if (verdictText(v.seal) !== '') { + lines.push(verdictText(v.seal)); + } + return lines; } // The outer map of SECURITY_CBOR: keys 2 and 3, undefined when absent. @@ -277,10 +299,30 @@ export function evaluateSecurity(b: Uint8Array, context?: SecurityContext): Verd }); if (w === undefined) return { signature: 'X', seal: 'X' }; const { signature, seal } = w; - return { - ...(signature === undefined ? { signature: 'F0' as const } : evaluateSignature(signature, context)), - seal: seal === undefined ? 'S0' : attempt(() => unmarshal(seal, decodeSeal, encodeSeal)) === undefined ? 'S2' : 'S1', - }; + const sig = signature === undefined ? { signature: 'F0' as const } : evaluateSignature(signature, seal !== undefined, context); + const sealed = seal === undefined ? { seal: 'S0' as const } : evaluateSealArea(seal, signature, context); + const detail: Detail | undefined = + sig.detail === undefined && sealed.sealHolder === undefined + ? undefined + : { + signers: sig.detail?.signers ?? [], + foreign: sig.detail?.foreign ?? [], + ...(sealed.sealHolder === undefined ? {} : { sealHolder: sealed.sealHolder, sealTime: sealed.sealTime! }), + }; + return { ...sig, seal: sealed.seal, ...(detail === undefined ? {} : { detail }) }; +} + +// The verdict of the content of key 3 (spec §29.7, §29.11): S2 for content that does not decode, S1 for a seal_type +// that is not 2 and, without the context of a capsule, as in v0.10, for seal_type 2 too; otherwise the token is checked. +function evaluateSealArea( + seal: Uint8Array, + signature: Uint8Array | undefined, + context: SecurityContext | undefined, +): { seal: Verdict; sealHolder?: string; sealTime?: Instant } { + const s = attempt(() => unmarshal(seal, decodeSeal, encodeSeal)); + if (s === undefined) return { seal: 'S2' }; + if (s.sealType !== SEAL_TYPE_RFC3161 || context === undefined) return { seal: 'S1' }; + return evaluateSeal(s.token, signature, context.controlCommit, context.headDigest, context.roundTime); } // The verdict of the content of key 2 (spec §29.7, §29.9): F1 for content @@ -288,11 +330,16 @@ export function evaluateSecurity(b: Uint8Array, context?: SecurityContext): Verd // 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 { +function evaluateSignature(content: Uint8Array, hasSeal: boolean, 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; + if (a === undefined) return unchecked; + if (a.alg === ALG_CMS) { + const r = evaluateCMS(a.key, a.value, hasSeal, context.controlCommit, context.headDigest, context.roundTime); + return r === undefined ? unchecked : r; + } + if (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)); diff --git a/src/lib/dkc/securitycms.ts b/src/lib/dkc/securitycms.ts new file mode 100644 index 0000000..258a1c3 --- /dev/null +++ b/src/lib/dkc/securitycms.ts @@ -0,0 +1,187 @@ +// The verdicts of a signature of alg 2 (CMS with certificates) and of a seal of +// seal_type 2 (RFC 3161), as the Go package capsule gives them (signature2.go, +// spec v0.11 §29.7, §29.10, §29.11): F1, F2, F5 and F6 with the signers named, +// and S1 to S5 with the authority of a valid seal. Internal: index.ts does not +// re-export it. + +import { ALG_CMS, authorMessage, sealSubject, signersDigest } from './author.ts'; +import { equalBytes, toHex, utf8Length } from './bytes.ts'; +import { type Decoder, Encoder, unmarshal } from './cbor.ts'; +import { + addInstants, + certHolder, + certIssuerName, + certValidAt, + checkSigner, + checkToken, + CmsAlgorithmError, + CmsFormError, + parseSignature, + parseToken, + type SignerInfo, + tokenImprintIsSHA256, +} from './cms.ts'; +import { compareInstants, type Instant } from './datekey.ts'; +import { DateKeysError } from './errors.ts'; +import { checkAuthor } from './pathrule.ts'; + +/** The most required signers of an alg 2 signature (spec §29.10). */ +export const MAX_SIGNERS = 16; +const MAX_AUTHOR_LEN = 256; + +/** A signer of an alg 2 signature as a reader shows it (spec §29.7, §29.10). */ +export interface SignerLine { + /** The name of the certificate as §29.7 shows it, or the SHA-256 of the certificate in hexadecimal when it does not meet the rules of the declared author. */ + readonly holder: string; + /** The issuer that the certificate says, with the same rules. */ + readonly issuer: string; + /** 'valid', 'invalid', 'absent', 'not verifiable', 'without seal', 'invalid seal' or 'out of validity'. */ + readonly result: string; + /** t, undefined without a seal that verifies. */ + readonly sealTime?: Instant; + /** Whether t plus the accuracy of the seal is before round_time. */ + readonly before: boolean; +} + +/** What the texts of F6, S4 and S5 name (spec §29.7, §29.10). */ +export interface Detail { + /** The required signers, in the order of SIGNERS, and the SignerInfo of other certificates, which never count. */ + readonly signers: readonly SignerLine[]; + readonly foreign: readonly SignerLine[]; + /** The holder of the certificate of the authority of a valid seal, as §29.7 writes it, and t. */ + readonly sealHolder?: string; + readonly sealTime?: Instant; +} + +// SIGNERS: a CBOR array of 1 to 16 strings of 32 bytes in strictly ascending order of bytes (spec §29.10). Throws a DateKeysError when it is not. +function decodeSigners(b: Uint8Array): Uint8Array[] { + const out: Uint8Array[] = []; + const decode = (d: Decoder): void => { + const n = d.array(MAX_SIGNERS); + if (n < 1) throw new DateKeysError('ERR_NON_CANONICAL_CBOR', 'SIGNERS is empty'); + for (let i = 0; i < n; i++) { + const h = d.bstr(32, 32); + const last = out[out.length - 1]; + if (last !== undefined && compare(last, h) >= 0) throw new DateKeysError('ERR_NON_CANONICAL_CBOR', 'SIGNERS is not in strictly ascending order'); + out.push(h); + } + }; + const encode = (e: Encoder): void => { + e.array(out.length); + for (const h of out) e.bstr(h); + }; + unmarshal(b, decode, encode); + return out; +} + +function compare(a: Uint8Array, b: Uint8Array): number { + for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] !== b[i]) return a[i]! < b[i]! ? -1 : 1; + return a.length - b.length; +} + +// How §29.7 shows a name: the name, when it meets the rules of the declared author, and the SHA-256 otherwise. +function holderText(name: string, hash: Uint8Array): string { + if (name !== '' && utf8Length(name) <= MAX_AUTHOR_LEN) { + try { + checkAuthor(name); + return name; + } catch (err) { + /* v8 ignore next -- @preserve: checkAuthor throws only its own error */ + if (!(err instanceof DateKeysError || err instanceof Error)) throw err; + } + } + return toHex(hash); +} + +// One SignerInfo as §29.10 orders: not verifiable, invalid, without seal, with an invalid seal, out of validity, or valid. +function signerLine(s: SignerInfo, msg: Uint8Array, roundTime: Instant | undefined): SignerLine { + const base = { holder: holderText(certHolder(s.cert), s.cert.hash), issuer: holderText(certIssuerName(s.cert), s.cert.hash) }; + const r = checkSigner(s, msg); + if (r === 'not verifiable') return { ...base, result: 'not verifiable', before: false }; + if (r === 'invalid') return { ...base, result: 'invalid', before: false }; + if (s.token === undefined) return { ...base, result: 'without seal', before: false }; + let ok = false; + let tok; + try { + tok = parseToken(s.token); + ok = checkToken(tok, s.signature); + } catch (err) { + if (!(err instanceof CmsFormError || err instanceof CmsAlgorithmError)) throw err; + } + if (!ok || tok === undefined) return { ...base, result: 'invalid seal', before: false }; + if (!certValidAt(s.cert, tok.genTime)) return { ...base, result: 'out of validity', before: false }; + return { ...base, result: 'valid', sealTime: tok.genTime, before: roundTime !== undefined && compareInstants(addInstants(tok.genTime, tok.accuracy), roundTime) < 0 }; +} + +/** + * The verdict of a signature of alg 2 (spec §29.10): undefined for F1 (content + * that breaks its profile), F2 when the signature of a required signer is + * invalid, F5 when something the capsule demands is missing, F6 when every + * required signer is valid and sealed. `signers` and `value` are keys 1 and 2 + * of the author-signature; `hasSeal` is whether key 3 exists, which an alg 2 + * signature forbids. + */ +export function evaluateCMS( + signers: Uint8Array, + value: Uint8Array, + hasSeal: boolean, + controlCommit: Uint8Array, + headDigest: Uint8Array, + roundTime: Instant | undefined, +): { signature: 'F2' | 'F5' | 'F6'; detail: Detail } | undefined { + let required: Uint8Array[]; + let sd; + try { + required = decodeSigners(signers); + sd = parseSignature(value); + } catch (err) { + if (!(err instanceof DateKeysError || err instanceof CmsFormError)) throw err; + return undefined; + } + const msg = authorMessage(controlCommit, headDigest, signersDigest(ALG_CMS, signers)); + const byHash = new Map(sd.signers.map((s) => [toHex(s.cert.hash), s])); + let invalid = false; + let incomplete = hasSeal; + const lines: SignerLine[] = []; + for (const h of required) { + const s = byHash.get(toHex(h)); + if (s === undefined) { + lines.push({ holder: toHex(h), issuer: '', result: 'absent', before: false }); + incomplete = true; + continue; + } + const line = signerLine(s, msg, roundTime); + if (line.result === 'invalid') invalid = true; + else if (line.result !== 'valid') incomplete = true; + lines.push(line); + } + const foreign = sd.signers.filter((s) => !required.some((h) => equalBytes(h, s.cert.hash))).map((s) => signerLine(s, msg, roundTime)); + return { signature: invalid ? 'F2' : incomplete ? 'F5' : 'F6', detail: { signers: lines, foreign } }; +} + +/** + * The verdict of a seal of seal_type 2 (spec §29.11): S2 or S1 for the form and + * the algorithms, S3 when it does not verify, and S4 or S5 when it does, with + * the authority and t. `signature` is the content of key 2, undefined without it. + */ +export function evaluateSeal( + token: Uint8Array, + signature: Uint8Array | undefined, + controlCommit: Uint8Array, + headDigest: Uint8Array, + roundTime: Instant | undefined, +): { seal: 'S1' | 'S2' | 'S3' | 'S4' | 'S5'; sealHolder?: string; sealTime?: Instant } { + let tok; + try { + tok = parseToken(token); + } catch (err) { + if (err instanceof CmsFormError) return { seal: 'S2' }; + if (err instanceof CmsAlgorithmError) return { seal: 'S1' }; + throw err; + } + if (!tokenImprintIsSHA256(tok)) return { seal: 'S1' }; + if (!checkToken(tok, sealSubject(controlCommit, headDigest, signature))) return { seal: 'S3' }; + const sealHolder = holderText(certHolder(tok.tsa), tok.tsa.hash); + const before = roundTime !== undefined && compareInstants(addInstants(tok.genTime, tok.accuracy), roundTime) < 0; + return { seal: before ? 'S4' : 'S5', sealHolder, sealTime: tok.genTime }; +} diff --git a/src/lib/dkc/testing/cmsbuild.ts b/src/lib/dkc/testing/cmsbuild.ts new file mode 100644 index 0000000..3b5b92a --- /dev/null +++ b/src/lib/dkc/testing/cmsbuild.ts @@ -0,0 +1,299 @@ +// Builds the CMS signatures and the RFC 3161 tokens that the tests of cms.ts and +// of the verdicts read: certificates of test keys, a detached signature of a +// message with its signedAttrs and, optionally, a time-stamp token of its +// signature. It is the encoder that a signing application has, the TypeScript +// counterpart of internal/cms/cmstest of the Go reference; nothing outside +// tests uses it. Keys and signatures come from WebCrypto, and the DER is built +// here. The certificates are not signed by anyone: cms.ts never checks who +// issued a certificate. + +import { concatBytes, compareBytes } from '../bytes.ts'; + +// WebCrypto takes a BufferSource over an ArrayBuffer, which a Uint8Array view does not promise. +const bs = (b: Uint8Array): ArrayBuffer => b.slice().buffer as ArrayBuffer; + +// ---- DER --------------------------------------------------------------------- + +export function tlv(tag: number, ...content: Uint8Array[]): Uint8Array { + const c = concatBytes(...content); + const n = c.length; + const head = n < 0x80 ? [tag, n] : n < 0x100 ? [tag, 0x81, n] : n < 0x10000 ? [tag, 0x82, n >> 8, n & 255] : [tag, 0x83, n >> 16, (n >> 8) & 255, n & 255]; + return concatBytes(Uint8Array.from(head), c); +} +export const seq = (...c: Uint8Array[]): Uint8Array => tlv(0x30, ...c); +/** A SET OF (or an implicit [n] SET OF) in DER order. */ +export const set = (tag: number, ...elems: Uint8Array[]): Uint8Array => tlv(tag, ...[...elems].sort(compareBytes)); +export const octets = (b: Uint8Array): Uint8Array => tlv(0x04, b); +export const utf8 = (s: string): Uint8Array => tlv(0x0c, new TextEncoder().encode(s)); + +export function oid(s: string): Uint8Array { + const parts = s.split('.').map(BigInt); + const out: number[] = []; + const push = (v: bigint): void => { + const bytes = [Number(v & 0x7fn)]; + v >>= 7n; + while (v > 0n) { + bytes.unshift(Number(v & 0x7fn) | 0x80); + v >>= 7n; + } + out.push(...bytes); + }; + push(parts[0]! * 40n + parts[1]!); + for (const p of parts.slice(2)) push(p); + return tlv(0x06, Uint8Array.from(out)); +} + +export function int(n: bigint | number): Uint8Array { + let v = BigInt(n); + const bytes: number[] = []; + for (;;) { + bytes.unshift(Number(v & 0xffn)); + v >>= 8n; + if ((v === 0n && (bytes[0]! & 0x80) === 0) || (v === -1n && (bytes[0]! & 0x80) !== 0)) break; + } + return tlv(0x02, Uint8Array.from(bytes)); +} + +const hex = (s: string): Uint8Array => Uint8Array.from(s.match(/../g) ?? [], (b) => parseInt(b, 16)); +const NULL = Uint8Array.of(5, 0); + +export const OID = { + data: '1.2.840.113549.1.7.1', + signedData: '1.2.840.113549.1.7.2', + contentType: '1.2.840.113549.1.9.3', + messageDigest: '1.2.840.113549.1.9.4', + sigCertV1: '1.2.840.113549.1.9.16.2.12', + sigCertV2: '1.2.840.113549.1.9.16.2.47', + timeStamp: '1.2.840.113549.1.9.16.2.14', + tstInfo: '1.2.840.113549.1.9.16.1.4', + ocsp: '1.3.6.1.5.5.7.16.2', + rsa: '1.2.840.113549.1.1.1', + pss: '1.2.840.113549.1.1.10', + mgf1: '1.2.840.113549.1.1.8', + ecdsa: { 'SHA-256': '1.2.840.10045.4.3.2', 'SHA-384': '1.2.840.10045.4.3.3', 'SHA-512': '1.2.840.10045.4.3.4' }, + sha: { 'SHA-256': '2.16.840.1.101.3.4.2.1', 'SHA-384': '2.16.840.1.101.3.4.2.2', 'SHA-512': '2.16.840.1.101.3.4.2.3' }, +} as const; + +export type HashName = 'SHA-256' | 'SHA-384' | 'SHA-512'; +const HASH_SIZE: Record = { 'SHA-256': 32, 'SHA-384': 48, 'SHA-512': 64 }; +export const digest = async (h: HashName | 'SHA-1', b: Uint8Array): Promise => new Uint8Array(await crypto.subtle.digest(h, bs(b))); +const hashAlg = (h: HashName): Uint8Array => seq(oid(OID.sha[h])); + +// ---- signers: a certificate with its private key ------------------------------ + +export interface Signer { + /** The DER of the certificate, its subjectKeyIdentifier, and what a SignerInfo needs of it. */ + readonly cert: Uint8Array; + readonly ski: Uint8Array; + readonly issuer: Uint8Array; + readonly serial: bigint; + /** The private key as PKCS #8, and its kind. */ + readonly pkcs8: Uint8Array; + readonly kind: 'rsa' | 'P-256' | 'P-384' | 'P-521'; +} + +/** How the certificate names itself: by commonName, by givenName and surname, or with neither. */ +export type NameKind = 'cn' | 'given-surname' | 'organization'; + +function name(cn: string, kind: NameKind = 'cn'): Uint8Array { + const org = set(0x31, seq(oid('2.5.4.10'), utf8('DateKeys test'))); + if (kind === 'given-surname') return seq(set(0x31, seq(oid('2.5.4.42'), utf8(cn.split(' ')[0]!))), set(0x31, seq(oid('2.5.4.4'), utf8(cn.split(' ')[1] ?? 'X'))), org); + if (kind === 'organization') return seq(org); + return seq(set(0x31, seq(oid('2.5.4.3'), utf8(cn))), org); +} + +function utcTime(t: Date): Uint8Array { + const p = (n: number, w = 2): string => String(n).padStart(w, '0'); + const y = t.getUTCFullYear(); + const body = `${p(t.getUTCMonth() + 1)}${p(t.getUTCDate())}${p(t.getUTCHours())}${p(t.getUTCMinutes())}${p(t.getUTCSeconds())}Z`; + return y < 2050 ? tlv(0x17, new TextEncoder().encode(`${p(y % 100)}${body}`)) : tlv(0x18, new TextEncoder().encode(`${p(y, 4)}${body}`)); +} + +async function signerOf(kind: Signer['kind'], bits: number, cn: string, notBefore: Date, notAfter: Date, nameKind: NameKind = 'cn'): Promise { + const algorithm = kind === 'rsa' ? { name: 'RSASSA-PKCS1-v1_5', modulusLength: bits, publicExponent: Uint8Array.of(1, 0, 1), hash: 'SHA-256' } : { name: 'ECDSA', namedCurve: kind }; + const pair = (await crypto.subtle.generateKey(algorithm, true, ['sign', 'verify'])) as CryptoKeyPair; + const spki = new Uint8Array(await crypto.subtle.exportKey('spki', pair.publicKey)); + const pkcs8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', pair.privateKey)); + const ski = new TextEncoder().encode(cn); + const issuer = name(cn, nameKind); + const serial = BigInt(Math.floor(Math.random() * 2 ** 40) + 1); + const tbs = seq( + tlv(0xa0, int(2)), + int(serial), + seq(oid(OID.ecdsa['SHA-256'])), + issuer, + seq(utcTime(notBefore), utcTime(notAfter)), + issuer, + spki, + tlv(0xa3, seq(seq(oid('2.5.29.14'), octets(octets(ski))))), + ); + const cert = seq(tbs, seq(oid(OID.ecdsa['SHA-256'])), tlv(0x03, Uint8Array.of(0, 1, 2, 3, 4, 5, 6, 7))); + return { cert, ski, issuer, serial, pkcs8, kind }; +} + +/** A signer with an RSA key of `bits` bits. */ +export const newRSA = (cn: string, bits: number, notBefore: Date, notAfter: Date): Promise => signerOf('rsa', bits, cn, notBefore, notAfter); +/** A signer with an ECDSA key on a NIST curve. */ +export const newECDSA = (cn: string, curve: 'P-256' | 'P-384' | 'P-521', notBefore: Date, notAfter: Date, nameKind: NameKind = 'cn'): Promise => + signerOf(curve, 0, cn, notBefore, notAfter, nameKind); + +// ECDSA from WebCrypto is r || s; a CMS signature is the DER of the two integers. +function ecdsaDER(raw: Uint8Array): Uint8Array { + const half = raw.length / 2; + const unsigned = (b: Uint8Array): Uint8Array => { + let i = 0; + while (i < b.length - 1 && b[i] === 0) i++; + const v = b.subarray(i); + return tlv(0x02, (v[0]! & 0x80) !== 0 ? concatBytes(Uint8Array.of(0), v) : v); + }; + return seq(unsigned(raw.subarray(0, half)), unsigned(raw.subarray(half))); +} + +async function sign(s: Signer, o: Options, data: Uint8Array): Promise { + const hash = o.hash ?? 'SHA-256'; + if (s.kind === 'rsa') { + const name = o.pss ? 'RSA-PSS' : 'RSASSA-PKCS1-v1_5'; + const key = await crypto.subtle.importKey('pkcs8', bs(s.pkcs8), { name, hash }, false, ['sign']); + return new Uint8Array(await crypto.subtle.sign(o.pss ? { name, saltLength: HASH_SIZE[hash] } : { name }, key, bs(data))); + } + const key = await crypto.subtle.importKey('pkcs8', bs(s.pkcs8), { name: 'ECDSA', namedCurve: s.kind }, false, ['sign']); + return ecdsaDER(new Uint8Array(await crypto.subtle.sign({ name: 'ECDSA', hash }, key, bs(data)))); +} + +// ---- the signature ---------------------------------------------------------- + +export interface Options { + /** The digest of the signature, SHA-256 by default. */ + hash?: HashName; + /** Signs with RSASSA-PSS instead of PKCS #1 v1.5. */ + pss?: boolean; + /** Writes trailerField [3] 1 in the PSS parameters, which is its default and DER does not write it. */ + pssTrailer?: boolean; + /** Names the signer by subjectKeyIdentifier instead of by issuer and serial. */ + ski?: boolean; + /** Gives the time-stamp token of the signature that Build wants as an unsigned attribute. */ + token?: (signature: Uint8Array) => Promise; + /** What the message-digest covers, when not the signed message. */ + message?: Uint8Array; + /** Edits the signedAttrs, as a list of the DER of each attribute, before they are signed. */ + mutate?: (attrs: Uint8Array[]) => Uint8Array[]; + /** Puts two signature-time-stamp attributes, to break the profile. */ + token2?: boolean; + /** Adds this response in crls. */ + ocsp?: Uint8Array; + /** The elements of crls as they are, instead of an OCSP response. */ + crls?: Uint8Array[]; + /** Leaves the signedAttrs in the order they are given, not in DER order. */ + unsorted?: boolean; + /** Leaves the certificate of the signer out of certificates. */ + omitCert?: boolean; + /** Adds an unsigned attribute of this many bytes, which decides nothing. */ + junk?: number; + /** Adds a signing-certificate attribute beside the v2. */ + sigCertV1?: boolean; + /** Added to the signed attributes, as the DER of each. */ + extraAttrs?: Uint8Array[]; + /** Another number than the version that RFC 5652 gives a SignerInfo. */ + version?: number; +} + +const attr = (o: string, ...values: Uint8Array[]): Uint8Array => seq(oid(o), set(0x31, ...values)); + +function encap(content: Uint8Array, token: boolean): Uint8Array { + return token ? seq(oid(OID.tstInfo), tlv(0xa0, octets(content))) : seq(oid(OID.data)); +} + +async function signerInfo(message: Uint8Array, o: Options, token: boolean, s: Signer): Promise { + const hash = o.hash ?? 'SHA-256'; + const sid = o.ski ? tlv(0x80, s.ski) : seq(s.issuer, int(s.serial)); + const attrs: Uint8Array[] = [ + attr(OID.contentType, oid(token ? OID.tstInfo : OID.data)), + attr(OID.messageDigest, octets(await digest(hash, o.message ?? message))), + ]; + const certSha1 = await digest('SHA-1', s.cert); + attrs.push(token ? attr(OID.sigCertV1, seq(seq(seq(octets(certSha1))))) : attr(OID.sigCertV2, seq(seq(seq(octets(await digest('SHA-256', s.cert))))))); + if (o.sigCertV1) attrs.push(attr(OID.sigCertV1, seq(seq(seq(octets(certSha1)))))); + attrs.push(...(o.extraAttrs ?? [])); + const list = o.mutate === undefined ? attrs : o.mutate(attrs); + const signed = o.unsorted ? tlv(0xa0, ...list) : set(0xa0, ...list); + const forSig = concatBytes(Uint8Array.of(0x31), signed.subarray(1)); + const signature = await sign(s, o, forSig); + + let sigAlg: Uint8Array; + if (s.kind === 'rsa') { + if (o.pss) { + const base = [tlv(0xa0, hashAlg(hash)), tlv(0xa1, seq(oid(OID.mgf1), hashAlg(hash))), tlv(0xa2, int(HASH_SIZE[hash]))]; + sigAlg = seq(oid(OID.pss), seq(...base, ...(o.pssTrailer ? [tlv(0xa3, int(1))] : []))); + } else { + sigAlg = seq(oid(OID.rsa), NULL); + } + } else { + sigAlg = seq(oid(OID.ecdsa[hash])); + } + const f = [int(o.version ?? (o.ski ? 3 : 1)), sid, hashAlg(hash), signed, sigAlg, octets(signature)]; + const unsigned: Uint8Array[] = []; + if (o.junk !== undefined && o.junk > 0) unsigned.push(attr('1.2.3.4.5', octets(new Uint8Array(o.junk)))); + if (o.token !== undefined) { + const t = await o.token(signature); + unsigned.push(o.token2 ? seq(oid(OID.timeStamp), set(0x31, t, seq(oid(OID.data)))) : attr(OID.timeStamp, t)); + } + if (unsigned.length > 0) f.push(set(0xa1, ...unsigned)); + return seq(...f); +} + +async function build(message: Uint8Array, o: Options, token: boolean, signers: Signer[]): Promise { + const hash = o.hash ?? 'SHA-256'; + const certs = o.omitCert ? [] : signers.map((s) => s.cert); + const infos = await Promise.all(signers.map((s) => signerInfo(message, o, token, s))); + const body: Uint8Array[] = [int(1), set(0x31, hashAlg(hash)), encap(message, token)]; + if (certs.length > 0) body.push(set(0xa0, ...certs)); + if (o.crls !== undefined) body.push(set(0xa1, ...o.crls)); + else if (o.ocsp !== undefined) body.push(set(0xa1, tlv(0xa1, oid(OID.ocsp), o.ocsp))); + body.push(set(0x31, ...infos)); + return seq(oid(OID.signedData), tlv(0xa0, seq(...body))); +} + +/** The detached CMS signature of `message` by the signers, in DER, as AutoFirma writes it. */ +export function signature(message: Uint8Array, o: Options, ...signers: Signer[]): Promise { + return build(message, o, false, signers); +} + +// ---- the token -------------------------------------------------------------- + +export interface TokenOptions { + hash?: HashName; + /** Whole seconds; 0 for none. */ + accuracySeconds?: number; + /** The version of the TSTInfo, 1 by default. */ + version?: number; + /** Written as the hashed message instead of the hash of the subject. */ + imprint?: Uint8Array; +} + +export function generalizedTime(s: string): Uint8Array { + return tlv(0x18, new TextEncoder().encode(s)); +} + +/** The TSTInfo of a token over `subject` at `genTime`, with the fields after genTime that the test gives. */ +export async function tstInfo(subject: Uint8Array, genTime: Uint8Array, o: TokenOptions = {}, ...after: Uint8Array[]): Promise { + const hash = o.hash ?? 'SHA-256'; + return seq(int(o.version ?? 1), oid('1.2.3.4'), seq(hashAlg(hash), octets(o.imprint ?? (await digest(hash, subject)))), int(42), genTime, ...after); +} + +export function genTimeOf(t: Date): Uint8Array { + const p = (n: number, w = 2): string => String(n).padStart(w, '0'); + return generalizedTime(`${p(t.getUTCFullYear(), 4)}${p(t.getUTCMonth() + 1)}${p(t.getUTCDate())}${p(t.getUTCHours())}${p(t.getUTCMinutes())}${p(t.getUTCSeconds())}Z`); +} + +/** The RFC 3161 token that `tsa` issues over `subject` at `genTime`. */ +export async function token(subject: Uint8Array, genTime: Date, o: TokenOptions, tsa: Signer): Promise { + const after = o.accuracySeconds === undefined || o.accuracySeconds === 0 ? [] : [seq(int(o.accuracySeconds))]; + return build(await tstInfo(subject, genTimeOf(genTime), o, ...after), {}, true, [tsa]); +} + +/** A token that `tsa` signs over the given TSTInfo, as it is. */ +export function tokenRaw(info: Uint8Array, tsa: Signer, o: Options = {}): Promise { + return build(info, o, true, [tsa]); +} + +export { hex }; diff --git a/src/lib/dkc/testing/pending.ts b/src/lib/dkc/testing/pending.ts deleted file mode 100644 index ed75c9e..0000000 --- a/src/lib/dkc/testing/pending.ts +++ /dev/null @@ -1,38 +0,0 @@ -// 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) -// makes every function here the identity, and the guard test fails until the -// entries are removed. -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[] = ['F5', 'F6']; -const SEAL_CHECKED: readonly Verdict[] = ['S3', 'S4', 'S5']; - -export interface Recorded { - readonly signature: Verdict; - 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 { - signature: SIGNATURE_CHECKED.includes(recorded.signature) ? 'F1' : recorded.signature, - seal: SEAL_CHECKED.includes(recorded.seal) ? 'S1' : recorded.seal, - }; -} - -/** Whether the verification of the reference is something this library does not do yet for `recorded`. */ -export function isPending(recorded: Recorded): boolean { - const p = ported(recorded); - return p.signature !== recorded.signature || p.seal !== recorded.seal; -} diff --git a/src/lib/dkc/testing/verdicts.ts b/src/lib/dkc/testing/verdicts.ts new file mode 100644 index 0000000..ac2ce0e --- /dev/null +++ b/src/lib/dkc/testing/verdicts.ts @@ -0,0 +1,7 @@ +// The two verdicts of a Verdicts, without the key, the label or the detail that a +// signature or a seal adds (spec v0.11, §29.7), for tests that compare only them. +import type { Verdict, Verdicts } from '../security'; + +export function kinds(v: Verdicts): { signature: Verdict; seal: Verdict } { + return { signature: v.signature, seal: v.seal }; +} diff --git a/src/lib/dkc/vectors.test.ts b/src/lib/dkc/vectors.test.ts index 0ba35d3..5111041 100644 --- a/src/lib/dkc/vectors.test.ts +++ b/src/lib/dkc/vectors.test.ts @@ -39,7 +39,7 @@ import { type Item, MAX_SAFE_UINT, walk } from './cbor.ts'; import { decodeControl, encodeControl } from './control.ts'; import { type Format, FORMAT_1, FORMAT_2, FORMAT_3, isFormat } from './framing.ts'; import { BLOQUE256, MAX_PAYLOAD_LENGTH, padmeParameters, paddedLength, payloadAgeLength, REFORZADO } from './padding.ts'; -import { canonicalJSON, compactDateKey, formatRFC3339Nano, type Instant, parseDateKey, parseRFC3339, resolveDateKey, unlockAt } from './datekey.ts'; +import { canonicalJSON, compactDateKey, formatRFC3339, formatRFC3339Nano, type Instant, parseDateKey, parseRFC3339, resolveDateKey, unlockAt } from './datekey.ts'; import { DateKeysError, errorCode } from './errors.ts'; import type { Extension, ExtensionRegistry } from './extension.ts'; import { decodeHead, encodeHead, type HeadFile } from './head.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, kinds, ported } from './testing/pending.ts'; +import { kinds } from './testing/verdicts.ts'; import { hasTestdata, hx, listTestdata, readBytes, readJSON } from './testing/testdata.ts'; import { applyEdits, @@ -681,8 +681,8 @@ 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(kinds(r.verdicts!)).toEqual(ported(c.verdicts!)); - if (!isPending(c.verdicts!)) expect(verdictLines(r.verdicts!)).toEqual(c.verdicts!.lines); + expect(kinds(r.verdicts!)).toEqual({ signature: c.verdicts!.signature, seal: c.verdicts!.seal }); + expect(verdictLines(r.verdicts!)).toEqual(c.verdicts!.lines); expect(sink.opened?.head).toBe(r.head); } else { expect({ step: last.step, ok: last.ok, error: last.error }).toEqual({ step: c.step, ok: false, error: c.error }); @@ -722,7 +722,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') { // A format 3 capsule that opens leaves the output untouched. - expect([last.step, last.ok, r.verdicts?.signature, r.verdicts?.seal, closed, aborted]).toEqual([18, true, ported(c.verdicts!).signature, ported(c.verdicts!).seal, false, false]); + expect([last.step, last.ok, r.verdicts?.signature, r.verdicts?.seal, closed, aborted]).toEqual([18, true, c.verdicts!.signature, c.verdicts!.seal, false, false]); } else { expect({ step: last.step, ok: last.ok, error: last.error }).toEqual({ step: c.step, ok: false, error: c.error }); expect([closed, aborted, sink.opened]).toEqual([false, true, undefined]); @@ -1195,32 +1195,39 @@ describe('testdata/', () => { for (const f of VECTOR_FILES) expect(readme, f).toContain(`\`${f}\``); }); - // The vectors of spec v0.11 for the verification of the signature, the seal - // and the locator (§29.8 to §29.11, §44.1) that this library does not port - // yet: their structure is checked here, and what a reader of v0.10 gives on - // them, so that the gap is stated. Porting makes these blocks check the - // verdicts and the locator themselves. - describe('vectors of v0.11 not ported yet', () => { - it('security_cms.json: every case has a context and verdicts of the table, and the cases without a context are read as the reference reads them', () => { + // The vectors of spec v0.11 for the verification of the signature with + // certificates and of the seal (§29.7, §29.10, §29.11): every area is read in + // the context of its capsule and must give the verdicts of the reference, the + // result of each signer and the authority of a valid seal. + describe('vectors of v0.11', () => { + it('security_cms.json: every case gives the verdicts, the signers and the seal of the reference', () => { const f = object(readJSON('vectors/security_cms.json'), 'security_cms.json'); expect(f.spec).toBe(SPEC_VERSION); const cases = array(f.cases, 'cases').map((c, i) => object(c, `cases[${i}]`)); expect(cases.length).toBeGreaterThanOrEqual(20); for (const [i, c] of cases.entries()) { const at = `cases[${i}] ${String(c.name)}`; - const recorded = { signature: verdict(c.signature, `${at}.signature`), seal: verdict(c.seal, `${at}.seal`) }; + const area = hexOf(c.security_cbor); const ctx = object(c.context, `${at}.context`); - expect(hexOf(ctx.control_commit), at).toHaveLength(32); - expect(hexOf(ctx.head_digest), at).toHaveLength(32); - // Read without the context of a capsule, as a reader of v0.10 does, the reference says exactly what this library says. - if (c.no_context === true) { - expect(evaluateSecurity(hexOf(c.security_cbor)), at).toEqual(recorded); - expect(isPending(recorded), at).toBe(false); - } + const v = + c.no_context === true + ? evaluateSecurity(area) + : evaluateSecurity(area, { controlCommit: hexOf(ctx.control_commit), headDigest: hexOf(ctx.head_digest), roundTime: parseRFC3339(str(ctx.round_time, `${at}.round_time`)) }); + expect({ signature: v.signature, seal: v.seal }, at).toEqual({ signature: verdict(c.signature, `${at}.signature`), seal: verdict(c.seal, `${at}.seal`) }); + const result = (s: { holder: string; issuer: string; result: string; sealTime?: Instant; before: boolean }): unknown => ({ + holder: s.holder, + issuer: s.issuer, + result: s.result, + ...(s.sealTime === undefined ? {} : { seal_time: formatRFC3339(s.sealTime) }), + before_round_time: s.before, + }); + expect(v.detail?.signers.map(result) ?? [], at).toEqual(c.signers ?? []); + expect(v.detail?.foreign.map(result) ?? [], at).toEqual(c.foreign_signers ?? []); + expect([v.detail?.sealHolder ?? '', v.detail?.sealTime === undefined ? '' : formatRFC3339(v.detail.sealTime)], at).toEqual([c.seal_holder ?? '', c.seal_time ?? '']); } }); - it('locator.json and ed25519_strict.json name this specification', () => { + it('locator.json and ed25519_strict.json name this specification (the locator is not ported yet; ed25519strict.test.ts runs the other)', () => { for (const name of ['vectors/locator.json', 'vectors/ed25519_strict.json']) { expect(object(readJSON(name), name).spec, name).toBe(SPEC_VERSION); }