Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 148 additions & 11 deletions src/cose/Signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { BinaryHelper, MalformedContentError } from '../util';
import { Algorithms, CoseAlgorithm } from './Algorithms';
import { Signer } from './Signer';
import { SigStructure } from './SigStructure';
import { TrustList } from './TrustList';
import {
AdditionalEKU,
CoseSignature,
Expand All @@ -31,6 +32,18 @@ import {
UnprotectedBucket,
} from './types';

/**
* Options for signature validation.
*/
export interface ValidationOptions {
/**
* Trust anchors (root certificates) to use for chain validation.
* Accepts PEM strings, DER bytes, or X509Certificate instances.
* If not provided, defaults to TrustList.trustAnchors for backwards compatibility.
*/
trustAnchors?: (string | Uint8Array | X509Certificate)[];
}

export class Signature {
public algorithm?: CoseAlgorithm;
public certificate?: X509Certificate;
Expand All @@ -41,6 +54,7 @@ export class Signature {
public paddingLength = 0;

private validatedTimestamp: Date | undefined;

/**
* Gets the validated timestamp or falls back to unverified timestamp
* @returns Date object representing the timestamp, or undefined if no timestamp exists
Expand Down Expand Up @@ -101,6 +115,8 @@ export class Signature {
signature.chainCertificates = x5chain
.slice(1)
.map(c => new X509Certificate(c as Uint8Array<ArrayBuffer>));
} else {
signature.chainCertificates = [];
}
} catch {
throw new MalformedContentError('Malformed credentials');
Expand Down Expand Up @@ -281,7 +297,7 @@ export class Signature {
for (const cert of signedData.certificates ?? []) {
if (!(cert instanceof pkijs.Certificate)) continue;
const x509Cert = new X509Certificate(cert.toSchema().toBER());
const certValidation = Signature.validateCertificate(x509Cert, tstInfo.genTime, false);
const certValidation = await Signature.validateCertificate(x509Cert, tstInfo.genTime, false);
if (certValidation !== ValidationStatusCode.SigningCredentialTrusted) {
result.addError(ValidationStatusCode.TimeStampUntrusted, sourceBox);
continue;
Expand Down Expand Up @@ -433,9 +449,14 @@ export class Signature {
* Validates the signature against a payload
* @param payload - The payload to validate against
* @param sourceBox - Optional JUMBF box for error context
* @param options - Optional validation options including trust anchors
* @returns Promise resolving to ValidationResult
*/
public async validate(payload: Uint8Array, sourceBox?: JUMBF.IBox): Promise<ValidationResult> {
public async validate(
payload: Uint8Array,
sourceBox?: JUMBF.IBox,
options?: ValidationOptions,
): Promise<ValidationResult> {
if (!this.certificate || !this.rawProtectedBucket || !this.signature || !this.algorithm) {
return ValidationResult.error(ValidationStatusCode.SigningCredentialInvalid, sourceBox);
}
Expand All @@ -445,12 +466,13 @@ export class Signature {
result.merge(await this.validateTimestamp(payload, CBORBox.encoder.encode(this.signature), sourceBox));
const timestamp = this.validatedTimestamp ?? new Date();

let code = Signature.validateCertificate(this.certificate, timestamp, true);
// Parse trust anchors from options or fall back to global TrustList for backwards compatibility
const trustAnchors =
options?.trustAnchors ? TrustList.parseTrustAnchors(options.trustAnchors) : TrustList.trustAnchors;

let code = await Signature.validateCertificate(this.certificate, timestamp, true);
if (code === ValidationStatusCode.SigningCredentialTrusted) {
for (const chainCertificate of this.chainCertificates) {
code = Signature.validateCertificate(chainCertificate, timestamp, false);
if (code !== ValidationStatusCode.SigningCredentialTrusted) break;
}
code = await Signature.validateChain(this.certificate, timestamp, this.chainCertificates, trustAnchors);
}
if (code === ValidationStatusCode.SigningCredentialTrusted) result.addInformational(code, sourceBox);
else result.addError(code, sourceBox);
Expand All @@ -477,13 +499,11 @@ export class Signature {
return result;
}

private static validateCertificate(
private static async validateCertificate(
certificate: X509Certificate,
validityTimestamp: Date,
isUsedForManifestSigning: boolean,
): ValidationStatusCode {
// TODO Actually verify the certificate chain

): Promise<ValidationStatusCode> {
const rawCertificate = AsnConvert.parse(certificate.rawData, ASN1Certificate).tbsCertificate;

// TODO verify OCSP
Expand Down Expand Up @@ -618,4 +638,121 @@ export class Signature {

return undefined;
}

private static keyIdsMatch(cert: X509Certificate, issuer: X509Certificate): boolean {
const aki = cert.getExtension(AuthorityKeyIdentifierExtension)?.keyId;
const ski = issuer.getExtension(SubjectKeyIdentifierExtension)?.keyId;
if (!aki || !ski) return false;

return aki === ski;
}

private static async verifySignature(cert: X509Certificate, issuer: X509Certificate): Promise<boolean> {
return cert.verify({
publicKey: issuer.publicKey,
});
}

/**
* Validates a certificate chain from a leaf certificate to a trusted root.
* Traverses the certificate chain by finding issuers in the intermediates list,
* validating each certificate's signature and timestamp. Returns a status indicating
* whether the chain terminates in a trusted root certificate.
* @param leaf - The leaf certificate to validate
* @param timestamp - The timestamp to validate certificates against
* @param intermediates - Array of intermediate certificates to use for chain building
* @param trustedRoots - Array of trusted root certificates
* @returns Promise resolving to a ValidationStatusCode indicating if the chain is trusted
* @throws Does not throw; errors are returned as validation status codes
*/
private static async validateChain(
leaf: X509Certificate,
timestamp: Date,
intermediates: X509Certificate[],
trustedRoots: X509Certificate[],
): Promise<ValidationStatusCode> {
let current = leaf;
const seen = new Set<string>();
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loop detection keyed by issuer.subject can produce false positives if different certificates share the same subject DN (common in rotated/intermediate reissues). Use a stable unique identifier instead (e.g., certificate thumbprint, raw DER hash, or SKI value when present) to avoid incorrectly rejecting valid chains.

Copilot uses AI. Check for mistakes.

const trustedRootThumbprints = await Promise.all(
trustedRoots.map(async r => {
return await r.publicKey.getThumbprint();
}),
);
while (true) {
// Check if current certificate is directly trusted
const currentThumbprint = await current.publicKey.getThumbprint();
const found = trustedRootThumbprints.find(trustedRootThumbprint =>
BinaryHelper.bufEqual(new Uint8Array(trustedRootThumbprint), new Uint8Array(currentThumbprint)),
);
if (found) {
return ValidationStatusCode.SigningCredentialTrusted;
}

// Find a trusted root that directly signed the current certificate to avoid unnecessary chain building and signature checks
let foundTrustedRoot = undefined;
for (const trustedRoot of trustedRoots) {
if (await Signature.validateChainCertificate(current, trustedRoot, timestamp)) {
foundTrustedRoot = trustedRoot;
break;
}
}
if (foundTrustedRoot) {
return ValidationStatusCode.SigningCredentialTrusted;
}

// Search issuer in intermediates
const issuer = (intermediates || []).find(intermediate => {
return Signature.keyIdsMatch(current, intermediate);
});

if (!issuer) {
return ValidationStatusCode.SigningCredentialUntrusted;
}

// Signature check and validate certificate and timestamp for the issuer
if (!(await Signature.validateChainCertificate(current, issuer, timestamp))) {
return ValidationStatusCode.SigningCredentialUntrusted;
}

// Loop detection
if (seen.has(issuer.subject)) {
return ValidationStatusCode.SigningCredentialUntrusted;
}

seen.add(issuer.subject);
current = issuer;
Comment on lines +718 to +724
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loop detection keyed by issuer.subject can produce false positives if different certificates share the same subject DN (common in rotated/intermediate reissues). Use a stable unique identifier instead (e.g., certificate thumbprint, raw DER hash, or SKI value when present) to avoid incorrectly rejecting valid chains.

Copilot uses AI. Check for mistakes.
}
}

/**
* Validates a certificate chain by verifying the signature and certificate validity.
* @param current - The current certificate in the chain to be validated
* @param issuer - The issuer certificate used to verify the current certificate's signature
* @param timestamp - The timestamp at which the certificate should be valid
* @returns A promise that resolves to `true` if both the signature verification and certificate validation succeed, `false` otherwise
* This method performs two validations:
* 1. Verifies that the current certificate is properly signed by the issuer certificate
* 2. Validates that the issuer certificate is trusted and valid at the given timestamp
* Both validations must pass for the method to return `true`.
*/
private static async validateChainCertificate(
current: X509Certificate,
issuer: X509Certificate,
timestamp: Date,
): Promise<boolean> {
Comment on lines +739 to +743
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validateChainCertificate reduces all non-trusted cases to a boolean, which causes validateChain to collapse diverse failures (expired, invalid extensions, etc.) into SigningCredentialUntrusted. Since validateCertificate returns specific ValidationStatusCodes, consider returning/propagating those codes instead of boolean so callers can report more accurate validation outcomes.

Copilot uses AI. Check for mistakes.
// Signature check
const verifySignature = await this.verifySignature(current, issuer);
if (!verifySignature) {
return false;
}

// Validate certificate and timestamp for the issuer
const validateCertificate = await Signature.validateCertificate(issuer, timestamp, false);
if (validateCertificate !== ValidationStatusCode.SigningCredentialTrusted) {
return false;
}
Comment on lines +750 to +754
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validateChainCertificate reduces all non-trusted cases to a boolean, which causes validateChain to collapse diverse failures (expired, invalid extensions, etc.) into SigningCredentialUntrusted. Since validateCertificate returns specific ValidationStatusCodes, consider returning/propagating those codes instead of boolean so callers can report more accurate validation outcomes.

Copilot uses AI. Check for mistakes.

return true;
}
}
72 changes: 72 additions & 0 deletions src/cose/TrustList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { X509Certificate } from '@peculiar/x509';

export class TrustList {
/**
* @deprecated Global mutable trust anchors cause race conditions and test flakiness.
* Use ValidationOptions.trustAnchors parameter in Signature.validate() instead.
* This property is maintained for backwards compatibility only.
*/
static trustAnchors: X509Certificate[] = [];

/**
* @deprecated Global mutable trust anchors cause race conditions and test flakiness.
* Use ValidationOptions.trustAnchors parameter in Signature.validate() instead.
* This method is maintained for backwards compatibility only.
*
* Configures global trust anchors used for PKI.js chain validation.
* Accepts PEM strings (single or multiple concatenated certs), DER bytes, or `X509Certificate` instances.
*/
public static setTrustAnchors(anchors: (string | Uint8Array | X509Certificate)[]): void {
TrustList.trustAnchors = TrustList.parseTrustAnchors(anchors);
}

/**
* Parses trust anchors from various formats into X509Certificate instances.
* Accepts PEM strings (single or multiple concatenated certs), DER bytes, or `X509Certificate` instances.
* @param anchors - Array of trust anchors in various formats
* @returns Array of parsed X509Certificate instances
*/
public static parseTrustAnchors(anchors: (string | Uint8Array | X509Certificate)[]): X509Certificate[] {
Comment on lines +19 to +29
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New parsing behavior (setTrustAnchors/parseTrustAnchors) is security- and correctness-critical, but there are no direct unit tests here. Consider adding focused tests that cover: multiple concatenated PEM certs, DER input, already-constructed X509Certificate, and handling of malformed blocks (and ensuring expected anchors are not silently dropped).

Copilot uses AI. Check for mistakes.
const out: X509Certificate[] = [];
for (const a of anchors) {
if (typeof a === 'string') {
for (const der of this.decodeAllPEMCertificates(a)) {
try {
// Cast to satisfy peculiar/x509 typing expecting ArrayBuffer
out.push(new X509Certificate(der as unknown as Uint8Array<ArrayBuffer>));
} catch {
/* ignore malformed entries */
}
}
} else if (a instanceof Uint8Array) {
try {
out.push(new X509Certificate(a as unknown as Uint8Array<ArrayBuffer>));
} catch {
/* ignore malformed entries */
}
} else if (a instanceof X509Certificate) {
out.push(a);
}
}

return out;
}

/**
* Decodes all PEM `CERTIFICATE` sections from a string into DER bytes.
*/
private static decodeAllPEMCertificates(pem: string): Uint8Array[] {
const pattern = /-----BEGIN CERTIFICATE-----([\s\S]*?)-----END CERTIFICATE-----/g;
const out: Uint8Array[] = [];
let match: RegExpExecArray | null;
while ((match = pattern.exec(pem)) !== null) {
const base64 = match[1].replace(/\r?\n|\s/g, '');
try {
out.push(Uint8Array.fromBase64(base64));
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uint8Array.fromBase64 is not available in standard Node.js runtimes, which will break trust anchor parsing outside Bun. Prefer decoding via Buffer.from(base64, 'base64') (or a small base64 helper) and then converting to Uint8Array.

Suggested change
out.push(Uint8Array.fromBase64(base64));
const derBuffer = Buffer.from(base64, 'base64');
out.push(new Uint8Array(derBuffer));

Copilot uses AI. Check for mistakes.
} catch {
/* ignore invalid blocks */
}
}
return out;
}
}
1 change: 1 addition & 0 deletions src/cose/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export * from './Algorithms';
export * from './LocalSigner';
export * from './Signature';
export * from './Signer';
export * from './TrustList';
export * from './types';
7 changes: 4 additions & 3 deletions src/manifest/Manifest.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Asset } from '../asset';
import { Signer } from '../cose';
import { Signer, ValidationOptions } from '../cose';
import { HashAlgorithm } from '../crypto';
import { Crypto } from '../crypto/Crypto';
import * as JUMBF from '../jumbf';
Expand Down Expand Up @@ -247,9 +247,10 @@ export class Manifest implements ManifestComponent {
/**
* Verifies the manifest's claim's validity
* @param asset - Asset for validation of bindings
* @param options - Optional validation options including trust anchors
* @returns Promise resolving to ValidationResult
*/
public async validate(asset: Asset): Promise<ValidationResult> {
public async validate(asset: Asset, options?: ValidationOptions): Promise<ValidationResult> {
const result = new ValidationResult();

if (!this.claim?.sourceBox) {
Expand All @@ -260,7 +261,7 @@ export class Manifest implements ManifestComponent {
// Validate the signature
const referencedSignature = this.getComponentByURL(this.claim?.signatureRef, true);
if (this.signature && referencedSignature === this.signature) {
result.merge(await this.signature.validate(this.claim.getBytes(this.claim)!));
result.merge(await this.signature.validate(this.claim.getBytes(this.claim)!, options));
} else {
result.addError(ValidationStatusCode.ClaimSignatureMissing, this.claim.signatureRef);
}
Expand Down
7 changes: 4 additions & 3 deletions src/manifest/ManifestStore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Asset } from '../asset';
import { Signer } from '../cose';
import { Signer, ValidationOptions } from '../cose';
import { HashAlgorithm } from '../crypto';
import * as JUMBF from '../jumbf';
import { BinaryHelper } from '../util';
Expand Down Expand Up @@ -130,11 +130,12 @@ export class ManifestStore {
/**
* Validates the active manifest
* @param asset Asset for validation of bindings
* @param options Optional validation options including trust anchors
*/
public async validate(asset: Asset): Promise<ValidationResult> {
public async validate(asset: Asset, options?: ValidationOptions): Promise<ValidationResult> {
const activeManifest = this.getActiveManifest();
if (activeManifest) {
return activeManifest.validate(asset);
return activeManifest.validate(asset, options);
} else {
return ValidationResult.error(ValidationStatusCode.ClaimCBORInvalid, this.sourceBox);
}
Expand Down
5 changes: 3 additions & 2 deletions src/manifest/Signature.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ValidationOptions } from '../cose';
import * as COSE from '../cose';
import * as JUMBF from '../jumbf';
import { TimestampProvider } from '../rfc3161';
Expand Down Expand Up @@ -78,9 +79,9 @@ export class Signature implements ManifestComponent {
return this.sourceBox;
}

public async validate(payload: Uint8Array): Promise<ValidationResult> {
public async validate(payload: Uint8Array, options?: ValidationOptions): Promise<ValidationResult> {
try {
return await this.signatureData.validate(payload, this.sourceBox);
return await this.signatureData.validate(payload, this.sourceBox, options);
} catch (e) {
if (e instanceof MalformedContentError) {
return ValidationResult.error(ValidationStatusCode.SigningCredentialInvalid, this.sourceBox);
Expand Down
7 changes: 6 additions & 1 deletion tests/asset-reading.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import assert from 'node:assert/strict';
import * as fs from 'node:fs/promises';
import { describe, it } from 'bun:test';
import { beforeAll, describe, it } from 'bun:test';
import { Asset, AssetType, BMFF, JPEG, PNG } from '../src/asset';
import { SuperBox } from '../src/jumbf';
import { ManifestStore, ValidationResult, ValidationStatusCode } from '../src/manifest';
import { BinaryHelper } from '../src/util';
import { setTrustList } from './utils/set-trust-list';

const baseDir = 'tests/fixtures';

Expand Down Expand Up @@ -205,6 +206,10 @@ const testFiles: Record<string, TestExpectations> = {
},
};

beforeAll(async () => {
await setTrustList();
});

describe('Functional Asset Reading Tests', function () {
for (const [filename, data] of Object.entries(testFiles)) {
describe(`test file ${filename}`, () => {
Expand Down
Loading