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
parent
c31ab2d2e7
commit
92b4d196eb
@ -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`);
|
||||
}
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
Loading…
Reference in new issue