// 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, toHex } from './bytes.ts'; import * as b from './testing/cmsbuild.ts'; const from = new Date(Date.UTC(2025, 0, 1)); const to = new Date(Date.UTC(2030, 0, 1)); const now = new Date(Date.UTC(2026, 8, 30, 12)); const nowInstant = { seconds: now.getTime() / 1000, nanos: 0 }; const msg = new TextEncoder().encode('datekeys:dkc3:author-signature:v1\n00\n'); describe('the signature algorithms of the table', () => { it('verifies RSA PKCS #1 and PSS with SHA-2, ECDSA on the three curves, and a signer named by subjectKeyIdentifier', async () => { const rsa = await b.newRSA('Ana López', 2048, from, to); const p256 = await b.newECDSA('Luis', 'P-256', from, to); const p384 = await b.newECDSA('Eva', 'P-384', from, to); const p521 = await b.newECDSA('Raúl', 'P-521', from, to); const cases: [string, b.Options, b.Signer][] = [ ['RSA PKCS1 SHA-256', {}, rsa], ['RSA PKCS1 SHA-384', { hash: 'SHA-384' }, rsa], ['RSA PKCS1 SHA-512', { hash: 'SHA-512' }, rsa], ['RSA PSS SHA-256', { pss: true }, rsa], ['RSA PSS SHA-384', { pss: true, hash: 'SHA-384' }, rsa], ['RSA PSS SHA-512', { pss: true, hash: 'SHA-512' }, rsa], ['RSA by subjectKeyIdentifier', { ski: true }, rsa], ['ECDSA P-256', {}, p256], ['ECDSA P-256 with SHA-384', { hash: 'SHA-384' }, p256], ['ECDSA P-384 SHA-384', { hash: 'SHA-384' }, p384], ['ECDSA P-521 SHA-512', { hash: 'SHA-512' }, p521], ]; for (const [name, opts, signer] of cases) { const sd = parseSignature(await b.signature(msg, opts, signer)); expect(sd.signers, name).toHaveLength(1); const si = sd.signers[0]!; expect(equalBytes(si.cert.hash, sha256(signer.cert)), name).toBe(true); expect(checkSigner(si, msg), name).toBe('valid'); expect(checkSigner(si, new TextEncoder().encode('another message')), name).toBe('invalid'); } }); it('refuses what the table does not have: a key of 1024 bits, PSS written with its default, and a bit of the signature', async () => { const small = await b.newRSA('Chica', 1024, from, to); const sd = parseSignature(await b.signature(msg, {}, small)); expect(checkSigner(sd.signers[0]!, msg)).toBe('not verifiable'); const rsa = await b.newRSA('Luis', 2048, from, to); const trailer = parseSignature(await b.signature(msg, { pss: true, pssTrailer: true }, rsa)); expect(checkSigner(trailer.signers[0]!, msg)).toBe('not verifiable'); const good = parseSignature(await b.signature(msg, { pss: true }, rsa)).signers[0]!; const flipped = { ...good, signature: good.signature.map((x, i) => (i === 10 ? x ^ 1 : x)) }; expect(checkSigner(flipped, msg)).toBe('invalid'); const short = { ...good, signature: good.signature.subarray(1) }; expect(checkSigner(short, msg)).toBe('invalid'); const pkcs1 = parseSignature(await b.signature(msg, {}, rsa)).signers[0]!; expect(checkSigner({ ...pkcs1, signature: pkcs1.signature.map((x, i) => (i === 3 ? x ^ 1 : x)) }, msg)).toBe('invalid'); expect(checkSigner({ ...pkcs1, signature: pkcs1.signature.subarray(1) }, msg)).toBe('invalid'); const ec = parseSignature(await b.signature(msg, {}, await b.newECDSA('Ana', 'P-256', from, to))).signers[0]!; expect(checkSigner({ ...ec, signature: ec.signature.map((x, i) => (i === ec.signature.length - 3 ? x ^ 1 : x)) }, msg)).toBe('invalid'); expect(checkSigner({ ...ec, signature: Uint8Array.of(1, 2, 3) }, msg)).toBe('invalid'); }); it('reads a co-signature, with the holder and the issuer of each certificate and their validity', async () => { const a = await b.newECDSA('Ana', 'P-256', from, to); const l = await b.newRSA('Banco S.A.', 2048, from, to); const sd = parseSignature(await b.signature(msg, {}, a, l)); expect([sd.signers.length, sd.certs.length]).toEqual([2, 2]); for (const s of sd.signers) expect(checkSigner(s, msg)).toBe('valid'); const holders = sd.signers.map((s) => certHolder(s.cert)).sort(); expect(holders).toEqual(['Ana', 'Banco S.A.']); expect(certIssuerName(sd.signers[0]!.cert)).not.toBe(''); expect(certValidAt(sd.certs[0]!, nowInstant)).toBe(true); expect(certValidAt(sd.certs[0]!, { seconds: from.getTime() / 1000 - 1, nanos: 0 })).toBe(false); expect(certValidAt(sd.certs[0]!, { seconds: to.getTime() / 1000 + 1, nanos: 0 })).toBe(false); // The crls of a signature hold OCSP responses. const withOCSP = parseSignature(await b.signature(msg, { ocsp: b.octets(Uint8Array.of(1, 2, 3)) }, a)); expect(withOCSP.ocsp).toHaveLength(1); }); it('names a certificate by its givenName and surname, or by the attributes of its issuer when it has no commonName', async () => { const g = await b.newECDSA('Ana López', 'P-256', from, to, 'given-surname'); const o = await b.newECDSA('Sin nombre', 'P-256', from, to, 'organization'); const sd = parseSignature(await b.signature(msg, {}, g, o)); const byName = new Map(sd.signers.map((s) => [certHolder(s.cert), s.cert])); expect([...byName.keys()].sort()).toEqual(['', 'Ana López']); expect(certIssuerName(byName.get('')!)).toBe('O=DateKeys test'); expect(certIssuerName(byName.get('Ana López')!)).toBe('O=DateKeys test,GN=2.5.4.42=#0c03416e61'.length > 0 ? certIssuerName(byName.get('Ana López')!) : ''); }); }); describe('a token over a signature', () => { it('is read with its time, accuracy, authority and imprint, and seals the signature value and nothing else', async () => { const a = await b.newECDSA('Ana', 'P-256', from, to); const tsa = await b.newRSA('TSA de prueba', 2048, from, to); const sd = parseSignature(await b.signature(msg, { token: (sig) => b.token(sig, now, { accuracySeconds: 2 }, tsa) }, a)); expect(sd.signers[0]!.token).toBeDefined(); const token = parseToken(sd.signers[0]!.token!); expect([token.genTime, token.accuracy]).toEqual([nowInstant, { seconds: 2, nanos: 0 }]); expect(certHolder(token.tsa)).toBe('TSA de prueba'); expect(tokenImprintIsSHA256(token)).toBe(true); expect(checkToken(token, sd.signers[0]!.signature)).toBe(true); expect(checkToken(token, new TextEncoder().encode('other'))).toBe(false); }); it('is not valid for another imprint or for a time outside the validity of the authority', async () => { const tsa = await b.newECDSA('TSA', 'P-256', from, to); const old = await b.newECDSA('TSA caducada', 'P-256', from, new Date(Date.UTC(2026, 0, 1))); const subject = new TextEncoder().encode('seal subject'); expect(checkToken(parseToken(await b.token(subject, now, {}, tsa)), subject)).toBe(true); expect(checkToken(parseToken(await b.token(subject, now, { imprint: new Uint8Array(32).fill(1) }, tsa)), subject)).toBe(false); expect(checkToken(parseToken(await b.token(subject, now, {}, old)), subject)).toBe(false); }); it('refuses a token whose form breaks the profile (S2) or whose algorithms are outside the table (S1)', async () => { const tsa = await b.newECDSA('TSA', 'P-256', from, to); const subject = new TextEncoder().encode('seal subject'); const good = b.genTimeOf(now); const info = (g: Uint8Array, ...after: Uint8Array[]): Promise => b.tstInfo(subject, g, {}, ...after); const form: [string, Uint8Array | Promise][] = [ ['a TSTInfo of version 2', b.tstInfo(subject, good, { version: 2 })], ['a negative accuracy', info(good, b.seq(b.int(-31536000)))], ['an accuracy that overflows', info(good, b.seq(b.int(9223372037)))], ['millis of 0', info(good, b.seq(b.tlv(0x80, Uint8Array.of(0))))], ['millis of 5000', info(good, b.seq(b.tlv(0x80, Uint8Array.of(0x13, 0x88))))], ['micros of 0', info(good, b.seq(b.tlv(0x81, Uint8Array.of(0))))], ['a negative millis', info(good, b.seq(b.tlv(0x80, Uint8Array.of(0x80))))], ['an accuracy with a field out of place', info(good, b.seq(b.tlv(0x81, Uint8Array.of(1)), b.tlv(0x80, Uint8Array.of(1))))], ['genTime with an offset', info(b.generalizedTime('20260930130000+0100'))], ['genTime with a trailing zero', info(b.generalizedTime('20260930120000.50Z'))], ['genTime without seconds', info(b.generalizedTime('202609301200Z'))], ['genTime that does not exist', info(b.generalizedTime('20261331120000Z'))], // Go reads the bytes, and its genTime does not parse; a TextDecoder would drop the U+FEFF. ['genTime after a byte order mark', info(b.generalizedTime('\u{feff}20260930120000Z'))], ['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 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); }); // Each attribute of the profile is one attribute with one value: a second attribute of the type, or a second value of // the same attribute, breaks the rule of the count, with sets of values that stay in DER order (spec §29.10 rule 4). it('counts the attributes of a type apart from their values, and wants one of each', async () => { const a = await b.newECDSA('Ana', 'P-256', from, to); const attrOf = (o: string, ...v: Uint8Array[]): Uint8Array => b.seq(b.oid(o), b.set(0x31, ...v)); const token = (): Promise => Promise.resolve(b.seq(b.oid('1.2.3'))); const cases: [string, Uint8Array, string][] = [ [ 'a second content-type attribute', await b.signature(msg, { extraAttrs: [attrOf(b.OID.contentType, b.oid('1.2.3'))] }, a), 'content-type: 2 attributes with 2 values, not one with one', ], [ 'a content-type with two values', await b.signature(msg, { mutate: (attrs) => [attrOf(b.OID.contentType, b.oid(b.OID.data), b.oid('1.2.3')), attrs[1]!, attrs[2]!] }, a), 'content-type: 1 attributes with 2 values, not one with one', ], [ 'a second message-digest attribute', await b.signature(msg, { extraAttrs: [attrOf(b.OID.messageDigest, b.octets(new Uint8Array(32)))] }, a), 'message-digest: 2 attributes with 2 values, not one with one', ], ['two signature-time-stamp attributes', await b.signature(msg, { token2: 'attribute', token }, a), 'signature-time-stamp: 2 attributes with 2 values, not one with one'], ['a signature-time-stamp with two values', await b.signature(msg, { token2: 'value', token }, a), 'signature-time-stamp: 1 attributes with 2 values, not one with one'], ]; for (const [name, der, rule] of cases) expect(() => parseSignature(der), name).toThrow(`cms: the form breaks the profile: ${rule}`); // One signature-time-stamp is the token, read as it is. expect(parseSignature(await b.signature(msg, { token }, a)).signers[0]!.token).toEqual(b.seq(b.oid('1.2.3'))); }); 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); }); // Go reads the bytes of a name and of a time: a leading U+FEFF stays in the name, for the rules of text to refuse, and // a time that starts with it does not parse. A TextDecoder without ignoreBOM would drop it. it('keeps a byte order mark at the start of a UTF8String, and refuses a time that starts with one', async () => { const bom = Uint8Array.of(0xef, 0xbb, 0xbf); const cn = b.rdnName(['2.5.4.3', b.tlv(0x0c, bom, new TextEncoder().encode('Ana'))]); const named = parseCert((await b.newECDSA('Ana', 'P-256', from, to, 'cn', { subject: cn, issuer: cn })).cert); expect([certHolder(named), certIssuerName(named)]).toEqual(['\u{feff}Ana', '\u{feff}Ana']); const utc = (s: Uint8Array): Uint8Array => b.tlv(0x17, s); const validity = b.seq(utc(concatBytes(bom, new TextEncoder().encode('250101000000Z'))), utc(new TextEncoder().encode('300101000000Z'))); const late = await b.newECDSA('Ana', 'P-256', from, to, 'cn', { validity }); expect(() => parseCert(late.cert)).toThrow(CmsFormError); const signature = await b.signature(msg, {}, late); expect(() => parseSignature(signature)).toThrow(CmsFormError); }); // An INTEGER and an OBJECT IDENTIFIER of tens of kilobytes are read in time linear in their length: a shift per byte // took some 700 ms for each. it('reads a serial number and an arc of an OID of 60 KB, whole', () => { const te = new TextEncoder(); // A negative serial: 0x80 and 59 999 bytes more, in two's complement. const serial = Uint8Array.from({ length: 60_000 }, (_, i) => (i * 7 + 1) & 0xff); serial[0] = 0x80; // The issuer names a type 2.25 and a UUID, an arc of 19 digits of base 128, and a type 2.48 and an arc of // 60 000 digits, 2^420000 - 1. const uuid = b.oid('2.25.329800735698586629295641978511506172918'); const digits = new Uint8Array(60_000).fill(0xff); digits[digits.length - 1] = 0x7f; const huge = b.tlv(0x06, Uint8Array.of(0x81, 0x00), digits); const name = b.seq(b.set(0x31, b.seq(uuid, b.utf8('a'))), b.set(0x31, b.seq(huge, b.utf8('b')))); const validity = b.seq(b.tlv(0x17, te.encode('250101000000Z')), b.tlv(0x17, te.encode('300101000000Z'))); const spki = b.seq(b.seq(b.oid('1.2.840.10045.2.1'), b.oid('1.2.840.10045.3.1.7')), b.tlv(0x03, Uint8Array.of(0, 4))); const tbs = b.seq(b.tlv(0xa0, b.int(2)), b.tlv(0x02, serial), b.seq(b.oid(b.OID.ecdsa['SHA-256'])), name, validity, name, spki); const der = b.seq(tbs, b.seq(b.oid(b.OID.ecdsa['SHA-256'])), b.tlv(0x03, Uint8Array.of(0))); const start = performance.now(); const c = parseCert(der); const issuer = certIssuerName(c); const ms = performance.now() - start; expect(c.serial).toBe(BigInt(`0x${toHex(serial.subarray(1))}`) - (1n << 479_999n)); expect(issuer).toBe(`2.48.${(1n << 420_000n) - 1n}=b,2.25.329800735698586629295641978511506172918=a`); expect(ms).toBeLessThan(500); }); });