The signature with certificates and the time seal in the library: alg 2, RFC 3161

der.ts checks DER byte by byte. cms.ts reads the CMS signature and the RFC
3161 token of spec v0.11 29.10 and 29.11 with the closed table of
algorithms: RSA PKCS 1 and PSS with BigInt, ECDSA with the arithmetic of
@noble/curves, no new package. securitycms.ts gives F1, F2, F5 and F6 with
the signers named, and S1 to S5 with the authority of a valid seal.
evaluateSecurity returns them with their detail, and verdictLines writes the
lines of F6 and S4. The 22 cases of security_cms.json and the fixtures
format3_signed_cms and format3_sealed give the verdicts, the signers and the
seal of the Go reference. testing/cmsbuild.ts builds signatures and tokens
with WebCrypto for the hostile cases ported from the Go tests, and the
pending mechanism of the first sync is gone. npm run verify passes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
main
dev 6 days ago
parent c31ab2d2e7
commit 92b4d196eb

@ -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. - `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. - **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. 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.

@ -1,6 +1,6 @@
# datekeys-ts # 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. 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.

@ -15,7 +15,7 @@
// another 2.x copy anywhere but under @noble/post-quantum, which pins // 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); // ~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/ 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, // 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 // @noble/hashes or @noble/ciphers, the root copies that those files
// resolve to. // resolve to.
@ -48,6 +48,7 @@ const FORBIDDEN = ['tlock-js', 'drand-client'];
// The only files besides the tests that may import noble. // The only files besides the tests that may import noble.
const NOBLE_IMPORTERS = [ const NOBLE_IMPORTERS = [
'src/lib/dkc/author.ts', 'src/lib/dkc/author.ts',
'src/lib/dkc/cms.ts',
'src/lib/dkc/digest.ts', 'src/lib/dkc/digest.ts',
'src/lib/dkc/ed25519strict.ts', 'src/lib/dkc/ed25519strict.ts',
'src/lib/dkc/ibe.ts', 'src/lib/dkc/ibe.ts',
@ -70,6 +71,8 @@ const NOT_IN_INDEX = [
'agefile.ts', 'agefile.ts',
'author.ts', 'author.ts',
'bech32.ts', 'bech32.ts',
'cms.ts',
'der.ts',
'digest.ts', 'digest.ts',
'ed25519strict.ts', 'ed25519strict.ts',
'encrypt.ts', 'encrypt.ts',
@ -82,6 +85,7 @@ const NOT_IN_INDEX = [
'random.ts', 'random.ts',
'recipient.ts', 'recipient.ts',
'release.ts', 'release.ts',
'securitycms.ts',
'sink.ts', 'sink.ts',
'tlock.ts', 'tlock.ts',
'writer.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([]); expect(importProblems(sources())).toEqual([]);
}); });

@ -69,6 +69,22 @@ export function authorMessage(controlCommitment: Uint8Array, headDigestValue: Ui
return text.encode(`${AUTHOR_MESSAGE_PREFIX}\n${toHex(d)}\n`); 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. */ /** 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 { export function authorCode(message: Uint8Array): string {
if (message.length !== AUTHOR_MESSAGE_SIZE) return ''; if (message.length !== AUTHOR_MESSAGE_SIZE) return '';

@ -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<Uint8Array> => b.tstInfo(subject, g, {}, ...after);
const form: [string, Uint8Array | Promise<Uint8Array>][] = [
['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);
});
});

@ -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<T>(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<string, Hash> = { [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<string, string> = {
'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<Cert>();
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<string, Uint8Array[]>;
readonly count: Map<string, number>;
}
// 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 };
}

@ -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);
});
});

@ -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`);
}
}

@ -17,7 +17,9 @@ import { checkHeadEnd, decodeHead, encodeHead } from './head.ts';
import { inspect, inspectView } from './inspect.ts'; import { inspect, inspectView } from './inspect.ts';
import { paddedLength, type Padding, payloadAgeLength } from './padding.ts'; import { paddedLength, type Padding, payloadAgeLength } from './padding.ts';
import { evaluateSecurity, type Verdict, verdictLines } from './security.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'; import { h, hx, listTestdata, readBytes, readJSON } from './testing/testdata.ts';
interface FixtureExt { interface FixtureExt {
@ -76,9 +78,30 @@ interface DkcFixture {
signers_digest: string; signers_digest: string;
author_message: string; author_message: string;
author_code: 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 { interface DkkFixture {
file: string; file: string;
sha256: string; sha256: string;
@ -220,7 +243,11 @@ describe.each(dkcFixtures)('$json.file', ({ json: fx }) => {
expect(hx(encodeHead(decoded))).toBe(fx.head_cbor); 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. // 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 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) { if (fx.signature !== undefined) {
// What is signed, recomputed from the control and the head of the fixture as the reference recomputes it. // What is signed, recomputed from the control and the head of the fixture as the reference recomputes it.
const cc = controlCommit(control, FORMAT_3); const cc = controlCommit(control, FORMAT_3);
@ -235,11 +262,16 @@ describe.each(dkcFixtures)('$json.file', ({ json: fx }) => {
code: fx.signature.author_code, code: fx.signature.author_code,
}); });
} }
// Where the reference checks a signature of alg 2 or a seal, this library gives what expect(kinds(verdicts)).toEqual({ signature: fx.verdicts!.signature, seal: fx.verdicts!.seal });
// a reader of v0.10 gives, until it ports the verification (testing/pending.ts). expect(verdictLines(verdicts)).toEqual(fx.verdicts!.lines);
const want = ported(fx.verdicts!); // The signers of an alg 2 signature, as the reference records them, and the authority of a valid seal.
expect(kinds(verdicts)).toEqual(want); if (fx.signature?.signer_results !== undefined) {
if (!isPending(fx.verdicts!)) expect(verdictLines(verdicts)).toEqual(fx.verdicts!.lines); 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]);
}
}); });
}); });

@ -26,7 +26,7 @@ import { type Release, type ReleaseSource, suppliedRelease } from './release.ts'
import { type Verdict, verdictLines } from './security.ts'; import { type Verdict, verdictLines } from './security.ts';
import { MemorySink, type Sink } from './sink.ts'; import { MemorySink, type Sink } from './sink.ts';
import { frame, split } from './testing/capsule.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 { h, hx, listTestdata, readBytes, readJSON } from './testing/testdata.ts';
import { applyEdits, edits } from './testing/vectors.ts'; import { applyEdits, edits } from './testing/vectors.ts';
import { parseX25519Identity, unwrapX25519, x25519PublicKey } from './x25519.ts'; import { parseX25519Identity, unwrapX25519, x25519PublicKey } from './x25519.ts';
@ -102,8 +102,8 @@ function expectFiles(f: Fixture, r: Opened, sink: MemorySink): void {
f.name, f.name,
).toEqual(f.side.files ?? []); ).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(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!)); expect(kinds(r.verdicts!), f.name).toEqual({ signature: f.side.verdicts!.signature, seal: f.side.verdicts!.seal });
if (!isPending(f.side.verdicts!)) expect(verdictLines(r.verdicts!), f.name).toEqual(f.side.verdicts!.lines); expect(verdictLines(r.verdicts!), f.name).toEqual(f.side.verdicts!.lines);
expect(r.areaLen, f.name).toBe(f.side.area_len); expect(r.areaLen, f.name).toBe(f.side.area_len);
expect([r.plaintext, r.unusableHeadExtensions], f.name).toEqual([undefined, []]); expect([r.plaintext, r.unusableHeadExtensions], f.name).toEqual([undefined, []]);
} }

@ -420,6 +420,7 @@ async function openCapsule(
if (format === FORMAT_3) { if (format === FORMAT_3) {
body = await openBody(plain, padded!.l, padded!.p, opts.sink!, opts.extensions, { body = await openBody(plain, padded!.l, padded!.p, opts.sink!, opts.extensions, {
controlCommit: controlCommit(control, format), controlCommit: controlCommit(control, format),
roundTime: inspection.unlockAt!,
...(opts.authorKeys === undefined ? {} : { authorKeys: opts.authorKeys }), ...(opts.authorKeys === undefined ? {} : { authorKeys: opts.authorKeys }),
}); });
} else if (opts.output === undefined) { } else if (opts.output === undefined) {

@ -28,6 +28,7 @@ import { DateKeysError } from './errors.ts';
import { checkNoncritical, type ExtensionRegistry, type Unusable } from './extension.ts'; import { checkNoncritical, type ExtensionRegistry, type Unusable } from './extension.ts';
import { checkHeadEnd, decodeHead, type Head } from './head.ts'; import { checkHeadEnd, decodeHead, type Head } from './head.ts';
import { headDigest } from './author.ts'; import { headDigest } from './author.ts';
import type { Instant } from './datekey.ts';
import { evaluateSecurity, type Verdicts } from './security.ts'; import { evaluateSecurity, type Verdicts } from './security.ts';
import type { Sink } from './sink.ts'; import type { Sink } from './sink.ts';
@ -180,7 +181,7 @@ export async function openBody(
p: number, p: number,
sink: Sink, sink: Sink,
reg: ExtensionRegistry | undefined, reg: ExtensionRegistry | undefined,
security: { readonly controlCommit: Uint8Array; readonly authorKeys?: ReadonlyMap<string, string> }, security: { readonly controlCommit: Uint8Array; readonly roundTime: Instant; readonly authorKeys?: ReadonlyMap<string, string> },
): Promise<OpenedBody> { ): Promise<OpenedBody> {
const r = new Plaintext(plain, p); const r = new Plaintext(plain, p);
let begun = false; let begun = false;
@ -201,6 +202,7 @@ export async function openBody(
const verdicts = evaluateSecurity(securityBytes, { const verdicts = evaluateSecurity(securityBytes, {
controlCommit: security.controlCommit, controlCommit: security.controlCommit,
headDigest: headDigest(hb), headDigest: headDigest(hb),
roundTime: security.roundTime,
...(security.authorKeys === undefined ? {} : { authorKeys: security.authorKeys }), ...(security.authorKeys === undefined ? {} : { authorKeys: security.authorKeys }),
}); });
let head: Head; let head: Head;

@ -83,6 +83,24 @@ describe('verdictLines', () => {
expect(verdictText('S0')).toBe(''); 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 // 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. // 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', () => { it('has the text of the verdicts of v0.11 that do not name anything, and none for those that do', () => {

@ -7,13 +7,15 @@
// security area never decides the opening, and its verdicts carry no error // security area never decides the opening, and its verdicts carry no error
// code. Internal: index.ts does not re-export it. // 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 { bech32Encode } from './bech32.ts';
import { utf8Length } from './bytes.ts'; import { utf8Length } from './bytes.ts';
import { type Decoder, Encoder, peek, unmarshal } from './cbor.ts'; import { type Decoder, Encoder, peek, unmarshal } from './cbor.ts';
import { verifyStrict } from './ed25519strict.ts'; import { verifyStrict } from './ed25519strict.ts';
import { DateKeysError } from './errors.ts'; import { DateKeysError } from './errors.ts';
import { formatRFC3339, type Instant } from './datekey.ts';
import { fieldOf, requireKeys } from './schema.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_TYPE_TAG = 'datekeys-security';
export const SECURITY_VERSION = 1; export const SECURITY_VERSION = 1;
@ -21,6 +23,8 @@ export const SECURITY_VERSION = 1;
// seal_type. // seal_type.
const MAX_SECURITY_ITEM = 65536; const MAX_SECURITY_ITEM = 65536;
const MAX_ALG = 2 ** 32 - 1; 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 * The verdict on the signature or on the seal of the security area (spec
@ -43,6 +47,8 @@ export interface Verdicts {
readonly authorKey?: Uint8Array; readonly authorKey?: Uint8Array;
/** The label of the saved key that signed (F3). */ /** The label of the saved key that signed (F3). */
readonly authorLabel?: string; 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 { export interface SecurityContext {
readonly controlCommit: Uint8Array; readonly controlCommit: Uint8Array;
readonly headDigest: 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<string, string>; readonly authorKeys?: ReadonlyMap<string, string>;
} }
@ -96,8 +104,22 @@ export function verdictLines(v: Verdicts): string[] {
// F3 and F4 name a key (spec §29.7). // 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 === '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.`; if (v.signature === 'F4') signature = `Firmado con la clave ${bech32Encode('dkauthor', v.authorKey!)}. No prueba quién la tiene.`;
const seal = verdictText(v.seal); const lines = [signature];
return seal === '' ? [signature] : [signature, seal]; 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. // 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' }; if (w === undefined) return { signature: 'X', seal: 'X' };
const { signature, seal } = w; const { signature, seal } = w;
return { const sig = signature === undefined ? { signature: 'F0' as const } : evaluateSignature(signature, seal !== undefined, context);
...(signature === undefined ? { signature: 'F0' as const } : evaluateSignature(signature, context)), const sealed = seal === undefined ? { seal: 'S0' as const } : evaluateSealArea(seal, signature, context);
seal: seal === undefined ? 'S0' : attempt(() => unmarshal(seal, decodeSeal, encodeSeal)) === undefined ? 'S2' : 'S1', 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 // 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 // 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 // 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. // or F4 when it does. This version implements alg 1 only.
function evaluateSignature(content: Uint8Array, context: SecurityContext | undefined): Pick<Verdicts, 'signature' | 'authorKey' | 'authorLabel'> { function evaluateSignature(content: Uint8Array, hasSeal: boolean, context: SecurityContext | undefined): Pick<Verdicts, 'signature' | 'authorKey' | 'authorLabel' | 'detail'> {
const unchecked = { signature: 'F1' } as const; const unchecked = { signature: 'F1' } as const;
if (context === undefined) return unchecked; if (context === undefined) return unchecked;
const a = attempt(() => unmarshal(content, decodeAuthorSignature, encodeAuthorSignature)); 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)); const message = authorMessage(context.controlCommit, context.headDigest, signersDigest(ALG_ED25519));
if (!verifyStrict(a.key, message, a.value)) return { signature: 'F2' }; if (!verifyStrict(a.key, message, a.value)) return { signature: 'F2' };
const label = context.authorKeys?.get(bech32Encode('dkauthor', a.key)); const label = context.authorKeys?.get(bech32Encode('dkauthor', a.key));

@ -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<string, SignerInfo>(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 };
}

@ -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<HashName, number> = { 'SHA-256': 32, 'SHA-384': 48, 'SHA-512': 64 };
export const digest = async (h: HashName | 'SHA-1', b: Uint8Array): Promise<Uint8Array> => 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<Signer> {
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<Signer> => 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<Signer> =>
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<Uint8Array> {
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<Uint8Array>;
/** 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<Uint8Array> {
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<Uint8Array> {
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<Uint8Array> {
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<Uint8Array> {
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<Uint8Array> {
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<Uint8Array> {
return build(info, o, true, [tsa]);
}
export { hex };

@ -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;
}

@ -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 };
}

@ -39,7 +39,7 @@ import { type Item, MAX_SAFE_UINT, walk } from './cbor.ts';
import { decodeControl, encodeControl } from './control.ts'; import { decodeControl, encodeControl } from './control.ts';
import { type Format, FORMAT_1, FORMAT_2, FORMAT_3, isFormat } from './framing.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 { 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 { DateKeysError, errorCode } from './errors.ts';
import type { Extension, ExtensionRegistry } from './extension.ts'; import type { Extension, ExtensionRegistry } from './extension.ts';
import { decodeHead, encodeHead, type HeadFile } from './head.ts'; import { decodeHead, encodeHead, type HeadFile } from './head.ts';
@ -61,7 +61,7 @@ import {
import type { Release, ReleaseSource } from './release.ts'; import type { Release, ReleaseSource } from './release.ts';
import { evaluateSecurity, type Verdict, verdictLines } from './security.ts'; import { evaluateSecurity, type Verdict, verdictLines } from './security.ts';
import { MemorySink } from './sink.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 { hasTestdata, hx, listTestdata, readBytes, readJSON } from './testing/testdata.ts';
import { import {
applyEdits, 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); expect(r.error?.message ?? 'ok', 'the text of capsule.Open').toBe(TEXTS.cases[c.index]!.text);
if (c.error === 'ok') { if (c.error === 'ok') {
expect({ step: last.step, ok: last.ok, error: r.error }).toEqual({ step: 18, ok: true, error: undefined }); 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!)); expect(kinds(r.verdicts!)).toEqual({ signature: c.verdicts!.signature, seal: c.verdicts!.seal });
if (!isPending(c.verdicts!)) expect(verdictLines(r.verdicts!)).toEqual(c.verdicts!.lines); expect(verdictLines(r.verdicts!)).toEqual(c.verdicts!.lines);
expect(sink.opened?.head).toBe(r.head); expect(sink.opened?.head).toBe(r.head);
} else { } else {
expect({ step: last.step, ok: last.ok, error: last.error }).toEqual({ step: c.step, ok: false, error: c.error }); 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); expect(r.error?.message ?? 'ok', 'the text of capsule.Open').toBe(TEXTS.cases[c.index]!.text);
if (c.error === 'ok') { if (c.error === 'ok') {
// A format 3 capsule that opens leaves the output untouched. // 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 { } else {
expect({ step: last.step, ok: last.ok, error: last.error }).toEqual({ step: c.step, ok: false, error: c.error }); 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]); 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}\``); 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 // The vectors of spec v0.11 for the verification of the signature with
// and the locator (§29.8 to §29.11, §44.1) that this library does not port // certificates and of the seal (§29.7, §29.10, §29.11): every area is read in
// yet: their structure is checked here, and what a reader of v0.10 gives on // the context of its capsule and must give the verdicts of the reference, the
// them, so that the gap is stated. Porting makes these blocks check the // result of each signer and the authority of a valid seal.
// verdicts and the locator themselves. describe('vectors of v0.11', () => {
describe('vectors of v0.11 not ported yet', () => { it('security_cms.json: every case gives the verdicts, the signers and the seal of the reference', () => {
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', () => {
const f = object(readJSON('vectors/security_cms.json'), 'security_cms.json'); const f = object(readJSON('vectors/security_cms.json'), 'security_cms.json');
expect(f.spec).toBe(SPEC_VERSION); expect(f.spec).toBe(SPEC_VERSION);
const cases = array(f.cases, 'cases').map((c, i) => object(c, `cases[${i}]`)); const cases = array(f.cases, 'cases').map((c, i) => object(c, `cases[${i}]`));
expect(cases.length).toBeGreaterThanOrEqual(20); expect(cases.length).toBeGreaterThanOrEqual(20);
for (const [i, c] of cases.entries()) { for (const [i, c] of cases.entries()) {
const at = `cases[${i}] ${String(c.name)}`; 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`); const ctx = object(c.context, `${at}.context`);
expect(hexOf(ctx.control_commit), at).toHaveLength(32); const v =
expect(hexOf(ctx.head_digest), at).toHaveLength(32); c.no_context === true
// Read without the context of a capsule, as a reader of v0.10 does, the reference says exactly what this library says. ? evaluateSecurity(area)
if (c.no_context === true) { : evaluateSecurity(area, { controlCommit: hexOf(ctx.control_commit), headDigest: hexOf(ctx.head_digest), roundTime: parseRFC3339(str(ctx.round_time, `${at}.round_time`)) });
expect(evaluateSecurity(hexOf(c.security_cbor)), at).toEqual(recorded); expect({ signature: v.signature, seal: v.seal }, at).toEqual({ signature: verdict(c.signature, `${at}.signature`), seal: verdict(c.seal, `${at}.seal`) });
expect(isPending(recorded), at).toBe(false); 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']) { for (const name of ['vectors/locator.json', 'vectors/ed25519_strict.json']) {
expect(object(readJSON(name), name).spec, name).toBe(SPEC_VERSION); expect(object(readJSON(name), name).spec, name).toBe(SPEC_VERSION);
} }

Loading…
Cancel
Save

Powered by TurnKey Linux.