Fixes T1, T3, T13 and the CMS part of T5 of the review of the session of 1 and 2 October (docs, spec_v0.11/revision_sesion_1_2_octubre.md): - securitycms.ts: an issuer that breaks the rules of the declared author shows the SHA-256 of the DER of its Name, Go's sha256.Sum256(RawIssuer), and no longer that of the certificate (cms.certIssuerHash). - cms.ts: an ECDSA key counts only with its point uncompressed, 0x04 and the two coordinates, the only form that Go's x509.ParsePKIXPublicKey reads: a compressed one makes a signer not verifiable (F5) and a seal S1. - cms.ts: a UTF8String and the times of a certificate and of a token keep a leading U+FEFF, as Go reads the bytes: such a name shows the hash, and such a time breaks the profile (F1, S2). - cms.ts: oidOf and intOf take time linear in the length of the element. An arc of up to seven digits accumulates in a number, a longer one and every INTEGER are read whole from hexadecimal, never by a shift per byte, which took some 700 ms for 60 KB; attributes of one type are appended, not copied. - security.ts: evaluateSecurity never throws. A fault while it evaluates the signature gives F1, one while it evaluates the seal S2, each apart, and one while it decodes the area X, as the Go reference will from v0.12. - Tests: securitycms.test.ts and security.failure.test.ts are new. cms.test.ts now refuses a second content-type and two signature-time- stamps for the rule of the count, with every SET OF in DER order, and der.test.ts tests the depth at its boundary. cmsbuild.ts makes names, validities and compressed points of its own. capsule.EvaluateSecurityIn of the Go reference at spec-v0.11 gives the same verdicts, signer lines and Spanish lines on 25 areas made with cmsbuild.ts: issuers with ESC, U+202E, empty, of 300 bytes or with a leading U+FEFF; names and times with a leading U+FEFF; and compressed keys on P-256, P-384 and P-521, as signers and as authorities of a seal. HEAD gave other ones in 19 of them. npm run verify passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
3f80e589fa
commit
06e992fa74
@ -0,0 +1,102 @@
|
||||
// Faults of the evaluation of the security area that no input reaches today:
|
||||
// the decoder, the strict verification of alg 1 and the evaluators of alg 2
|
||||
// and of the seal are replaced by functions that throw, in a file of its own
|
||||
// because vi.mock replaces a module for the whole file. evaluateSecurity never
|
||||
// throws, since the security area never decides the opening (spec §29.3): a
|
||||
// fault while it evaluates the signature gives F1, and one while it evaluates
|
||||
// the seal, S2, each without touching the other verdict; one while it decodes
|
||||
// the area gives X. The Go reference does the same from v0.12.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { controlCommit, headDigest } from './author.ts';
|
||||
import { peek } from './cbor.ts';
|
||||
import { decodeControl } from './control.ts';
|
||||
import { parseRFC3339 } from './datekey.ts';
|
||||
import { verifyStrict } from './ed25519strict.ts';
|
||||
import { FORMAT_3 } from './framing.ts';
|
||||
import { evaluateSecurity, type SecurityContext } from './security.ts';
|
||||
import { evaluateCMS, evaluateSeal } from './securitycms.ts';
|
||||
import { h, readJSON } from './testing/testdata.ts';
|
||||
|
||||
vi.mock('./cbor.ts', async (importOriginal) => {
|
||||
const m = await importOriginal<typeof import('./cbor.ts')>();
|
||||
return { ...m, peek: vi.fn(m.peek) };
|
||||
});
|
||||
vi.mock('./ed25519strict.ts', async (importOriginal) => {
|
||||
const m = await importOriginal<typeof import('./ed25519strict.ts')>();
|
||||
return { ...m, verifyStrict: vi.fn(m.verifyStrict) };
|
||||
});
|
||||
vi.mock('./securitycms.ts', async (importOriginal) => {
|
||||
const m = await importOriginal<typeof import('./securitycms.ts')>();
|
||||
return { ...m, evaluateCMS: vi.fn(m.evaluateCMS), evaluateSeal: vi.fn(m.evaluateSeal) };
|
||||
});
|
||||
|
||||
interface Record {
|
||||
control_cbor: string;
|
||||
head_cbor: string;
|
||||
security_cbor: string;
|
||||
unlock_at: string;
|
||||
}
|
||||
|
||||
// The security area of a fixture of the Go reference, in the context of its capsule.
|
||||
function fixture(name: string): { area: Uint8Array; context: SecurityContext } {
|
||||
const fx = readJSON<Record>(`fixtures/${name}.json`);
|
||||
const context = {
|
||||
controlCommit: controlCommit(decodeControl(h(fx.control_cbor), FORMAT_3), FORMAT_3),
|
||||
headDigest: headDigest(h(fx.head_cbor)),
|
||||
roundTime: parseRFC3339(fx.unlock_at),
|
||||
};
|
||||
return { area: h(fx.security_cbor), context };
|
||||
}
|
||||
|
||||
const fault = (): never => {
|
||||
throw new Error('a fault that no input should cause');
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(peek).mockReset();
|
||||
vi.mocked(verifyStrict).mockReset();
|
||||
vi.mocked(evaluateCMS).mockReset();
|
||||
vi.mocked(evaluateSeal).mockReset();
|
||||
});
|
||||
|
||||
describe('evaluateSecurity, when an evaluator throws', () => {
|
||||
// format3_sealed holds a signature of alg 1, F4, and a seal of seal_type 2 from before the round, S4.
|
||||
it('gives the verdicts of the fixtures when nothing throws', () => {
|
||||
const sealed = fixture('format3_sealed');
|
||||
expect(evaluateSecurity(sealed.area, sealed.context)).toMatchObject({ signature: 'F4', seal: 'S4' });
|
||||
const cms = fixture('format3_signed_cms');
|
||||
expect(evaluateSecurity(cms.area, cms.context)).toMatchObject({ signature: 'F6', seal: 'S0' });
|
||||
});
|
||||
|
||||
it('gives F1 for a fault in a signature of alg 1, and the seal still verifies', () => {
|
||||
const { area, context } = fixture('format3_sealed');
|
||||
vi.mocked(verifyStrict).mockImplementationOnce(fault);
|
||||
const v = evaluateSecurity(area, context);
|
||||
expect([v.signature, v.seal, v.authorKey, v.detail?.sealHolder]).toEqual(['F1', 'S4', undefined, 'Autoridad de Sellado de prueba']);
|
||||
});
|
||||
|
||||
it('gives F1 for a fault in a signature of alg 2, with no signer named', () => {
|
||||
const { area, context } = fixture('format3_signed_cms');
|
||||
vi.mocked(evaluateCMS).mockImplementationOnce(() => {
|
||||
throw 'not even an Error';
|
||||
});
|
||||
expect(evaluateSecurity(area, context)).toEqual({ signature: 'F1', seal: 'S0' });
|
||||
});
|
||||
|
||||
it('gives S2 for a fault in the seal, and the signature still verifies', () => {
|
||||
const { area, context } = fixture('format3_sealed');
|
||||
vi.mocked(evaluateSeal).mockImplementationOnce(fault);
|
||||
const v = evaluateSecurity(area, context);
|
||||
expect([v.signature, v.seal, v.authorKey === undefined, v.detail]).toEqual(['F4', 'S2', false, undefined]);
|
||||
});
|
||||
|
||||
it('gives F1 and S2 for faults in both, and X for a fault while it decodes the area', () => {
|
||||
const { area, context } = fixture('format3_sealed');
|
||||
vi.mocked(verifyStrict).mockImplementationOnce(fault);
|
||||
vi.mocked(evaluateSeal).mockImplementationOnce(fault);
|
||||
expect(evaluateSecurity(area, context)).toEqual({ signature: 'F1', seal: 'S2' });
|
||||
vi.mocked(peek).mockImplementationOnce(fault);
|
||||
expect(evaluateSecurity(area, context)).toEqual({ signature: 'X', seal: 'X' });
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,152 @@
|
||||
// Tests of securitycms.ts, the verdicts of a signature of alg 2 and of a seal
|
||||
// of seal_type 2 (spec v0.11 §29.7, §29.10, §29.11), through evaluateSecurity
|
||||
// in the context of a capsule, on signatures and tokens that
|
||||
// testing/cmsbuild.ts makes: what a signer line shows of a certificate, a byte
|
||||
// order mark at the start of a name or a time, and keys whose point is
|
||||
// compressed. capsule.EvaluateSecurityIn of the Go reference at spec-v0.11
|
||||
// gives the same verdicts, signer lines and Spanish lines on areas made the
|
||||
// same way: an oracle compared them, and the hashes of the issuers below are
|
||||
// the ones it gave for these Names.
|
||||
|
||||
import { sha256 } from '@noble/hashes/sha2.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ALG_CMS, authorMessage, sealSubject, signersDigest } from './author.ts';
|
||||
import { compareBytes, toHex } from './bytes.ts';
|
||||
import { Encoder } from './cbor.ts';
|
||||
import { evaluateSecurity, type SecurityContext, type Verdicts, verdictLines } from './security.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 context: SecurityContext = {
|
||||
controlCommit: new Uint8Array(32).fill(1),
|
||||
headDigest: new Uint8Array(32).fill(2),
|
||||
roundTime: { seconds: Date.UTC(2026, 9, 1) / 1000, nanos: 0 },
|
||||
};
|
||||
const te = new TextEncoder();
|
||||
const BOM = Uint8Array.of(0xef, 0xbb, 0xbf);
|
||||
|
||||
// SECURITY_CBOR with the contents of keys 2 and 3 given.
|
||||
function area(signature: Uint8Array | undefined, seal: Uint8Array | undefined): Uint8Array {
|
||||
const e = new Encoder();
|
||||
e.map(2 + (signature === undefined ? 0 : 1) + (seal === undefined ? 0 : 1));
|
||||
e.uint(0);
|
||||
e.text('datekeys-security');
|
||||
e.uint(1);
|
||||
e.uint(1);
|
||||
if (signature !== undefined) {
|
||||
e.uint(2);
|
||||
e.bstr(signature);
|
||||
}
|
||||
if (seal !== undefined) {
|
||||
e.uint(3);
|
||||
e.bstr(seal);
|
||||
}
|
||||
return e.out();
|
||||
}
|
||||
|
||||
// The verdicts of a signature of alg 2 by the signers, all of them required,
|
||||
// each with a signature-time-stamp of `tsa` when it is given.
|
||||
async function signed(tsa: b.Signer | undefined, ...signers: b.Signer[]): Promise<Verdicts> {
|
||||
const hashes = signers.map((s) => sha256(s.cert)).sort(compareBytes);
|
||||
const list = new Encoder();
|
||||
list.array(hashes.length);
|
||||
for (const x of hashes) list.bstr(x);
|
||||
const key1 = list.out();
|
||||
const msg = authorMessage(context.controlCommit, context.headDigest, signersDigest(ALG_CMS, key1));
|
||||
const cms = await b.signature(msg, tsa === undefined ? {} : { token: (sig) => b.token(sig, now, {}, tsa) }, ...signers);
|
||||
const e = new Encoder();
|
||||
e.map(3);
|
||||
e.uint(0);
|
||||
e.uint(ALG_CMS);
|
||||
e.uint(1);
|
||||
e.bstr(key1);
|
||||
e.uint(2);
|
||||
e.bstr(cms);
|
||||
return evaluateSecurity(area(e.out(), undefined), context);
|
||||
}
|
||||
|
||||
// The verdicts of a seal of seal_type 2 by `tsa`, without a signature.
|
||||
async function sealed(tsa: b.Signer): Promise<Verdicts> {
|
||||
const token = await b.token(sealSubject(context.controlCommit, context.headDigest, undefined), now, {}, tsa);
|
||||
const e = new Encoder();
|
||||
e.map(2);
|
||||
e.uint(0);
|
||||
e.uint(2);
|
||||
e.uint(1);
|
||||
e.bstr(token);
|
||||
return evaluateSecurity(area(undefined, e.out()), context);
|
||||
}
|
||||
|
||||
const cn = (s: string): Uint8Array => b.rdnName(['2.5.4.3', b.utf8(s)], ['2.5.4.10', b.utf8('DateKeys test')]);
|
||||
|
||||
describe('the issuer of a signer', () => {
|
||||
// The issuer is text of the certificate, as the holder is: one that breaks the rules of the declared author shows
|
||||
// the SHA-256 of the DER of its Name, Go's sha256.Sum256(RawIssuer), and never that of the certificate.
|
||||
it('shows the SHA-256 of the Name of an issuer that breaks the rules of the declared author, as Go', async () => {
|
||||
const tsa = await b.newECDSA('TSA de prueba', 'P-256', from, to);
|
||||
const issuers: [string, Uint8Array, string][] = [
|
||||
['an issuer with ESC', cn('Ana\x1b[2J'), '53213e495daf7cc473c94da46283bae22d5d54b58681a0635cd22b96abde7c3f'],
|
||||
['an issuer with U+202E', cn('Ana\u{202e}gpj.exe'), 'b1772664c1dbb3470b8d419f2275839241987889333ce17f223414a939634559'],
|
||||
['an empty issuer', b.seq(), 'e4f60d0aa6d7f3d3b6a6494b1c861b99f649c6f9ec51abaf201b20f297327c95'],
|
||||
['an issuer of 300 bytes', cn('x'.repeat(300)), '01ce813ba635fe45b8125d46b368eec8eee9a1d00f4c0b066c387c1b04a2ee92'],
|
||||
['an issuer that starts with U+FEFF', b.rdnName(['2.5.4.3', b.tlv(0x0c, BOM, te.encode('Autoridad'))]), '2bcf35767b63b7b0370d61cf63620623adac5a09662763ebd331316d4d4c6097'],
|
||||
];
|
||||
for (const [name, issuer, hash] of issuers) {
|
||||
const s = await b.newECDSA('Ana', 'P-256', from, to, 'cn', { issuer });
|
||||
const v = await signed(tsa, s);
|
||||
expect([v.signature, v.seal], name).toEqual(['F6', 'S0']);
|
||||
const line = v.detail!.signers[0]!;
|
||||
expect([line.holder, line.issuer, line.result], name).toEqual(['Ana', hash, 'valid']);
|
||||
expect(toHex(sha256(issuer)), name).toBe(hash);
|
||||
expect(verdictLines(v)[1], name).toBe(` Ana (emisor según su certificado: ${hash}), sellado el 2026-09-30T12:00:00Z, antes de la fecha de apertura.`);
|
||||
}
|
||||
// An issuer that meets the rules shows its name.
|
||||
const good = await signed(tsa, await b.newECDSA('Ana', 'P-256', from, to, 'cn', { issuer: cn('Autoridad de prueba') }));
|
||||
expect(good.detail!.signers[0]!.issuer).toBe('Autoridad de prueba');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a byte order mark at the start of a name or a time', () => {
|
||||
// Go reads the bytes: the U+FEFF of a UTF8String stays, the rules of text refuse it, and the hash is shown; a time
|
||||
// that starts with it does not parse, and the certificate breaks the profile.
|
||||
it('keeps it in a name, which then shows the hash of the certificate, as Go', async () => {
|
||||
const tsa = await b.newECDSA('TSA de prueba', 'P-256', from, to);
|
||||
const subject = b.rdnName(['2.5.4.3', b.tlv(0x0c, BOM, te.encode('Ana'))]);
|
||||
const s = await b.newECDSA('Ana', 'P-256', from, to, 'cn', { subject });
|
||||
const v = await signed(tsa, s);
|
||||
expect([v.signature, v.detail!.signers[0]!.holder]).toEqual(['F6', toHex(sha256(s.cert))]);
|
||||
const authority = await b.newECDSA('TSA', 'P-256', from, to, 'cn', { subject: b.rdnName(['2.5.4.3', b.tlv(0x0c, BOM, te.encode('TSA'))]) });
|
||||
const seal = await sealed(authority);
|
||||
expect([seal.seal, seal.detail!.sealHolder]).toEqual(['S4', toHex(sha256(authority.cert))]);
|
||||
});
|
||||
|
||||
it('refuses a time of a certificate that starts with it: F1 for a signer, S2 for the authority of a seal, as Go', async () => {
|
||||
const tsa = await b.newECDSA('TSA de prueba', 'P-256', from, to);
|
||||
const utc = (s: Uint8Array): Uint8Array => b.tlv(0x17, s);
|
||||
const validity = b.seq(utc(new Uint8Array([...BOM, ...te.encode('250101000000Z')])), utc(te.encode('300101000000Z')));
|
||||
const v = await signed(tsa, await b.newECDSA('Ana', 'P-256', from, to, 'cn', { validity }));
|
||||
expect([v.signature, v.seal, v.detail]).toEqual(['F1', 'S0', undefined]);
|
||||
const seal = await sealed(await b.newECDSA('TSA', 'P-256', from, to, 'cn', { validity }));
|
||||
expect([seal.signature, seal.seal]).toEqual(['F0', 'S2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('an ECDSA key with its point compressed', () => {
|
||||
// Go's x509.ParsePKIXPublicKey reads only the uncompressed point, 0x04 and the two coordinates: a key written
|
||||
// otherwise is outside the table, though noble would read it.
|
||||
it.each(['P-256', 'P-384', 'P-521'] as const)('is outside the table on %s: F5 for a signer, S1 for the authority of a seal, as Go', async (curve) => {
|
||||
const tsa = await b.newECDSA('TSA de prueba', 'P-256', from, to);
|
||||
const compressed = await b.newECDSA('Ana', curve, from, to, 'cn', { compressed: true });
|
||||
const v = await signed(tsa, compressed);
|
||||
expect([v.signature, v.detail!.signers[0]!.result]).toEqual(['F5', 'not verifiable']);
|
||||
const plain = await b.newECDSA('Ana', curve, from, to);
|
||||
expect((await signed(tsa, plain)).signature).toBe('F6');
|
||||
const authority = await b.newECDSA('TSA', curve, from, to, 'cn', { compressed: true });
|
||||
expect((await sealed(authority)).seal).toBe('S1');
|
||||
const late = await signed(authority, plain);
|
||||
expect([late.signature, late.detail!.signers[0]!.result]).toEqual(['F5', 'invalid seal']);
|
||||
expect((await sealed(await b.newECDSA('TSA', curve, from, to))).seal).toBe('S4');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue