diff --git a/src/lib/dkc/cms.test.ts b/src/lib/dkc/cms.test.ts index 2c4aa99..11355bc 100644 --- a/src/lib/dkc/cms.test.ts +++ b/src/lib/dkc/cms.test.ts @@ -18,7 +18,7 @@ import { tokenImprintIsSHA256, } from './cms.ts'; import { derContent, splitDer } from './der.ts'; -import { concatBytes, equalBytes } from './bytes.ts'; +import { concatBytes, equalBytes, toHex } from './bytes.ts'; import * as b from './testing/cmsbuild.ts'; const from = new Date(Date.UTC(2025, 0, 1)); @@ -148,6 +148,8 @@ describe('a token over a signature', () => { ['genTime with a trailing zero', info(b.generalizedTime('20260930120000.50Z'))], ['genTime without seconds', info(b.generalizedTime('202609301200Z'))], ['genTime that does not exist', info(b.generalizedTime('20261331120000Z'))], + // Go reads the bytes, and its genTime does not parse; a TextDecoder would drop the U+FEFF. + ['genTime after a byte order mark', info(b.generalizedTime('\u{feff}20260930120000Z'))], ['ordering FALSE written', info(good, b.tlv(0x01, Uint8Array.of(0)))], ['an extra INTEGER at the end', info(good, b.int(7), b.int(8), b.int(9))], ['a field out of order', info(good, b.int(7), b.seq(b.int(1)))], @@ -213,11 +215,6 @@ describe('the form of a signature', () => { ['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), @@ -243,6 +240,36 @@ describe('the form of a signature', () => { for (const [name, der] of bad) expect(() => parseSignature(der), name).toThrow(CmsFormError); }); + // Each attribute of the profile is one attribute with one value: a second attribute of the type, or a second value of + // the same attribute, breaks the rule of the count, with sets of values that stay in DER order (spec §29.10 rule 4). + it('counts the attributes of a type apart from their values, and wants one of each', async () => { + const a = await b.newECDSA('Ana', 'P-256', from, to); + const attrOf = (o: string, ...v: Uint8Array[]): Uint8Array => b.seq(b.oid(o), b.set(0x31, ...v)); + const token = (): Promise => Promise.resolve(b.seq(b.oid('1.2.3'))); + const cases: [string, Uint8Array, string][] = [ + [ + 'a second content-type attribute', + await b.signature(msg, { extraAttrs: [attrOf(b.OID.contentType, b.oid('1.2.3'))] }, a), + 'content-type: 2 attributes with 2 values, not one with one', + ], + [ + 'a content-type with two values', + await b.signature(msg, { mutate: (attrs) => [attrOf(b.OID.contentType, b.oid(b.OID.data), b.oid('1.2.3')), attrs[1]!, attrs[2]!] }, a), + 'content-type: 1 attributes with 2 values, not one with one', + ], + [ + 'a second message-digest attribute', + await b.signature(msg, { extraAttrs: [attrOf(b.OID.messageDigest, b.octets(new Uint8Array(32)))] }, a), + 'message-digest: 2 attributes with 2 values, not one with one', + ], + ['two signature-time-stamp attributes', await b.signature(msg, { token2: 'attribute', token }, a), 'signature-time-stamp: 2 attributes with 2 values, not one with one'], + ['a signature-time-stamp with two values', await b.signature(msg, { token2: 'value', token }, a), 'signature-time-stamp: 1 attributes with 2 values, not one with one'], + ]; + for (const [name, der, rule] of cases) expect(() => parseSignature(der), name).toThrow(`cms: the form breaks the profile: ${rule}`); + // One signature-time-stamp is the token, read as it is. + expect(parseSignature(await b.signature(msg, { token }, a)).signers[0]!.token).toEqual(b.seq(b.oid('1.2.3'))); + }); + it('accepts a signing-certificate beside the v2, an explicit SHA-256 hashAlgorithm and a signature with junk in its unsigned attributes', async () => { const a = await b.newECDSA('Ana', 'P-256', from, to); const both = parseSignature(await b.signature(msg, { sigCertV1: true }, a)); @@ -268,4 +295,46 @@ describe('the certificates', () => { expect(() => parseCert(Uint8Array.of(1))).toThrow(CmsFormError); expect(() => parseCert(concatBytes(a.cert, Uint8Array.of(0)))).toThrow(CmsFormError); }); + + // Go reads the bytes of a name and of a time: a leading U+FEFF stays in the name, for the rules of text to refuse, and + // a time that starts with it does not parse. A TextDecoder without ignoreBOM would drop it. + it('keeps a byte order mark at the start of a UTF8String, and refuses a time that starts with one', async () => { + const bom = Uint8Array.of(0xef, 0xbb, 0xbf); + const cn = b.rdnName(['2.5.4.3', b.tlv(0x0c, bom, new TextEncoder().encode('Ana'))]); + const named = parseCert((await b.newECDSA('Ana', 'P-256', from, to, 'cn', { subject: cn, issuer: cn })).cert); + expect([certHolder(named), certIssuerName(named)]).toEqual(['\u{feff}Ana', '\u{feff}Ana']); + const utc = (s: Uint8Array): Uint8Array => b.tlv(0x17, s); + const validity = b.seq(utc(concatBytes(bom, new TextEncoder().encode('250101000000Z'))), utc(new TextEncoder().encode('300101000000Z'))); + const late = await b.newECDSA('Ana', 'P-256', from, to, 'cn', { validity }); + expect(() => parseCert(late.cert)).toThrow(CmsFormError); + const signature = await b.signature(msg, {}, late); + expect(() => parseSignature(signature)).toThrow(CmsFormError); + }); + + // An INTEGER and an OBJECT IDENTIFIER of tens of kilobytes are read in time linear in their length: a shift per byte + // took some 700 ms for each. + it('reads a serial number and an arc of an OID of 60 KB, whole', () => { + const te = new TextEncoder(); + // A negative serial: 0x80 and 59 999 bytes more, in two's complement. + const serial = Uint8Array.from({ length: 60_000 }, (_, i) => (i * 7 + 1) & 0xff); + serial[0] = 0x80; + // The issuer names a type 2.25 and a UUID, an arc of 19 digits of base 128, and a type 2.48 and an arc of + // 60 000 digits, 2^420000 - 1. + const uuid = b.oid('2.25.329800735698586629295641978511506172918'); + const digits = new Uint8Array(60_000).fill(0xff); + digits[digits.length - 1] = 0x7f; + const huge = b.tlv(0x06, Uint8Array.of(0x81, 0x00), digits); + const name = b.seq(b.set(0x31, b.seq(uuid, b.utf8('a'))), b.set(0x31, b.seq(huge, b.utf8('b')))); + const validity = b.seq(b.tlv(0x17, te.encode('250101000000Z')), b.tlv(0x17, te.encode('300101000000Z'))); + const spki = b.seq(b.seq(b.oid('1.2.840.10045.2.1'), b.oid('1.2.840.10045.3.1.7')), b.tlv(0x03, Uint8Array.of(0, 4))); + const tbs = b.seq(b.tlv(0xa0, b.int(2)), b.tlv(0x02, serial), b.seq(b.oid(b.OID.ecdsa['SHA-256'])), name, validity, name, spki); + const der = b.seq(tbs, b.seq(b.oid(b.OID.ecdsa['SHA-256'])), b.tlv(0x03, Uint8Array.of(0))); + const start = performance.now(); + const c = parseCert(der); + const issuer = certIssuerName(c); + const ms = performance.now() - start; + expect(c.serial).toBe(BigInt(`0x${toHex(serial.subarray(1))}`) - (1n << 479_999n)); + expect(issuer).toBe(`2.48.${(1n << 420_000n) - 1n}=b,2.25.329800735698586629295641978511506172918=a`); + expect(ms).toBeLessThan(500); + }); }); diff --git a/src/lib/dkc/cms.ts b/src/lib/dkc/cms.ts index b1f67f1..a0e3a7a 100644 --- a/src/lib/dkc/cms.ts +++ b/src/lib/dkc/cms.ts @@ -15,7 +15,7 @@ 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 { concatBytes, decodeUtf8, equalBytes, toHex } from './bytes.ts'; import { compareInstants, type Instant } from './datekey.ts'; import { checkDer, derContent, DerError, setOfSorted, splitDer } from './der.ts'; @@ -36,32 +36,63 @@ export class CmsAlgorithmError extends Error { } // ---- small DER readers ------------------------------------------------------ +// +// Both read in time linear in the length of the element: a bigint built by a +// shift per byte costs the square of it, some 700 ms for 60 KB, and the +// security area holds up to 64 KiB. + +// The longest arc, in digits of base 128, that accumulates in a number: 49 +// bits, which a double holds exactly. +const MAX_NUMBER_ARC = 7; +const HEX_DIGITS = '0123456789abcdef'; +// The dotted text of an OBJECT IDENTIFIER, as Go's ObjectIdentifier.String. 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; + let start = 0; + for (let i = 0; i < c.length; i++) { + if ((c[i]! & 0x80) !== 0) continue; + const v = arcValue(c.subarray(start, i + 1)); + if (parts.length === 0) { + const x = v < 40n ? 0n : v < 80n ? 1n : 2n; + parts.push(String(x), String(v - 40n * x)); + } else { + parts.push(String(v)); } + start = i + 1; } return parts.join('.'); } +// The value of an arc from its digits of base 128. An arc of more than seven +// digits, which no OID of the profile has, is read whole from its bits as +// hexadecimal, never by a shift per digit. +function arcValue(d: Uint8Array): bigint { + if (d.length <= MAX_NUMBER_ARC) { + let n = 0; + for (const x of d) n = n * 128 + (x & 0x7f); + return BigInt(n); + } + const nibbles: string[] = []; + let acc = 0; + let bits = 0; + for (let i = d.length - 1; i >= 0; i--) { + acc |= (d[i]! & 0x7f) << bits; + bits += 7; + for (; bits >= 4; bits -= 4) { + nibbles.push(HEX_DIGITS[acc & 15]!); + acc >>>= 4; + } + } + nibbles.push(HEX_DIGITS[acc]!); + return BigInt(`0x${nibbles.reverse().join('')}`); +} + +// An INTEGER, in two's complement, read whole from its hexadecimal. function intOf(el: Uint8Array): bigint { const c = derContent(el); - let v = 0n; - for (const b of c) v = (v << 8n) | BigInt(b); + const v = BigInt(`0x0${toHex(c)}`); return c.length > 0 && (c[0]! & 0x80) !== 0 ? v - (1n << BigInt(8 * c.length)) : v; } @@ -159,18 +190,12 @@ export interface Cert { 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 0x0c: // UTF8String, whose leading U+FEFF stays, as in Go, for the rules of text to refuse + return decodeUtf8(c); case 0x13: // PrintableString case 0x16: // IA5String case 0x1a: // VisibleString @@ -200,9 +225,12 @@ function parseName(el: Uint8Array): readonly Rdn[] { }); } +// The bytes of a time as text, a leading U+FEFF kept: no time starts with it. +const TIME_TEXT = new TextDecoder('utf-8', { ignoreBOM: true }); + // UTCTime and GeneralizedTime as a certificate has them, YYMMDDHHMMSSZ and YYYYMMDDHHMMSSZ. function parseCertTime(el: Uint8Array): Instant { - const s = new TextDecoder().decode(derContent(el)); + const s = TIME_TEXT.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]); @@ -324,6 +352,15 @@ export function certIssuerName(c: Cert): string { return cn !== '' ? cn : nameString(c.issuer).trim(); } +/** + * The SHA-256 of the DER of the Name of the issuer, which a reader shows in + * place of an issuer that breaks the rules of the declared author (spec + * §29.7), as Go's sha256.Sum256(RawIssuer). + */ +export function certIssuerHash(c: Cert): Uint8Array { + return sha256(c.rawIssuer); +} + /** 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; @@ -527,7 +564,10 @@ function attrs(b: Uint8Array): AttrSet { 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]); + // Appended, not copied: thousands of attributes of one type fit in the area. + const list = out.vals.get(oid); + if (list === undefined) out.vals.set(oid, vals); + else for (const v of vals) list.push(v); out.count.set(oid, (out.count.get(oid) ?? 0) + 1); } return out; @@ -694,7 +734,8 @@ type PublicKey = { kind: 'rsa'; n: bigint; e: bigint } | { kind: 'ec'; curve: ty // 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. +// P-521 with an uncompressed point, the only form that Go's +// x509.ParsePKIXPublicKey reads. function publicKey(c: Cert): PublicKey | undefined { try { checkDer(c.spki); @@ -719,6 +760,8 @@ function publicKey(c: Cert): PublicKey | undefined { 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; + // 0x04 and the two coordinates: noble would also read a compressed point. + if (key[0] !== 0x04 || key.length !== 1 + 2 * curve.Point.Fp.BYTES) return undefined; curve.Point.fromBytes(key); // a point of the curve, or it throws return { kind: 'ec', curve, point: key }; } @@ -914,7 +957,7 @@ function parseTSTInfo(b: Uint8Array): { genTime: Instant; accuracy: Instant; imp // 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 s = TIME_TEXT.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])); diff --git a/src/lib/dkc/der.test.ts b/src/lib/dkc/der.test.ts index 6402678..547ba70 100644 --- a/src/lib/dkc/der.test.ts +++ b/src/lib/dkc/der.test.ts @@ -7,6 +7,13 @@ import { h } from './testing/testdata.ts'; const zeros = (n: number): string => '00'.repeat(n); +// n SEQUENCEs, each holding the next, as hex; the innermost holds `inner`, or nothing. +function nested(n: number, inner = ''): string { + let hex = `30${(inner.length / 2).toString(16).padStart(2, '0')}${inner}`; + for (let i = 1; i < n; i++) hex = `30${(hex.length / 2).toString(16).padStart(2, '0')}${hex}`; + return hex; +} + describe('checkDer', () => { const cases: [name: string, hex: string, ok: boolean][] = [ ['sequence of an integer and a null', '3005020101' + '0500', true], @@ -46,7 +53,8 @@ describe('checkDer', () => { ['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], + ['33 SEQUENCEs, each holding the next', nested(33), true], + ['34 SEQUENCEs, each holding the next', nested(34), false], ]; it.each(cases)('%s', (_name, hex, ok) => { const b = h(hex); @@ -54,11 +62,13 @@ describe('checkDer', () => { 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/); + // The outermost element is at depth 0: an element at depth 33, constructed or not, is refused for its depth alone, + // since everything else about it is valid. + it('refuses an element nested more than 32 levels below the outermost one', () => { + expect(() => checkDer(h(nested(33)))).not.toThrow(); + expect(() => checkDer(h(nested(34)))).toThrow('der: nested too deep'); + expect(() => checkDer(h(nested(32, '0500')))).not.toThrow(); + expect(() => checkDer(h(nested(33, '0500')))).toThrow('der: nested too deep'); }); }); diff --git a/src/lib/dkc/security.failure.test.ts b/src/lib/dkc/security.failure.test.ts new file mode 100644 index 0000000..16828ca --- /dev/null +++ b/src/lib/dkc/security.failure.test.ts @@ -0,0 +1,102 @@ +// Faults of the evaluation of the security area that no input reaches today: +// the decoder, the strict verification of alg 1 and the evaluators of alg 2 +// and of the seal are replaced by functions that throw, in a file of its own +// because vi.mock replaces a module for the whole file. evaluateSecurity never +// throws, since the security area never decides the opening (spec §29.3): a +// fault while it evaluates the signature gives F1, and one while it evaluates +// the seal, S2, each without touching the other verdict; one while it decodes +// the area gives X. The Go reference does the same from v0.12. + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { controlCommit, headDigest } from './author.ts'; +import { peek } from './cbor.ts'; +import { decodeControl } from './control.ts'; +import { parseRFC3339 } from './datekey.ts'; +import { verifyStrict } from './ed25519strict.ts'; +import { FORMAT_3 } from './framing.ts'; +import { evaluateSecurity, type SecurityContext } from './security.ts'; +import { evaluateCMS, evaluateSeal } from './securitycms.ts'; +import { h, readJSON } from './testing/testdata.ts'; + +vi.mock('./cbor.ts', async (importOriginal) => { + const m = await importOriginal(); + return { ...m, peek: vi.fn(m.peek) }; +}); +vi.mock('./ed25519strict.ts', async (importOriginal) => { + const m = await importOriginal(); + return { ...m, verifyStrict: vi.fn(m.verifyStrict) }; +}); +vi.mock('./securitycms.ts', async (importOriginal) => { + const m = await importOriginal(); + return { ...m, evaluateCMS: vi.fn(m.evaluateCMS), evaluateSeal: vi.fn(m.evaluateSeal) }; +}); + +interface Record { + control_cbor: string; + head_cbor: string; + security_cbor: string; + unlock_at: string; +} + +// The security area of a fixture of the Go reference, in the context of its capsule. +function fixture(name: string): { area: Uint8Array; context: SecurityContext } { + const fx = readJSON(`fixtures/${name}.json`); + const context = { + controlCommit: controlCommit(decodeControl(h(fx.control_cbor), FORMAT_3), FORMAT_3), + headDigest: headDigest(h(fx.head_cbor)), + roundTime: parseRFC3339(fx.unlock_at), + }; + return { area: h(fx.security_cbor), context }; +} + +const fault = (): never => { + throw new Error('a fault that no input should cause'); +}; + +afterEach(() => { + vi.mocked(peek).mockReset(); + vi.mocked(verifyStrict).mockReset(); + vi.mocked(evaluateCMS).mockReset(); + vi.mocked(evaluateSeal).mockReset(); +}); + +describe('evaluateSecurity, when an evaluator throws', () => { + // format3_sealed holds a signature of alg 1, F4, and a seal of seal_type 2 from before the round, S4. + it('gives the verdicts of the fixtures when nothing throws', () => { + const sealed = fixture('format3_sealed'); + expect(evaluateSecurity(sealed.area, sealed.context)).toMatchObject({ signature: 'F4', seal: 'S4' }); + const cms = fixture('format3_signed_cms'); + expect(evaluateSecurity(cms.area, cms.context)).toMatchObject({ signature: 'F6', seal: 'S0' }); + }); + + it('gives F1 for a fault in a signature of alg 1, and the seal still verifies', () => { + const { area, context } = fixture('format3_sealed'); + vi.mocked(verifyStrict).mockImplementationOnce(fault); + const v = evaluateSecurity(area, context); + expect([v.signature, v.seal, v.authorKey, v.detail?.sealHolder]).toEqual(['F1', 'S4', undefined, 'Autoridad de Sellado de prueba']); + }); + + it('gives F1 for a fault in a signature of alg 2, with no signer named', () => { + const { area, context } = fixture('format3_signed_cms'); + vi.mocked(evaluateCMS).mockImplementationOnce(() => { + throw 'not even an Error'; + }); + expect(evaluateSecurity(area, context)).toEqual({ signature: 'F1', seal: 'S0' }); + }); + + it('gives S2 for a fault in the seal, and the signature still verifies', () => { + const { area, context } = fixture('format3_sealed'); + vi.mocked(evaluateSeal).mockImplementationOnce(fault); + const v = evaluateSecurity(area, context); + expect([v.signature, v.seal, v.authorKey === undefined, v.detail]).toEqual(['F4', 'S2', false, undefined]); + }); + + it('gives F1 and S2 for faults in both, and X for a fault while it decodes the area', () => { + const { area, context } = fixture('format3_sealed'); + vi.mocked(verifyStrict).mockImplementationOnce(fault); + vi.mocked(evaluateSeal).mockImplementationOnce(fault); + expect(evaluateSecurity(area, context)).toEqual({ signature: 'F1', seal: 'S2' }); + vi.mocked(peek).mockImplementationOnce(fault); + expect(evaluateSecurity(area, context)).toEqual({ signature: 'X', seal: 'X' }); + }); +}); diff --git a/src/lib/dkc/security.ts b/src/lib/dkc/security.ts index 944ef1f..378dd5b 100644 --- a/src/lib/dkc/security.ts +++ b/src/lib/dkc/security.ts @@ -1,11 +1,13 @@ // The security area of a format 3 capsule (spec §29.3, §29.7), as -// EncodeSecurity, EvaluateSecurity and the verdicts of the Go package -// capsule at spec-v0.10 (format3.go). SECURITY_CBOR is the map +// EncodeSecurity, EvaluateSecurityIn and the verdicts of the Go package +// capsule at spec-v0.11 (format3.go, signature.go). SECURITY_CBOR is the map // {0: "datekeys-security", 1: 1, ? 2: author-signature, ? 3: seal}, whose -// keys 2 and 3 hold CBOR encoded apart. This version implements no alg and -// no seal_type, and writes the area empty. Its evaluation never fails: the -// security area never decides the opening, and its verdicts carry no error -// code. Internal: index.ts does not re-export it. +// keys 2 and 3 hold CBOR encoded apart. In the context of a capsule it checks +// the signature of alg 1 with the strict profile of ed25519strict.ts, and the +// signature of alg 2 and the seal of seal_type 2 with securitycms.ts; the +// writer of this library does not sign, and writes the area empty. Its +// evaluation never throws: the security area never decides the opening, and +// its verdicts carry no error code. Internal: index.ts does not re-export it. import { ALG_CMS, ALG_ED25519, authorMessage, signersDigest } from './author.ts'; import { bech32Encode } from './bech32.ts'; @@ -33,9 +35,18 @@ const SEAL_TYPE_RFC3161 = 2; * - F0: no signature (no key 2); * - F1: a signature that is not checked: it does not decode, breaks its * schema or has an alg this reader does not implement; + * - F2: a signature that does not correspond to this content: of alg 1, it + * does not verify; of alg 2, a required signer has an invalid one; + * - F3 and F4: a valid signature of alg 1, with a key that the person saved, + * whose label it names, or with another; + * - F5: a signature of alg 2 without a signature or a seal that it demands; + * - F6: a signature of alg 2 whose required signers are all valid and sealed; * - S0: no seal (no key 3); nothing is shown about the date; - * - S1: a seal_type this reader does not implement; - * - S2: a seal that does not decode or breaks its schema. + * - S1: a seal_type this reader does not implement, or a token with an + * algorithm outside the table; + * - S2: a seal that does not decode or breaks its schema; + * - S3: a seal that does not correspond to this content; + * - S4 and S5: a valid seal, from before the time of the round or not. */ export type Verdict = 'X' | 'F0' | 'F1' | 'F2' | 'F3' | 'F4' | 'F5' | 'F6' | 'S0' | 'S1' | 'S2' | 'S3' | 'S4' | 'S5'; @@ -272,35 +283,42 @@ function decodeAlg(d: Decoder): number { return v; } -// Runs a decoding whose failure is a verdict, not an error: its result, or -// undefined when it fails. Anything but a DateKeysError is a bug and -// propagates. -function attempt(decode: () => T): T | undefined { +// What the evaluation of the signature gives, and that of the seal. +type SignatureVerdicts = Pick; +type SealVerdicts = { seal: Verdict; sealHolder?: string; sealTime?: Instant }; + +// Runs an evaluation whose failure is a verdict, never an error: its result, +// or `failed` when it throws, whatever it throws. The decoders throw a +// DateKeysError for content that breaks its schema; anything else is a fault +// that no input should cause, of this library or of noble, and gives the +// same verdict, since the security area never decides the opening (spec +// §29.3). +function attempt(evaluate: () => T, failed: F): T | F { try { - return decode(); - } catch (err) { - /* v8 ignore next -- @preserve: the decoders throw only DateKeysError */ - if (!(err instanceof DateKeysError)) throw err; - return undefined; + return evaluate(); + } catch { + return failed; } } /** * Reads SECURITY_CBOR and returns its verdicts (spec §29.3, §29.7). It never - * fails: the security area never decides the opening. For the signature and + * throws: the security area never decides the opening. For the signature and * for the seal apart, the first row of the table of §29.7 that holds * decides: alg and seal_type are read only from content that decodes and - * meets its schema. + * meets its schema. An exception while the signature is evaluated gives F1, + * and one while the seal is, S2, each without touching the other verdict. */ export function evaluateSecurity(b: Uint8Array, context?: SecurityContext): Verdicts { const w = attempt(() => { const h = peek(b); return h.typeTag === SECURITY_TYPE_TAG && h.version === SECURITY_VERSION ? unmarshal(b, decodeWire, encodeWire) : undefined; - }); + }, undefined); if (w === undefined) return { signature: 'X', seal: 'X' }; const { signature, seal } = w; - const sig = signature === undefined ? { signature: 'F0' as const } : evaluateSignature(signature, seal !== undefined, context); - const sealed = seal === undefined ? { seal: 'S0' as const } : evaluateSealArea(seal, signature, context); + const sig: SignatureVerdicts = + signature === undefined ? { signature: 'F0' } : attempt(() => evaluateSignature(signature, seal !== undefined, context), { signature: 'F1' as const }); + const sealed: SealVerdicts = seal === undefined ? { seal: 'S0' } : attempt(() => evaluateSealArea(seal, signature, context), { seal: 'S2' as const }); const detail: Detail | undefined = sig.detail === undefined && sealed.sealHolder === undefined ? undefined @@ -314,26 +332,23 @@ export function evaluateSecurity(b: Uint8Array, context?: SecurityContext): Verd // The verdict of the content of key 3 (spec §29.7, §29.11): S2 for content that does not decode, S1 for a seal_type // that is not 2 and, without the context of a capsule, as in v0.10, for seal_type 2 too; otherwise the token is checked. -function evaluateSealArea( - seal: Uint8Array, - signature: Uint8Array | undefined, - context: SecurityContext | undefined, -): { seal: Verdict; sealHolder?: string; sealTime?: Instant } { - const s = attempt(() => unmarshal(seal, decodeSeal, encodeSeal)); +function evaluateSealArea(seal: Uint8Array, signature: Uint8Array | undefined, context: SecurityContext | undefined): SealVerdicts { + const s = attempt(() => unmarshal(seal, decodeSeal, encodeSeal), undefined); if (s === undefined) return { seal: 'S2' }; if (s.sealType !== SEAL_TYPE_RFC3161 || context === undefined) return { seal: 'S1' }; return evaluateSeal(s.token, signature, context.controlCommit, context.headDigest, context.roundTime); } -// The verdict of the content of key 2 (spec §29.7, §29.9): F1 for content -// that does not decode, an alg this reader does not implement, or a key or a -// signature of another length, and without the context of a capsule, as in -// v0.10; F2 when the signature does not verify with the strict profile; F3 -// or F4 when it does. This version implements alg 1 only. -function evaluateSignature(content: Uint8Array, hasSeal: boolean, context: SecurityContext | undefined): Pick { +// The verdict of the content of key 2 (spec §29.7, §29.9, §29.10): F1 for +// content that does not decode, an alg this reader does not implement, or a +// key or a signature of alg 1 of another length, and without the context of +// a capsule, as in v0.10; alg 2, a signature with certificates, goes to +// evaluateCMS, which gives F1, F2, F5 or F6; alg 1 is F2 when the signature +// does not verify with the strict profile, and F3 or F4 when it does. +function evaluateSignature(content: Uint8Array, hasSeal: boolean, context: SecurityContext | undefined): SignatureVerdicts { const unchecked = { signature: 'F1' } as const; if (context === undefined) return unchecked; - const a = attempt(() => unmarshal(content, decodeAuthorSignature, encodeAuthorSignature)); + const a = attempt(() => unmarshal(content, decodeAuthorSignature, encodeAuthorSignature), undefined); if (a === undefined) return unchecked; if (a.alg === ALG_CMS) { const r = evaluateCMS(a.key, a.value, hasSeal, context.controlCommit, context.headDigest, context.roundTime); diff --git a/src/lib/dkc/securitycms.test.ts b/src/lib/dkc/securitycms.test.ts new file mode 100644 index 0000000..d57ded1 --- /dev/null +++ b/src/lib/dkc/securitycms.test.ts @@ -0,0 +1,152 @@ +// Tests of securitycms.ts, the verdicts of a signature of alg 2 and of a seal +// of seal_type 2 (spec v0.11 §29.7, §29.10, §29.11), through evaluateSecurity +// in the context of a capsule, on signatures and tokens that +// testing/cmsbuild.ts makes: what a signer line shows of a certificate, a byte +// order mark at the start of a name or a time, and keys whose point is +// compressed. capsule.EvaluateSecurityIn of the Go reference at spec-v0.11 +// gives the same verdicts, signer lines and Spanish lines on areas made the +// same way: an oracle compared them, and the hashes of the issuers below are +// the ones it gave for these Names. + +import { sha256 } from '@noble/hashes/sha2.js'; +import { describe, expect, it } from 'vitest'; +import { ALG_CMS, authorMessage, sealSubject, signersDigest } from './author.ts'; +import { compareBytes, toHex } from './bytes.ts'; +import { Encoder } from './cbor.ts'; +import { evaluateSecurity, type SecurityContext, type Verdicts, verdictLines } from './security.ts'; +import * as b from './testing/cmsbuild.ts'; + +const from = new Date(Date.UTC(2025, 0, 1)); +const to = new Date(Date.UTC(2030, 0, 1)); +const now = new Date(Date.UTC(2026, 8, 30, 12)); +const context: SecurityContext = { + controlCommit: new Uint8Array(32).fill(1), + headDigest: new Uint8Array(32).fill(2), + roundTime: { seconds: Date.UTC(2026, 9, 1) / 1000, nanos: 0 }, +}; +const te = new TextEncoder(); +const BOM = Uint8Array.of(0xef, 0xbb, 0xbf); + +// SECURITY_CBOR with the contents of keys 2 and 3 given. +function area(signature: Uint8Array | undefined, seal: Uint8Array | undefined): Uint8Array { + const e = new Encoder(); + e.map(2 + (signature === undefined ? 0 : 1) + (seal === undefined ? 0 : 1)); + e.uint(0); + e.text('datekeys-security'); + e.uint(1); + e.uint(1); + if (signature !== undefined) { + e.uint(2); + e.bstr(signature); + } + if (seal !== undefined) { + e.uint(3); + e.bstr(seal); + } + return e.out(); +} + +// The verdicts of a signature of alg 2 by the signers, all of them required, +// each with a signature-time-stamp of `tsa` when it is given. +async function signed(tsa: b.Signer | undefined, ...signers: b.Signer[]): Promise { + const hashes = signers.map((s) => sha256(s.cert)).sort(compareBytes); + const list = new Encoder(); + list.array(hashes.length); + for (const x of hashes) list.bstr(x); + const key1 = list.out(); + const msg = authorMessage(context.controlCommit, context.headDigest, signersDigest(ALG_CMS, key1)); + const cms = await b.signature(msg, tsa === undefined ? {} : { token: (sig) => b.token(sig, now, {}, tsa) }, ...signers); + const e = new Encoder(); + e.map(3); + e.uint(0); + e.uint(ALG_CMS); + e.uint(1); + e.bstr(key1); + e.uint(2); + e.bstr(cms); + return evaluateSecurity(area(e.out(), undefined), context); +} + +// The verdicts of a seal of seal_type 2 by `tsa`, without a signature. +async function sealed(tsa: b.Signer): Promise { + const token = await b.token(sealSubject(context.controlCommit, context.headDigest, undefined), now, {}, tsa); + const e = new Encoder(); + e.map(2); + e.uint(0); + e.uint(2); + e.uint(1); + e.bstr(token); + return evaluateSecurity(area(undefined, e.out()), context); +} + +const cn = (s: string): Uint8Array => b.rdnName(['2.5.4.3', b.utf8(s)], ['2.5.4.10', b.utf8('DateKeys test')]); + +describe('the issuer of a signer', () => { + // The issuer is text of the certificate, as the holder is: one that breaks the rules of the declared author shows + // the SHA-256 of the DER of its Name, Go's sha256.Sum256(RawIssuer), and never that of the certificate. + it('shows the SHA-256 of the Name of an issuer that breaks the rules of the declared author, as Go', async () => { + const tsa = await b.newECDSA('TSA de prueba', 'P-256', from, to); + const issuers: [string, Uint8Array, string][] = [ + ['an issuer with ESC', cn('Ana\x1b[2J'), '53213e495daf7cc473c94da46283bae22d5d54b58681a0635cd22b96abde7c3f'], + ['an issuer with U+202E', cn('Ana\u{202e}gpj.exe'), 'b1772664c1dbb3470b8d419f2275839241987889333ce17f223414a939634559'], + ['an empty issuer', b.seq(), 'e4f60d0aa6d7f3d3b6a6494b1c861b99f649c6f9ec51abaf201b20f297327c95'], + ['an issuer of 300 bytes', cn('x'.repeat(300)), '01ce813ba635fe45b8125d46b368eec8eee9a1d00f4c0b066c387c1b04a2ee92'], + ['an issuer that starts with U+FEFF', b.rdnName(['2.5.4.3', b.tlv(0x0c, BOM, te.encode('Autoridad'))]), '2bcf35767b63b7b0370d61cf63620623adac5a09662763ebd331316d4d4c6097'], + ]; + for (const [name, issuer, hash] of issuers) { + const s = await b.newECDSA('Ana', 'P-256', from, to, 'cn', { issuer }); + const v = await signed(tsa, s); + expect([v.signature, v.seal], name).toEqual(['F6', 'S0']); + const line = v.detail!.signers[0]!; + expect([line.holder, line.issuer, line.result], name).toEqual(['Ana', hash, 'valid']); + expect(toHex(sha256(issuer)), name).toBe(hash); + expect(verdictLines(v)[1], name).toBe(` Ana (emisor según su certificado: ${hash}), sellado el 2026-09-30T12:00:00Z, antes de la fecha de apertura.`); + } + // An issuer that meets the rules shows its name. + const good = await signed(tsa, await b.newECDSA('Ana', 'P-256', from, to, 'cn', { issuer: cn('Autoridad de prueba') })); + expect(good.detail!.signers[0]!.issuer).toBe('Autoridad de prueba'); + }); +}); + +describe('a byte order mark at the start of a name or a time', () => { + // Go reads the bytes: the U+FEFF of a UTF8String stays, the rules of text refuse it, and the hash is shown; a time + // that starts with it does not parse, and the certificate breaks the profile. + it('keeps it in a name, which then shows the hash of the certificate, as Go', async () => { + const tsa = await b.newECDSA('TSA de prueba', 'P-256', from, to); + const subject = b.rdnName(['2.5.4.3', b.tlv(0x0c, BOM, te.encode('Ana'))]); + const s = await b.newECDSA('Ana', 'P-256', from, to, 'cn', { subject }); + const v = await signed(tsa, s); + expect([v.signature, v.detail!.signers[0]!.holder]).toEqual(['F6', toHex(sha256(s.cert))]); + const authority = await b.newECDSA('TSA', 'P-256', from, to, 'cn', { subject: b.rdnName(['2.5.4.3', b.tlv(0x0c, BOM, te.encode('TSA'))]) }); + const seal = await sealed(authority); + expect([seal.seal, seal.detail!.sealHolder]).toEqual(['S4', toHex(sha256(authority.cert))]); + }); + + it('refuses a time of a certificate that starts with it: F1 for a signer, S2 for the authority of a seal, as Go', async () => { + const tsa = await b.newECDSA('TSA de prueba', 'P-256', from, to); + const utc = (s: Uint8Array): Uint8Array => b.tlv(0x17, s); + const validity = b.seq(utc(new Uint8Array([...BOM, ...te.encode('250101000000Z')])), utc(te.encode('300101000000Z'))); + const v = await signed(tsa, await b.newECDSA('Ana', 'P-256', from, to, 'cn', { validity })); + expect([v.signature, v.seal, v.detail]).toEqual(['F1', 'S0', undefined]); + const seal = await sealed(await b.newECDSA('TSA', 'P-256', from, to, 'cn', { validity })); + expect([seal.signature, seal.seal]).toEqual(['F0', 'S2']); + }); +}); + +describe('an ECDSA key with its point compressed', () => { + // Go's x509.ParsePKIXPublicKey reads only the uncompressed point, 0x04 and the two coordinates: a key written + // otherwise is outside the table, though noble would read it. + it.each(['P-256', 'P-384', 'P-521'] as const)('is outside the table on %s: F5 for a signer, S1 for the authority of a seal, as Go', async (curve) => { + const tsa = await b.newECDSA('TSA de prueba', 'P-256', from, to); + const compressed = await b.newECDSA('Ana', curve, from, to, 'cn', { compressed: true }); + const v = await signed(tsa, compressed); + expect([v.signature, v.detail!.signers[0]!.result]).toEqual(['F5', 'not verifiable']); + const plain = await b.newECDSA('Ana', curve, from, to); + expect((await signed(tsa, plain)).signature).toBe('F6'); + const authority = await b.newECDSA('TSA', curve, from, to, 'cn', { compressed: true }); + expect((await sealed(authority)).seal).toBe('S1'); + const late = await signed(authority, plain); + expect([late.signature, late.detail!.signers[0]!.result]).toEqual(['F5', 'invalid seal']); + expect((await sealed(await b.newECDSA('TSA', curve, from, to))).seal).toBe('S4'); + }); +}); diff --git a/src/lib/dkc/securitycms.ts b/src/lib/dkc/securitycms.ts index 258a1c3..5cfcd02 100644 --- a/src/lib/dkc/securitycms.ts +++ b/src/lib/dkc/securitycms.ts @@ -10,6 +10,7 @@ import { type Decoder, Encoder, unmarshal } from './cbor.ts'; import { addInstants, certHolder, + certIssuerHash, certIssuerName, certValidAt, checkSigner, @@ -33,7 +34,7 @@ const MAX_AUTHOR_LEN = 256; 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. */ + /** The issuer that the certificate says, with the same rules, and the SHA-256 of the DER of its Name when it does not meet them. */ readonly issuer: string; /** 'valid', 'invalid', 'absent', 'not verifiable', 'without seal', 'invalid seal' or 'out of validity'. */ readonly result: string; @@ -94,8 +95,10 @@ function holderText(name: string, hash: Uint8Array): string { } // One SignerInfo as §29.10 orders: not verifiable, invalid, without seal, with an invalid seal, out of validity, or valid. +// The issuer is text of the certificate, as the holder is: an issuer that breaks the rules shows the SHA-256 of its Name, +// so that no escape, no control and no bidirectional character reaches a line of the verdicts. 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 base = { holder: holderText(certHolder(s.cert), s.cert.hash), issuer: holderText(certIssuerName(s.cert), certIssuerHash(s.cert)) }; 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 }; diff --git a/src/lib/dkc/testing/cmsbuild.ts b/src/lib/dkc/testing/cmsbuild.ts index 3b5b92a..5c3e0a2 100644 --- a/src/lib/dkc/testing/cmsbuild.ts +++ b/src/lib/dkc/testing/cmsbuild.ts @@ -8,6 +8,7 @@ // issued a certificate. import { concatBytes, compareBytes } from '../bytes.ts'; +import { derContent, splitDer } from '../der.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; @@ -102,6 +103,19 @@ function name(cn: string, kind: NameKind = 'cn'): Uint8Array { return seq(set(0x31, seq(oid('2.5.4.3'), utf8(cn))), org); } +/** A Name of one attribute per RDN, each given as its type and the DER of its value. */ +export function rdnName(...attributes: [type: string, value: Uint8Array][]): Uint8Array { + return seq(...attributes.map(([type, value]) => set(0x31, seq(oid(type), value)))); +} + +/** What a test changes in a certificate: the DER of its subject, of its issuer and of its Validity, and the point of an ECDSA key, compressed. */ +export interface CertOptions { + readonly subject?: Uint8Array; + readonly issuer?: Uint8Array; + readonly validity?: Uint8Array; + readonly compressed?: boolean; +} + function utcTime(t: Date): Uint8Array { const p = (n: number, w = 2): string => String(n).padStart(w, '0'); const y = t.getUTCFullYear(); @@ -109,21 +123,30 @@ function utcTime(t: Date): Uint8Array { 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 { +// The SubjectPublicKeyInfo of an ECDSA key with its point 04 || x || y written compressed, 02 or 03 || x. +function compressPoint(spki: Uint8Array): Uint8Array { + const [alg, key] = splitDer(spki).children; + const point = derContent(key!).subarray(1); + const x = point.subarray(1, 1 + (point.length - 1) / 2); + return seq(alg!, tlv(0x03, Uint8Array.of(0, 2 + (point[point.length - 1]! & 1)), x)); +} + +async function signerOf(kind: Signer['kind'], bits: number, cn: string, notBefore: Date, notAfter: Date, nameKind: NameKind = 'cn', o: CertOptions = {}): Promise { const algorithm = kind === 'rsa' ? { name: 'RSASSA-PKCS1-v1_5', modulusLength: bits, publicExponent: Uint8Array.of(1, 0, 1), hash: 'SHA-256' } : { name: 'ECDSA', namedCurve: kind }; const pair = (await crypto.subtle.generateKey(algorithm, true, ['sign', 'verify'])) as CryptoKeyPair; - const spki = new Uint8Array(await crypto.subtle.exportKey('spki', pair.publicKey)); + const exported = new Uint8Array(await crypto.subtle.exportKey('spki', pair.publicKey)); + const spki = o.compressed === true ? compressPoint(exported) : exported; const pkcs8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', pair.privateKey)); const ski = new TextEncoder().encode(cn); - const issuer = name(cn, nameKind); + const issuer = o.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, + o.validity ?? seq(utcTime(notBefore), utcTime(notAfter)), + o.subject ?? name(cn, nameKind), spki, tlv(0xa3, seq(seq(oid('2.5.29.14'), octets(octets(ski))))), ); @@ -134,8 +157,8 @@ async function signerOf(kind: Signer['kind'], bits: number, cn: string, notBefor /** A signer with an RSA key of `bits` bits. */ export const newRSA = (cn: string, bits: number, notBefore: Date, notAfter: Date): Promise => signerOf('rsa', bits, cn, notBefore, notAfter); /** A signer with an ECDSA key on a NIST curve. */ -export const newECDSA = (cn: string, curve: 'P-256' | 'P-384' | 'P-521', notBefore: Date, notAfter: Date, nameKind: NameKind = 'cn'): Promise => - signerOf(curve, 0, cn, notBefore, notAfter, nameKind); +export const newECDSA = (cn: string, curve: 'P-256' | 'P-384' | 'P-521', notBefore: Date, notAfter: Date, nameKind: NameKind = 'cn', o: CertOptions = {}): Promise => + signerOf(curve, 0, cn, notBefore, notAfter, nameKind, o); // ECDSA from WebCrypto is r || s; a CMS signature is the DER of the two integers. function ecdsaDER(raw: Uint8Array): Uint8Array { @@ -177,8 +200,13 @@ export interface Options { 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; + /** + * Puts a second signature-time-stamp beside the token, to break the + * profile: in an attribute of its own, or as a second value of the same + * attribute. It is a ContentInfo of id-data, which the token must not be, + * so that the SET OF stays in DER order. + */ + token2?: 'attribute' | 'value'; /** Adds this response in crls. */ ocsp?: Uint8Array; /** The elements of crls as they are, instead of an OCSP response. */ @@ -235,7 +263,9 @@ async function signerInfo(message: Uint8Array, o: Options, token: boolean, s: Si 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)); + const second = seq(oid(OID.data)); + if (o.token2 === 'attribute') unsigned.push(attr(OID.timeStamp, t), attr(OID.timeStamp, second)); + else unsigned.push(o.token2 === 'value' ? attr(OID.timeStamp, t, second) : attr(OID.timeStamp, t)); } if (unsigned.length > 0) f.push(set(0xa1, ...unsigned)); return seq(...f);