diff --git a/src/cose/Signature.ts b/src/cose/Signature.ts index d4909246..34d7e9f8 100644 --- a/src/cose/Signature.ts +++ b/src/cose/Signature.ts @@ -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, @@ -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; @@ -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 @@ -101,6 +115,8 @@ export class Signature { signature.chainCertificates = x5chain .slice(1) .map(c => new X509Certificate(c as Uint8Array)); + } else { + signature.chainCertificates = []; } } catch { throw new MalformedContentError('Malformed credentials'); @@ -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; @@ -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 { + public async validate( + payload: Uint8Array, + sourceBox?: JUMBF.IBox, + options?: ValidationOptions, + ): Promise { if (!this.certificate || !this.rawProtectedBucket || !this.signature || !this.algorithm) { return ValidationResult.error(ValidationStatusCode.SigningCredentialInvalid, sourceBox); } @@ -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); @@ -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 { const rawCertificate = AsnConvert.parse(certificate.rawData, ASN1Certificate).tbsCertificate; // TODO verify OCSP @@ -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 { + 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 { + let current = leaf; + const seen = new Set(); + + 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; + } + } + + /** + * 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 { + // 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; + } + + return true; + } } diff --git a/src/cose/TrustList.ts b/src/cose/TrustList.ts new file mode 100644 index 00000000..2892509d --- /dev/null +++ b/src/cose/TrustList.ts @@ -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[] { + 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)); + } catch { + /* ignore malformed entries */ + } + } + } else if (a instanceof Uint8Array) { + try { + out.push(new X509Certificate(a as unknown as Uint8Array)); + } 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)); + } catch { + /* ignore invalid blocks */ + } + } + return out; + } +} diff --git a/src/cose/index.ts b/src/cose/index.ts index 4320ca61..3a7a7211 100644 --- a/src/cose/index.ts +++ b/src/cose/index.ts @@ -2,4 +2,5 @@ export * from './Algorithms'; export * from './LocalSigner'; export * from './Signature'; export * from './Signer'; +export * from './TrustList'; export * from './types'; diff --git a/src/manifest/Manifest.ts b/src/manifest/Manifest.ts index ae487eba..e411537b 100644 --- a/src/manifest/Manifest.ts +++ b/src/manifest/Manifest.ts @@ -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'; @@ -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 { + public async validate(asset: Asset, options?: ValidationOptions): Promise { const result = new ValidationResult(); if (!this.claim?.sourceBox) { @@ -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); } diff --git a/src/manifest/ManifestStore.ts b/src/manifest/ManifestStore.ts index cded1ff0..575a4b5c 100644 --- a/src/manifest/ManifestStore.ts +++ b/src/manifest/ManifestStore.ts @@ -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'; @@ -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 { + public async validate(asset: Asset, options?: ValidationOptions): Promise { const activeManifest = this.getActiveManifest(); if (activeManifest) { - return activeManifest.validate(asset); + return activeManifest.validate(asset, options); } else { return ValidationResult.error(ValidationStatusCode.ClaimCBORInvalid, this.sourceBox); } diff --git a/src/manifest/Signature.ts b/src/manifest/Signature.ts index 05dfad92..2dc698ba 100644 --- a/src/manifest/Signature.ts +++ b/src/manifest/Signature.ts @@ -1,3 +1,4 @@ +import type { ValidationOptions } from '../cose'; import * as COSE from '../cose'; import * as JUMBF from '../jumbf'; import { TimestampProvider } from '../rfc3161'; @@ -78,9 +79,9 @@ export class Signature implements ManifestComponent { return this.sourceBox; } - public async validate(payload: Uint8Array): Promise { + public async validate(payload: Uint8Array, options?: ValidationOptions): Promise { 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); diff --git a/tests/asset-reading.test.ts b/tests/asset-reading.test.ts index f9676e2e..dd66f6c4 100644 --- a/tests/asset-reading.test.ts +++ b/tests/asset-reading.test.ts @@ -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'; @@ -205,6 +206,10 @@ const testFiles: Record = { }, }; +beforeAll(async () => { + await setTrustList(); +}); + describe('Functional Asset Reading Tests', function () { for (const [filename, data] of Object.entries(testFiles)) { describe(`test file ${filename}`, () => { diff --git a/tests/certificate-chain.test.ts b/tests/certificate-chain.test.ts new file mode 100644 index 00000000..5f39a151 --- /dev/null +++ b/tests/certificate-chain.test.ts @@ -0,0 +1,1183 @@ +/** + * @file Certificate Chain Validation Tests + * + * End-to-end tests that verify the C2PA manifest signing and validation pipeline + * correctly enforces X.509 certificate chain rules. Each test dynamically generates + * a certificate hierarchy (root → intermediate → leaf) using `@peculiar/x509`, + * signs a JPEG asset with a C2PA manifest, and then validates the result. + * + * The test suite covers the following areas of the C2PA specification's certificate + * requirements: + * + * 1. **Basic chain structure** – valid 2- and 3-level chains, self-signed roots, + * and missing intermediates. + * 2. **Signature verification** – tampered intermediate signatures and irrelevant + * certificates in the chain. + * 3. **Validity period** – expired / not-yet-valid certificates at every level. + * 4. **Key Usage extensions** – missing `digitalSignature` bit and non-critical + * Key Usage flag. + * 5. **Basic Constraints** – missing extension on intermediates. + * 6. **Subject Key Identifier** – missing SKI on root and intermediate. + * 7. **Authority Key Identifier** – missing AKI on intermediate. + * 8. **Error handling** – malformed and empty certificate data. + * 9. **Loop detection** – circular certificate references (A → B → C → B). + * 10. **Subject/Issuer matching** – mismatched Authority Key Identifier. + * + * @see {@link https://c2pa.org/specifications/} for the C2PA specification. + */ + +import assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; +import { + AuthorityKeyIdentifierExtension, + BasicConstraintsExtension, + ExtendedKeyUsage, + ExtendedKeyUsageExtension, + Extension, + KeyUsageFlags, + KeyUsagesExtension, + SubjectKeyIdentifierExtension, + X509Certificate, + X509CertificateCreateParams, + X509CertificateCreateSelfSignedParams, + X509CertificateGenerator, +} from '@peculiar/x509'; +import { beforeAll, describe, expect, it } from 'bun:test'; +import { JPEG } from '../src/asset'; +import { CoseAlgorithmIdentifier, LocalSigner, TrustList } from '../src/cose'; +import { SuperBox } from '../src/jumbf'; +import { DataHashAssertion, ManifestStore, ValidationResult } from '../src/manifest'; +import { LocalTimestampProvider } from '../src/rfc3161'; +import { + getExpectedValidationStatusEntries, + getExpectedValidationStatusEntriesInvalid, + getExpectedValidationStatusEntriesUntrusted, + getExpectedValidationStatusEntriesWrongTimeStamp, +} from './utils/testCertificates'; + +/** Path to the unsigned source JPEG used as input for every test. */ +const sourceFile = 'tests/fixtures/trustnxt-icon.jpg'; +/** Path where the signed JPEG is temporarily written during a test (deleted afterwards). */ +const targetFile = 'tests/fixtures/trustnxt-icon-certificate-chain-signed.jpg'; + +/** + * Performs the full sign-then-validate round-trip for a JPEG asset. + * + * 1. Reads the unsigned {@link sourceFile}. + * 2. Creates a C2PA manifest with a SHA-512 data hash assertion. + * 3. Signs the manifest using the supplied {@link signer} and {@link timestampProvider}. + * 4. Writes the signed asset to {@link targetFile}. + * 5. Re-reads the signed file, extracts and deserialises the JUMBF manifest store, + * and runs full validation. + * 6. Cleans up the temporary file. + * + * @param signer - The {@link LocalSigner} used to produce the COSE signature. + * @param timestampProvider - The {@link LocalTimestampProvider} used to produce the RFC 3161 timestamp. + * @returns A tuple of the {@link ValidationResult} and the active manifest's label. + */ +async function getValidationResult( + signer: LocalSigner, + timestampProvider: LocalTimestampProvider, +): Promise<[ValidationResult, string]> { + // load the file into a buffer + const buf = await fs.readFile(sourceFile); + assert.ok(buf); + + // ensure it's a JPEG + assert.ok(await JPEG.canRead(buf)); + + // construct the asset + const asset = await JPEG.create(buf); + + // create a new manifest store and append a new manifest + const manifestStore = new ManifestStore(); + const manifest = manifestStore.createManifest({ + assetFormat: 'image/jpeg', + instanceID: 'xyzxyz2', + defaultHashAlgorithm: 'SHA-256', + signer, + }); + + // create a data hash assertion + const dataHashAssertion = DataHashAssertion.create('SHA-512'); + manifest.addAssertion(dataHashAssertion); + + // make space in the asset + await asset.ensureManifestSpace(manifestStore.measureSize()); + + // update the hard binding + await dataHashAssertion.updateWithAsset(asset); + + // create the signature + await manifest.sign(signer, timestampProvider); + + // write the JUMBF box to the asset + await asset.writeManifestJUMBF(manifestStore.getBytes()); + + // write the asset to the target file + await fs.writeFile(targetFile, await asset.getDataRange()); + + // load the file into a buffer + const targetBuf = await fs.readFile(targetFile); + assert.ok(targetBuf); + + // ensure it's a JPEG + assert.ok(await JPEG.canRead(targetBuf)); + + // construct the asset + const targetAsset = await JPEG.create(targetBuf); + + // extract the C2PA manifest store in binary JUMBF format + const jumbf = await targetAsset.getManifestJUMBF(); + assert.ok(jumbf, 'no JUMBF found'); + + // deserialize the JUMBF box structure + const superBox = SuperBox.fromBuffer(jumbf); + + // construct the manifest store from the JUMBF box + const targetManifestStore = ManifestStore.read(superBox); + + // get active manifest + const targetManifest = targetManifestStore.getActiveManifest(); + assert.ok(targetManifest, 'No active manifest found'); + assert.ok(targetManifest.signature, 'No signature found in manifest'); + + // validate the asset against the store + const validationResult = await targetManifestStore.validate(targetAsset); + + // delete test file, ignore the case it doesn't exist + await fs.unlink(targetFile).catch(() => undefined); + + assert.ok(targetManifest.label, 'No manifest label'); + return [validationResult, targetManifest.label]; +} + +/** + * Exports a {@link CryptoKey} to its PKCS#8 DER-encoded byte representation. + * + * This is the format expected by {@link LocalSigner} and {@link LocalTimestampProvider}. + * + * @param key - A private {@link CryptoKey} with `extractable` set to `true`. + * @returns The PKCS#8-encoded private key bytes. + */ +async function toPkcs8Bytes(key: CryptoKey): Promise { + const der = await crypto.subtle.exportKey('pkcs8', key); // ArrayBuffer (DER) + return new Uint8Array(der); +} + +enum ExtensionClassNames { + BasicConstraintsExtension = 'BasicConstraintsExtension', + ExtendedKeyUsageExtension = 'ExtendedKeyUsageExtension', + KeyUsagesExtension = 'KeyUsagesExtension', + SubjectKeyIdentifierExtension = 'SubjectKeyIdentifierExtension', + AuthorityKeyIdentifierExtension = 'AuthorityKeyIdentifierExtension', +} +type ExtensionClassName = keyof typeof ExtensionClassNames; +type ExtensionChangeMap = Partial>; + +/** + * Builds the standard X.509v3 extensions for a **root CA** certificate. + * + * Index layout (used by {@link applyExtensionChanges}): + * - `[0]` BasicConstraints – CA:true, pathLen 3, critical + * - `[1]` KeyUsage – digitalSignature | keyCertSign | cRLSign, critical + * - `[2]` SubjectKeyIdentifier + * + * @param subjectPublicKey - The root CA's public key (used for SKI calculation). + */ +async function getRootExtensions(subjectPublicKey: CryptoKey): Promise { + return [ + new BasicConstraintsExtension(true, 3, true), + new KeyUsagesExtension( + KeyUsageFlags.digitalSignature + KeyUsageFlags.keyCertSign + KeyUsageFlags.cRLSign, + true, + ), + await SubjectKeyIdentifierExtension.create(subjectPublicKey, false), + ]; +} + +/** + * Builds the standard X.509v3 extensions for an **intermediate CA** certificate. + * + * Index layout (used by {@link applyExtensionChanges}): + * - `[0]` BasicConstraints – CA:true, pathLen 2, critical + * - `[1]` ExtendedKeyUsage – emailProtection, critical + * - `[2]` KeyUsage – digitalSignature | keyCertSign | cRLSign, critical + * - `[3]` SubjectKeyIdentifier + * - `[4]` AuthorityKeyIdentifier + * + * @param subjectPublicKey - The intermediate CA's public key. + * @param issuerPublicKey - The issuing CA's public key (used for AKI calculation). + */ +async function getIntermediateExtensions( + subjectPublicKey: CryptoKey, + issuerPublicKey: CryptoKey, +): Promise { + return [ + new BasicConstraintsExtension(true, 2, true), + new ExtendedKeyUsageExtension([ExtendedKeyUsage.emailProtection], true), + new KeyUsagesExtension( + KeyUsageFlags.digitalSignature + KeyUsageFlags.keyCertSign + KeyUsageFlags.cRLSign, + true, + ), + await SubjectKeyIdentifierExtension.create(subjectPublicKey, false), + await AuthorityKeyIdentifierExtension.create(issuerPublicKey, false), + ]; +} + +/** + * Builds the standard X.509v3 extensions for a **leaf (end-entity)** certificate. + * + * Index layout (used by {@link applyExtensionChanges}): + * - `[0]` BasicConstraints – CA:false, pathLen 1, critical + * - `[1]` ExtendedKeyUsage – emailProtection, critical + * - `[2]` KeyUsage – digitalSignature only, critical + * - `[3]` SubjectKeyIdentifier + * - `[4]` AuthorityKeyIdentifier + * + * @param subjectPublicKey - The leaf certificate's public key. + * @param issuerPublicKey - The issuing CA's public key (used for AKI calculation). + */ +async function getLeafExtensions(subjectPublicKey: CryptoKey, issuerPublicKey: CryptoKey): Promise { + return [ + new BasicConstraintsExtension(false, 1, true), + new ExtendedKeyUsageExtension([ExtendedKeyUsage.emailProtection], true), + new KeyUsagesExtension(KeyUsageFlags.digitalSignature, true), + await SubjectKeyIdentifierExtension.create(subjectPublicKey, false), + await AuthorityKeyIdentifierExtension.create(issuerPublicKey, false), + ]; +} + +/** + * Generates a self-signed ECDSA P-256 **root CA** certificate and registers it + * as the sole trust anchor via {@link TrustList.setTrustAnchors}. + * + * @param partial - Optional overrides merged into the certificate creation params + * (e.g. `notBefore`, `notAfter`). + * @param extensionChanges - Optional map of index → replacement {@link Extension} (or `undefined` + * to remove). Applied via {@link applyExtensionChanges}. + * @returns A tuple of `[keyPair, certificate]`. + */ +async function createRootCertificate( + partial?: Partial, + extensionChanges?: ExtensionChangeMap, +): Promise<[CryptoKeyPair, X509Certificate]> { + const rootKeys = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); + const extensions = await getRootExtensions(rootKeys.publicKey); + const rootCert = await X509CertificateGenerator.createSelfSigned( + { + serialNumber: '01', + name: `C=NL, ST=Zuid-Holland, O=Dawn Technology, OU=Development, CN=Root`, + keys: rootKeys, + signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' }, + extensions: applyExtensionChanges(extensions, extensionChanges), + ...partial, + }, + crypto, + ); + TrustList.setTrustAnchors([rootCert]); + + return [rootKeys, rootCert]; +} + +/** + * Generates an ECDSA P-256 **intermediate CA** certificate signed by the given root. + * + * @param rootCert - The issuing root CA certificate. + * @param rootKeys - The root CA's key pair (private key used to sign). + * @param partial - Optional overrides merged into the certificate creation params. + * @param extensionChanges - Optional extension replacements/removals. + * @returns A tuple of `[keyPair, certificate]`. + */ +async function createIntermediateCertificate( + rootCert: X509Certificate, + rootKeys: CryptoKeyPair, + partial?: Partial, + extensionChanges?: ExtensionChangeMap, +): Promise<[CryptoKeyPair, X509Certificate]> { + const intermediateKeys = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ + 'sign', + 'verify', + ]); + const extensions = await getIntermediateExtensions(intermediateKeys.publicKey, rootKeys.publicKey); + const intermediateCert = await X509CertificateGenerator.create( + { + serialNumber: '02', + subject: `C=NL, ST=Zuid-Holland, O=Dawn Technology, OU=Development, CN=Intermediate`, + issuer: rootCert.subject, + signingKey: rootKeys.privateKey, + publicKey: intermediateKeys.publicKey, + signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' }, + extensions: applyExtensionChanges(extensions, extensionChanges), + ...partial, + }, + crypto, + ); + + return [intermediateKeys, intermediateCert]; +} + +/** + * Generates an ECDSA P-256 **leaf (end-entity)** certificate signed by the given intermediate CA. + * + * @param intermediateCert - The issuing intermediate CA certificate. + * @param intermediateKeys - The intermediate CA's key pair (private key used to sign). + * @param partial - Optional overrides merged into the certificate creation params. + * @param extensionChanges - Optional extension replacements/removals. + * @returns A tuple of `[keyPair, certificate]`. + */ +async function createLeafCertificate( + intermediateCert: X509Certificate, + intermediateKeys: CryptoKeyPair, + partial?: Partial, + extensionChanges?: ExtensionChangeMap, +): Promise<[CryptoKeyPair, X509Certificate]> { + const leafKeys = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); + const extensions = await getLeafExtensions(leafKeys.publicKey, intermediateKeys.publicKey); + const leafCert = await X509CertificateGenerator.create( + { + serialNumber: '03', + subject: `C=NL, ST=Zuid-Holland, O=Dawn Technology, OU=Development, CN=Leaf`, + issuer: intermediateCert.subject, + signingKey: intermediateKeys.privateKey, + publicKey: leafKeys.publicKey, + signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' }, + extensions: applyExtensionChanges(extensions, extensionChanges), + ...partial, + }, + crypto, + ); + return [leafKeys, leafCert]; +} + +/** + * Applies targeted modifications to an extensions array, allowing tests to + * replace or remove individual extensions by index. + * + * - If the value for an index is an {@link Extension}, it **replaces** the + * extension at that position. + * - If the value is `undefined`, the extension at that position is **removed** + * (via `splice`). + * + * @param extensions - The original ordered array of extensions. + * @param changes - A map of `constructor.name → replacement | undefined`. + * @returns The mutated extensions array. + */ +function applyExtensionChanges(extensions: Extension[], changes?: ExtensionChangeMap): Extension[] { + if (changes) { + for (const [extensionName, replacement] of Object.entries(changes)) { + const index = extensions.findIndex((e: Extension) => e.constructor.name === extensionName); + if (index >= 0) { + if (replacement) { + extensions[index] = replacement; + } else { + extensions.splice(index, 1); + } + } + } + } + return extensions; +} + +describe('Certificate Chain Validation', () => { + let rootCert: X509Certificate; + let rootKeys: CryptoKeyPair; + let intermediateCert: X509Certificate; + let intermediateKeys: CryptoKeyPair; + let leafCert: X509Certificate; + let leafKeys: CryptoKeyPair; + let timestampProvider: LocalTimestampProvider; + let signer: LocalSigner; + + /** + * One-time setup that creates the default 3-level certificate hierarchy + * (root → intermediate → leaf), a {@link LocalTimestampProvider}, and a + * {@link LocalSigner}. Individual tests that need alternative certificates + * generate their own instances, but reuse these as a baseline. + */ + beforeAll(async () => { + // Generate the default certificate chain: root → intermediate → leaf + [rootKeys, rootCert] = await createRootCertificate(); + [intermediateKeys, intermediateCert] = await createIntermediateCertificate(rootCert, rootKeys); + [leafKeys, leafCert] = await createLeafCertificate(intermediateCert, intermediateKeys); + + // Create a timestamp provider backed by the leaf certificate + timestampProvider = new LocalTimestampProvider(leafCert, await toPkcs8Bytes(leafKeys.privateKey), [ + intermediateCert, + ]); + // Create a COSE signer backed by the leaf certificate (ES256 / P-256) + signer = new LocalSigner(await toPkcs8Bytes(leafKeys.privateKey), CoseAlgorithmIdentifier.ES256, leafCert, [ + intermediateCert, + ]); + }); + + describe('1. Basic Certificate Chain Structure', () => { + it('should accept valid 3-level chain (root → intermediate → leaf)', async () => { + // Happy-path: the default chain (leaf → intermediate → root) must validate + const [validationResult, label] = await getValidationResult(signer, timestampProvider); + + // check individual codes + assert.deepEqual(validationResult.statusEntries, getExpectedValidationStatusEntries(label)); + + // // check overall validity + assert.ok(validationResult.isValid, 'Validation result invalid'); + }); + + it('should accept valid 2-level chain (root → leaf)', async () => { + const [directLeafKeys, directLeafCert] = await createLeafCertificate(rootCert, rootKeys); // Create a leaf certificate directly signed by the root + // Create timestamp provider + const otherTimestampProvider = new LocalTimestampProvider( + directLeafCert, + await toPkcs8Bytes(directLeafKeys.privateKey), + [rootCert], + ); + // Create a signer + const otherSigner = new LocalSigner( + await toPkcs8Bytes(directLeafKeys.privateKey), + CoseAlgorithmIdentifier.ES256, + directLeafCert, + [rootCert], + ); + + const [validationResult, label] = await getValidationResult(otherSigner, otherTimestampProvider); + + // check individual codes + assert.deepEqual(validationResult.statusEntries, getExpectedValidationStatusEntries(label)); + + // // check overall validity + assert.ok(validationResult.isValid, 'Validation result invalid'); + }); + + it('should accept self-signed root certificate', async () => { + // Signing directly with the root CA (no leaf) is structurally invalid + // because the root lacks the required end-entity extensions. + const otherTimestampProvider = new LocalTimestampProvider( + rootCert, + await toPkcs8Bytes(rootKeys.privateKey), + ); + const otherSigner = new LocalSigner( + await toPkcs8Bytes(rootKeys.privateKey), + CoseAlgorithmIdentifier.ES256, + rootCert, + ); + + const [validationResult, label] = await getValidationResult(otherSigner, otherTimestampProvider); + + // Expect "SigningCredentialInvalid" because the root is not a valid end-entity + assert.deepEqual(validationResult.statusEntries, getExpectedValidationStatusEntriesInvalid(label)); + + assert.ok(!validationResult.isValid, 'Validation result should be invalid'); + }); + + it('should detect when intermediate certificate is missing', async () => { + // The signer uses the root certificate directly (no chain certs), + // while the timestamp provider includes the intermediate. The signing + // credential validation should fail because the root is not a valid leaf. + const otherTimestampProvider = new LocalTimestampProvider( + rootCert, + await toPkcs8Bytes(rootKeys.privateKey), + [intermediateCert], + ); + const otherSigner = new LocalSigner( + await toPkcs8Bytes(rootKeys.privateKey), + CoseAlgorithmIdentifier.ES256, + rootCert, + ); + + const [validationResult, label] = await getValidationResult(otherSigner, otherTimestampProvider); + + // check individual codes + assert.deepEqual(validationResult.statusEntries, getExpectedValidationStatusEntriesInvalid(label)); + + // // check overall validity + assert.ok(!validationResult.isValid, 'Validation result should be invalid'); + }); + }); + + describe('2. Certificate Signature Verification', () => { + it('should detect invalid signature', async () => { + const wrongRootKeys = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ + 'sign', + 'verify', + ]); + const wrongIntermediateCert = await X509CertificateGenerator.create( + { + serialNumber: '02', + subject: `C=NL, ST=Zuid-Holland, O=Dawn Technology, OU=Development, CN=Intermediate`, + issuer: rootCert.subject, + signingKey: wrongRootKeys.privateKey, + publicKey: intermediateKeys.publicKey, + signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' }, + extensions: [ + new BasicConstraintsExtension(false, 2, true), + new ExtendedKeyUsageExtension([ExtendedKeyUsage.emailProtection], true), + new KeyUsagesExtension(KeyUsageFlags.digitalSignature, true), + await SubjectKeyIdentifierExtension.create(intermediateKeys.publicKey, false), + await AuthorityKeyIdentifierExtension.create(wrongRootKeys.publicKey, false), + ], + }, + crypto, + ); + + // Create timestamp provider + const otherTimestampProvider = new LocalTimestampProvider( + leafCert, + await toPkcs8Bytes(leafKeys.privateKey), + [wrongIntermediateCert], + ); + // Create a signer + const otherSigner = new LocalSigner( + await toPkcs8Bytes(leafKeys.privateKey), + CoseAlgorithmIdentifier.ES256, + leafCert, + [wrongIntermediateCert], + ); + + const [validationResult, label] = await getValidationResult(otherSigner, otherTimestampProvider); + + // check individual codes + assert.deepEqual(validationResult.statusEntries, getExpectedValidationStatusEntriesUntrusted(label)); + + // // check overall validity + assert.ok(!validationResult.isValid, 'Validation result should be invalid'); + }); + + it('should deal with irrelevant certificates in the chain', async () => { + const anyKeys = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ + 'sign', + 'verify', + ]); + const anyCert = await X509CertificateGenerator.create( + { + serialNumber: '04', + subject: `C=NL, ST=Zuid-Holland, O=Dawn Technology, OU=Development, CN=Irrelevant`, + issuer: rootCert.subject, + signingKey: anyKeys.privateKey, + publicKey: anyKeys.publicKey, + signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' }, + }, + crypto, + ); + + // Create timestamp provider + const otherTimestampProvider = new LocalTimestampProvider( + leafCert, + await toPkcs8Bytes(leafKeys.privateKey), + [intermediateCert], + ); + // Create a signer + const otherSigner = new LocalSigner( + await toPkcs8Bytes(leafKeys.privateKey), + CoseAlgorithmIdentifier.ES256, + leafCert, + [intermediateCert, anyCert], + ); + + const [validationResult, label] = await getValidationResult(otherSigner, otherTimestampProvider); + + // check individual codes + assert.deepEqual(validationResult.statusEntries, getExpectedValidationStatusEntries(label)); + + // // check overall validity + assert.ok(validationResult.isValid, 'Validation result should be valid'); + }); + + // // TODO not working + // it('should deal with irrelevant timestamp certificates in the chain', async () => { + // const anyKeys = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ + // 'sign', + // 'verify', + // ]); + // const anyCert = await X509CertificateGenerator.create( + // { + // serialNumber: '04', + // subject: `C=NL, ST=Zuid-Holland, O=Dawn Technology, OU=Development, CN=Irrelevant`, + // issuer: rootCert.subject, + // signingKey: anyKeys.privateKey, + // publicKey: anyKeys.publicKey, + // signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' }, + // }, + // crypto, + // ); + + // // Create timestamp provider + // const otherTimestampProvider = new LocalTimestampProvider( + // leafCert, + // await toPkcs8Bytes(leafKeys.privateKey), + // [intermediateCert], + // ); + // // Create a signer + // const otherSigner = new LocalSigner( + // await toPkcs8Bytes(leafKeys.privateKey), + // CoseAlgorithmIdentifier.ES256, + // leafCert, + // [intermediateCert, anyCert], + // ); + + // const [validationResult, label] = await getValidationResult(otherSigner, otherTimestampProvider); + + // // check individual codes + // assert.deepEqual(validationResult.statusEntries, getExpectedValidationStatusEntries(label)); + + // // // check overall validity + // assert.ok(validationResult.isValid, 'Validation result should be valid'); + // }); + }); + + describe('3. Certificate Validity Period', () => { + it('should accept certificate within validity period', async () => { + // Generate test certificates + const [otherRootKeys, otherRootCert] = await createRootCertificate({ notBefore: new Date() }); + const [otherIntermediateKeys, otherIntermediateCert] = await createIntermediateCertificate( + otherRootCert, + otherRootKeys, + { notBefore: new Date() }, + ); + const [otherLeafKeys, otherLeafCert] = await createLeafCertificate( + otherIntermediateCert, + otherIntermediateKeys, + { notBefore: new Date() }, + ); + + // Create timestamp provider + const otherTimestampProvider = new LocalTimestampProvider( + otherLeafCert, + await toPkcs8Bytes(otherLeafKeys.privateKey), + [otherIntermediateCert], + ); + // Create a signer + const otherSigner = new LocalSigner( + await toPkcs8Bytes(otherLeafKeys.privateKey), + CoseAlgorithmIdentifier.ES256, + otherLeafCert, + [otherIntermediateCert], + ); + + const [validationResult, label] = await getValidationResult(otherSigner, otherTimestampProvider); + + // check individual codes + assert.deepEqual(validationResult.statusEntries, getExpectedValidationStatusEntries(label)); + + // // check overall validity + assert.ok(validationResult.isValid, 'Validation result should be valid'); + }); + + it('should detect expired leaf certificate', async () => { + const [otherLeafKeys, otherLeafCert] = await createLeafCertificate(intermediateCert, intermediateKeys, { + notAfter: new Date(Date.now() - 1000), + }); // expired 1 second ago + + // Create timestamp provider + const otherTimestampProvider = new LocalTimestampProvider( + otherLeafCert, + await toPkcs8Bytes(otherLeafKeys.privateKey), + [intermediateCert], + ); + // Create a signer + const otherSigner = new LocalSigner( + await toPkcs8Bytes(otherLeafKeys.privateKey), + CoseAlgorithmIdentifier.ES256, + otherLeafCert, + [intermediateCert], + ); + + const [validationResult, label] = await getValidationResult(otherSigner, otherTimestampProvider); + + // check individual codes + assert.deepEqual(validationResult.statusEntries, getExpectedValidationStatusEntriesWrongTimeStamp(label)); + + // // check overall validity + assert.ok(!validationResult.isValid, 'Validation result should be invalid'); + }); + + it('should detect not-yet-valid leaf certificate', async () => { + const [otherLeafKeys, otherLeafCert] = await createLeafCertificate(intermediateCert, intermediateKeys, { + notBefore: new Date(Date.now() + 1000), // not valid yet + }); + + // Create timestamp provider + const otherTimestampProvider = new LocalTimestampProvider( + otherLeafCert, + await toPkcs8Bytes(otherLeafKeys.privateKey), + [intermediateCert], + ); + // Create a signer + const otherSigner = new LocalSigner( + await toPkcs8Bytes(otherLeafKeys.privateKey), + CoseAlgorithmIdentifier.ES256, + otherLeafCert, + [intermediateCert], + ); + + const [validationResult, label] = await getValidationResult(otherSigner, otherTimestampProvider); + + // check individual codes + assert.deepEqual(validationResult.statusEntries, getExpectedValidationStatusEntriesWrongTimeStamp(label)); + + // // check overall validity + assert.ok(!validationResult.isValid, 'Validation result should be invalid'); + }); + + it('should detect expired intermediate certificate', async () => { + // TODO Not working for the TSA certificate + + // Create a new intermediate certificate that is expired + const [otherIntermediateKeys, otherIntermediateCert] = await createIntermediateCertificate( + rootCert, + rootKeys, + { + notAfter: new Date(Date.now() - 1000), // expired 1 second ago + }, + ); + const [otherLeafKeys, otherLeafCert] = await createLeafCertificate( + otherIntermediateCert, + otherIntermediateKeys, + ); + + // Create a signer + const otherSigner = new LocalSigner( + await toPkcs8Bytes(otherLeafKeys.privateKey), + CoseAlgorithmIdentifier.ES256, + otherLeafCert, + [otherIntermediateCert], + ); + + const [validationResult, label] = await getValidationResult(otherSigner, timestampProvider); + + // check individual codes + assert.deepEqual(validationResult.statusEntries, getExpectedValidationStatusEntriesUntrusted(label)); + + // // check overall validity + assert.ok(!validationResult.isValid, 'Validation result should be invalid'); + }); + + it('should detect not-yet-valid intermediate certificate', async () => { + // TODO Not working for the TSA certificate + + // Create a new intermediate certificate that is not valid yet + const [otherIntermediateKeys, otherIntermediateCert] = await createIntermediateCertificate( + rootCert, + rootKeys, + { + notBefore: new Date(Date.now() + 1000), // not valid yet + }, + ); + const [otherLeafKeys, otherLeafCert] = await createLeafCertificate( + otherIntermediateCert, + otherIntermediateKeys, + ); + + // Create a signer + const otherSigner = new LocalSigner( + await toPkcs8Bytes(otherLeafKeys.privateKey), + CoseAlgorithmIdentifier.ES256, + otherLeafCert, + [otherIntermediateCert], + ); + + const [validationResult, label] = await getValidationResult(otherSigner, timestampProvider); + + // check individual codes + assert.deepEqual(validationResult.statusEntries, getExpectedValidationStatusEntriesUntrusted(label)); + + // // check overall validity + assert.ok(!validationResult.isValid, 'Validation result should be invalid'); + }); + + it('should detect expired root certificate', async () => { + // TODO Not working for the TSA certificate + + // Create a new root certificate that is expired + const [otherRootKeys, otherRootCert] = await createRootCertificate({ + notAfter: new Date(Date.now() - 1000), // expired 1 second ago + }); + const [otherIntermediateKeys, otherIntermediateCert] = await createIntermediateCertificate( + otherRootCert, + otherRootKeys, + ); + const [otherLeafKeys, otherLeafCert] = await createLeafCertificate( + otherIntermediateCert, + otherIntermediateKeys, + ); + + // Create a signer + const otherSigner = new LocalSigner( + await toPkcs8Bytes(otherLeafKeys.privateKey), + CoseAlgorithmIdentifier.ES256, + otherLeafCert, + [otherIntermediateCert], + ); + + const [validationResult, label] = await getValidationResult(otherSigner, timestampProvider); + + // check individual codes + assert.deepEqual(validationResult.statusEntries, getExpectedValidationStatusEntriesUntrusted(label)); + + // // check overall validity + assert.ok(!validationResult.isValid, 'Validation result should be invalid'); + }); + + it('should detect not-yet-valid root certificate', async () => { + // TODO Not working for the TSA certificate + + // Create a new root certificate that is not valid yet + const [otherRootKeys, otherRootCert] = await createRootCertificate({ + notBefore: new Date(Date.now() + 1000), // not valid yet + }); + const [otherIntermediateKeys, otherIntermediateCert] = await createIntermediateCertificate( + otherRootCert, + otherRootKeys, + ); + const [otherLeafKeys, otherLeafCert] = await createLeafCertificate( + otherIntermediateCert, + otherIntermediateKeys, + ); + + // Create a signer + const otherSigner = new LocalSigner( + await toPkcs8Bytes(otherLeafKeys.privateKey), + CoseAlgorithmIdentifier.ES256, + otherLeafCert, + [otherIntermediateCert], + ); + + const [validationResult, label] = await getValidationResult(otherSigner, timestampProvider); + + // check individual codes + assert.deepEqual(validationResult.statusEntries, getExpectedValidationStatusEntriesUntrusted(label)); + + // // check overall validity + assert.ok(!validationResult.isValid, 'Validation result should be invalid'); + }); + }); + + describe('4. Key Usage Extensions', () => { + it('certificates used to sign C2PA manifests shall assert the digitalSignature bit for the intermediate', async () => { + const [otherIntermediateKeys, otherIntermediateCert] = await createIntermediateCertificate( + rootCert, + rootKeys, + undefined, + { + [ExtensionClassNames.KeyUsagesExtension]: new KeyUsagesExtension( + KeyUsageFlags.keyCertSign + KeyUsageFlags.cRLSign, + true, + ), + }, + ); + const [otherLeafKeys, otherLeafCert] = await createLeafCertificate( + otherIntermediateCert, + otherIntermediateKeys, + ); + + // Create a signer + const otherSigner = new LocalSigner( + await toPkcs8Bytes(otherLeafKeys.privateKey), + CoseAlgorithmIdentifier.ES256, + otherLeafCert, + [otherIntermediateCert], + ); + + const [validationResult, label] = await getValidationResult(otherSigner, timestampProvider); + + // check individual codes + assert.deepEqual(validationResult.statusEntries, getExpectedValidationStatusEntriesUntrusted(label)); + + // // check overall validity + assert.ok(!validationResult.isValid, 'Validation result should be invalid'); + }); + + it('should have critical flag on KeyUsage extension for the root', async () => { + const [otherRootKeys, otherRootCert] = await createRootCertificate(undefined, { + [ExtensionClassNames.KeyUsagesExtension]: new KeyUsagesExtension( + KeyUsageFlags.digitalSignature + KeyUsageFlags.keyCertSign, + false, + ), + }); + + const [otherIntermediateKeys, otherIntermediateCert] = await createIntermediateCertificate( + otherRootCert, + otherRootKeys, + ); + const [otherLeafKeys, otherLeafCert] = await createLeafCertificate( + otherIntermediateCert, + otherIntermediateKeys, + ); + + // Create a signer + const otherSigner = new LocalSigner( + await toPkcs8Bytes(otherLeafKeys.privateKey), + CoseAlgorithmIdentifier.ES256, + otherLeafCert, + [otherIntermediateCert], + ); + + const [validationResult, label] = await getValidationResult(otherSigner, timestampProvider); + + // check individual codes + assert.deepEqual(validationResult.statusEntries, getExpectedValidationStatusEntriesUntrusted(label)); + + // // check overall validity + assert.ok(!validationResult.isValid, 'Validation result should be invalid'); + }); + }); + + describe('5. Basic Constraints', () => { + it('should have a Basic Constraints Extension in intermediate certificates', async () => { + const [otherIntermediateKeys, otherIntermediateCert] = await createIntermediateCertificate( + rootCert, + rootKeys, + undefined, + { [ExtensionClassNames.BasicConstraintsExtension]: undefined }, + ); + const [otherLeafKeys, otherLeafCert] = await createLeafCertificate( + otherIntermediateCert, + otherIntermediateKeys, + ); + + // Create a signer + const otherSigner = new LocalSigner( + await toPkcs8Bytes(otherLeafKeys.privateKey), + CoseAlgorithmIdentifier.ES256, + otherLeafCert, + [otherIntermediateCert], + ); + + const [validationResult, label] = await getValidationResult(otherSigner, timestampProvider); + + // check individual codes + assert.deepEqual(validationResult.statusEntries, getExpectedValidationStatusEntriesUntrusted(label)); + + // // check overall validity + assert.ok(!validationResult.isValid, 'Validation result should be invalid'); + }); + }); + + describe('6. Subject Key Identifier Extension', () => { + it('root should have a subject key identifier', async () => { + const [otherRootKeys, otherRootCert] = await createRootCertificate(undefined, { + [ExtensionClassNames.SubjectKeyIdentifierExtension]: undefined, + }); + const [otherIntermediateKeys, otherIntermediateCert] = await createIntermediateCertificate( + otherRootCert, + otherRootKeys, + ); + const [otherLeafKeys, otherLeafCert] = await createLeafCertificate( + otherIntermediateCert, + otherIntermediateKeys, + ); + + // Create a signer + const otherSigner = new LocalSigner( + await toPkcs8Bytes(otherLeafKeys.privateKey), + CoseAlgorithmIdentifier.ES256, + otherLeafCert, + [otherIntermediateCert], + ); + + const [validationResult, label] = await getValidationResult(otherSigner, timestampProvider); + + // check individual codes + assert.deepEqual(validationResult.statusEntries, getExpectedValidationStatusEntriesUntrusted(label)); + + // // check overall validity + assert.ok(!validationResult.isValid, 'Validation result should be invalid'); + }); + + it('intermediate should have a subject key identifier', async () => { + const [otherIntermediateKeys, otherIntermediateCert] = await createIntermediateCertificate( + rootCert, + rootKeys, + undefined, + { [ExtensionClassNames.SubjectKeyIdentifierExtension]: undefined }, + ); + const [otherLeafKeys, otherLeafCert] = await createLeafCertificate( + otherIntermediateCert, + otherIntermediateKeys, + ); + + // Create a signer + const otherSigner = new LocalSigner( + await toPkcs8Bytes(otherLeafKeys.privateKey), + CoseAlgorithmIdentifier.ES256, + otherLeafCert, + [otherIntermediateCert], + ); + + const [validationResult, label] = await getValidationResult(otherSigner, timestampProvider); + + // check individual codes + assert.deepEqual(validationResult.statusEntries, getExpectedValidationStatusEntriesUntrusted(label)); + + // // check overall validity + assert.ok(!validationResult.isValid, 'Validation result should be invalid'); + }); + }); + + describe('7. Authority Key Identifier Extension', () => { + it('intermediate should have an authority key identifier', async () => { + const [otherIntermediateKeys, otherIntermediateCert] = await createIntermediateCertificate( + rootCert, + rootKeys, + undefined, + { [ExtensionClassNames.AuthorityKeyIdentifierExtension]: undefined }, + ); + const [otherLeafKeys, otherLeafCert] = await createLeafCertificate( + otherIntermediateCert, + otherIntermediateKeys, + ); + + // Create a signer + const otherSigner = new LocalSigner( + await toPkcs8Bytes(otherLeafKeys.privateKey), + CoseAlgorithmIdentifier.ES256, + otherLeafCert, + [otherIntermediateCert], + ); + + const [validationResult, label] = await getValidationResult(otherSigner, timestampProvider); + + // check individual codes + assert.deepEqual(validationResult.statusEntries, getExpectedValidationStatusEntriesUntrusted(label)); + + // // check overall validity + assert.ok(!validationResult.isValid, 'Validation result should be invalid'); + }); + }); + + describe('8. Error Handling', () => { + it('should handle malformed certificate data', () => { + expect(async () => { + new LocalSigner(await toPkcs8Bytes(leafKeys.privateKey), CoseAlgorithmIdentifier.ES256, leafCert, [ + intermediateCert, + new X509Certificate(new Uint8Array([1, 2, 3, 4])), + ]); + }).toThrow(); + }); + + it('should handle empty certificate data', async () => { + expect(async () => { + new LocalSigner(await toPkcs8Bytes(leafKeys.privateKey), CoseAlgorithmIdentifier.ES256, leafCert, [ + intermediateCert, + new X509Certificate(new Uint8Array([])), + ]); + }).toThrow(); + }); + }); + + /** + * Section 9 – Loop Detection + * + * Certificate chains must be acyclic. This test constructs two + * intermediates that mutually sign each other (A → B → A) to confirm + * the validator does not enter an infinite loop and correctly rejects + * the chain as untrusted. + */ + describe('9. Loop Detection', () => { + it('should detect circular certificate references', async () => { + // If a certificate chain contains a loop (A → B → C → B) + // This should be detected + const otherIntermediateKeys = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign', 'verify'], + ); + const circularIntermediateKeys = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign', 'verify'], + ); + const otherIntermediateCert = await X509CertificateGenerator.create( + { + serialNumber: '11', + subject: `C=NL, ST=Zuid-Holland, O=Dawn Technology, OU=Development, CN=OtherIntermediate`, + issuer: `C=NL, ST=Zuid-Holland, O=Dawn Technology, OU=Development, CN=CircularIntermediate`, + signingKey: circularIntermediateKeys.privateKey, + publicKey: intermediateKeys.publicKey, + signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' }, + extensions: await getIntermediateExtensions( + otherIntermediateKeys.publicKey, + circularIntermediateKeys.publicKey, + ), + }, + crypto, + ); + const circularIntermediateCert = await X509CertificateGenerator.create( + { + serialNumber: '12', + subject: `C=NL, ST=Zuid-Holland, O=Dawn Technology, OU=Development, CN=CircularIntermediate`, + issuer: `C=NL, ST=Zuid-Holland, O=Dawn Technology, OU=Development, CN=OtherIntermediate`, + signingKey: otherIntermediateKeys.privateKey, + publicKey: circularIntermediateKeys.publicKey, + signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' }, + extensions: await getIntermediateExtensions( + circularIntermediateKeys.publicKey, + otherIntermediateKeys.publicKey, + ), + }, + crypto, + ); + const [otherLeafKeys, otherLeafCert] = await createLeafCertificate( + otherIntermediateCert, + otherIntermediateKeys, + ); + + // Create a signer + const otherSigner = new LocalSigner( + await toPkcs8Bytes(otherLeafKeys.privateKey), + CoseAlgorithmIdentifier.ES256, + otherLeafCert, + [otherIntermediateCert, circularIntermediateCert], + ); + + const [validationResult, label] = await getValidationResult(otherSigner, timestampProvider); + + // check individual codes + assert.deepEqual(validationResult.statusEntries, getExpectedValidationStatusEntriesUntrusted(label)); + + // // check overall validity + assert.ok(!validationResult.isValid, 'Validation result should be invalid'); + }); + }); + + describe('10. Subject/Issuer Matching', () => { + it("should not validate if the AKI does not match the issuer's SKI", async () => { + const misMatchKeys = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ + 'sign', + 'verify', + ]); + + const otherLeafKeys = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ + 'sign', + 'verify', + ]); + const otherLeafCert = await X509CertificateGenerator.create( + { + serialNumber: '03', + subject: `C=NL, ST=Zuid-Holland, O=Dawn Technology, OU=Development, CN=Leaf`, + issuer: intermediateCert.subject, + signingKey: intermediateKeys.privateKey, + publicKey: otherLeafKeys.publicKey, + signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' }, + extensions: await getLeafExtensions(otherLeafKeys.publicKey, misMatchKeys.publicKey), + }, + crypto, + ); + + // Create a signer + const otherSigner = new LocalSigner( + await toPkcs8Bytes(otherLeafKeys.privateKey), + CoseAlgorithmIdentifier.ES256, + otherLeafCert, + [intermediateCert], + ); + + const [validationResult, label] = await getValidationResult(otherSigner, timestampProvider); + + // check individual codes + assert.deepEqual(validationResult.statusEntries, getExpectedValidationStatusEntriesUntrusted(label)); + + // // check overall validity + assert.ok(!validationResult.isValid, 'Validation result should be invalid'); + }); + }); +}); diff --git a/tests/fixtures/trust-list-wrong.pem b/tests/fixtures/trust-list-wrong.pem new file mode 100644 index 00000000..c47260dd --- /dev/null +++ b/tests/fixtures/trust-list-wrong.pem @@ -0,0 +1,88 @@ +Subject C=US, ST=CA, L=Somewhere, O=C2PA Test Root CA, OU=FOR TESTING_ONLY, CN=Root CA +-----BEGIN CERTIFICATE----- +MIIGsDCCBGSgAwIBAgIUfj5imtzP59mXEBNbWkgFaXLfgZkwQQYJKoZIhvcNAQEK +MDSgDzANBglghkgBZQMEAgEFAKEcMBoGCSqGSIb3DQEBCDANBglghkgBZQMEAgEF +AKIDAgEgMIGMMQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExEjAQBgNVBAcMCVNv +bWV3aGVyZTEnMCUGA1UECgweQzJQQSBUZXN0IEludGVybWVkaWF0ZSBSb290IENB +MRkwFwYDVQQLDBBGT1IgVEVTVElOR19PTkxZMRgwFgYDVQQDDA9JbnRlcm1lZGlh +dGUgQ0EwHhcNMjIwNjEwMTg0NjI4WhcNMzAwODI2MTg0NjI4WjCBgDELMAkGA1UE +BhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUxHzAdBgNVBAoM +FkMyUEEgVGVzdCBTaWduaW5nIENlcnQxGTAXBgNVBAsMEEZPUiBURVNUSU5HX09O +TFkxFDASBgNVBAMMC0MyUEEgU2lnbmVyMIICVjBBBgkqhkiG9w0BAQowNKAPMA0G +CWCGSAFlAwQCAQUAoRwwGgYJKoZIhvcNAQEIMA0GCWCGSAFlAwQCAQUAogMCASAD +ggIPADCCAgoCggIBAOtiNSWBpKkHL78khDYV2HTYkVUmTu5dgn20GiUjOjWhAyWK +5uZL+iuHWmHUOq0xqC39R+hyaMkcIAUf/XcJRK40Jh1s2kJ4+kCk7+RB1n1xeZeJ +jrKhJ7zCDhH6eFVqO9Om3phcpZyKt01yDkhfIP95GzCILuPm5lLKYI3P0FmpC8zl +5ctevgG1TXJcX8bNU6fsHmmw0rBrVXUOR+N1MOFO/h++mxIhhLW601XrgYu6lDQD +IDOc/IxwzEp8+SAzL3v6NStBEYIq2d+alUgEUAOM8EzZsungs0dovMPGcfw7COsG +4xrdmLHExRau4E1g1ANfh2QsYdraNMtS/wcpI1PG6BkqUQ4zlMoO/CI2nZ5oninb +uL9x/UJt+a6VvHA0e4bTIcJJVq3/t69mpZtNe6WqDfGU+KLZ5HJSBNSW9KyWxSAU +FuDFAMtKZRZmTBonKHSjYlYtT+/WN7n/LgFJ2EYxPeFcGGPrVqRTw38g0QA8cyFe +wHfQBZUiSKdvMRB1zmIj+9nmYsh8ganJzuPaUgsGNVKoOJZHq+Ya3ewBjwslR91k +QtEGq43PRCvx4Vf+qiXeMCzK+L1Gg0v+jt80grz+y8Ch5/EkxitaH/ei/HRJGyvD +Zu7vrV6fbWLfWysBoFStHWirQcocYDGsFm9hh7bwM+W0qvNB/hbRQ0xfrMI9AgMB +AAGjeDB2MAwGA1UdEwEB/wQCMAAwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwQwDgYD +VR0PAQH/BAQDAgbAMB0GA1UdDgQWBBQ3KHUtnyxDJcV9ncAu37sql3aF7jAfBgNV +HSMEGDAWgBQMMoDK5ZZtTx/7+QsB1qnlDNwA4jBBBgkqhkiG9w0BAQowNKAPMA0G +CWCGSAFlAwQCAQUAoRwwGgYJKoZIhvcNAQEIMA0GCWCGSAFlAwQCAQUAogMCASAD +ggIBAAmBZubOjnCXIYmg2l1pDYH+XIyp5feayZz6Nhgz6xB7CouNgvcjkYW7EaqN +RuEkAJWJC68OnjMwwe6tXWQC4ifMKbVg8aj/IRaVAqkEL/MRQ89LnL9F9AGxeugJ +ulYtpqzFOJUKCPxcXGEoPyqjY7uMdTS14JzluKUwtiQZAm4tcwh/ZdRkt69i3wRq +VxIY2TK0ncvr4N9cX1ylO6m+GxufseFSO0NwEMxjonJcvsxFwjB8eFUhE0yH3pdD +gqE2zYfv9kjYkFGngtOqbCe2ixRM5oj9qoS+aKVdOi9m/gObcJkSW9JYAJD2GHLO +yLpGWRhg4xnn1s7n2W9pWB7+txNR7aqkrUNhZQdznNVdWRGOale4uHJRSPZAetQT +oYoVAyIX1ba1L/GRo52mOOT67AJhmIVVJJFVvMvvJeQ8ktW8GlxYjG9HHbRpE0S1 +Hv7FhOg0vEAqyrKcYn5JWYGAvEr0VqUqBPz3/QZ8gbmJwXinnUku1QZbGZUIFFIS +3MDaPXMWmp2KuNMxJXHE1CfaiD7yn2plMV5QZakde3+Kfo6qv2GISK+WYhnGZAY/ +LxtEOqwVrQpDQVJ5jgR/RKPIsOobdboR/aTVjlp7OOfvLxFUvD66zOiVa96fAsfw +ltU2Cp0uWdQKSLoktmQWLYgEe3QOqvgLDeYP2ScAdm+S+lHV +-----END CERTIFICATE----- + +Subject C=US, ST=Washington, L=Seattle, O=Amazon Web Services\, Inc., OU=Amazon Bedrock, CN=Amazon Web Services\, Inc. +-----BEGIN CERTIFICATE----- +MIIE4jCCA8qgAwIBAgIQDoTxo1cQcFRgZuCjsA1tYjANBgkqhkiG9w0BAQwFADBm +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSUwIwYDVQQDExxEaWdpQ2VydCBEb2N1bWVudCBTaWdu +aW5nIENBMB4XDTI0MDgxNDAwMDAwMFoXDTI1MDgxNDIzNTk1OVowgZUxCzAJBgNV +BAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdTZWF0dGxlMSIw +IAYDVQQKExlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRcwFQYDVQQLEw5BbWF6 +b24gQmVkcm9jazEiMCAGA1UEAxMZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjB2 +MBAGByqGSM49AgEGBSuBBAAiA2IABEKUt+neiVgWfp5cqjhkwUXwKOkU+mSWU6sa +2YEl9/DfL/0JlPOR9ntsUmsg679LtVPYcEtqqqgIOjJ9redxuAo90VLM01xWT+nk +qUUdtHpBVPRODS3ZrBpECNavldmvQ6OCAggwggIEMB8GA1UdIwQYMBaAFO/ONZPO +9obF+IT1DOdab9kvS+NkMB0GA1UdDgQWBBRkOTg3sqb/0k1bHUE+AqFxzLmpqDAW +BgNVHSAEDzANMAsGCWCGSAGG/WwDFTAOBgNVHQ8BAf8EBAMCBsAwHQYDVR0lBBYw +FAYIKwYBBQUHAwIGCCsGAQUFBwMEMIGNBgNVHR8EgYUwgYIwP6A9oDuGOWh0dHA6 +Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydERvY3VtZW50U2lnbmluZ0NBLWcx +LmNybDA/oD2gO4Y5aHR0cDovL2NybDQuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0RG9j +dW1lbnRTaWduaW5nQ0EtZzEuY3JsMHsGCCsGAQUFBwEBBG8wbTAkBggrBgEFBQcw +AYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEUGCCsGAQUFBzAChjlodHRwOi8v +Y2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnREb2N1bWVudFNpZ25pbmdDQS5j +cnQwEwYKKoZIhvcvAQEJAgQFMAMCAQEwWQYKKoZIhvcvAQEJAQRLMEkCAQGGRGh0 +dHA6Ly9hZG9iZS50aW1lc3RhbXAuZGlnaWNlcnQuY29tLzBFODRGMUEzNTcxMDcw +NTQ2MDY2RTBBM0IwMEQ2RDYyMA0GCSqGSIb3DQEBDAUAA4IBAQBqDwAfeE6JQfbs +S/6hwSNN5i2SWYg7z8W7KV2wxVyP9oSxVALvV2r9Eaomn+JHs8ZDi/667gvUDRzX +BRusP3pSOIyVT5tzMk3oGSzZUM16bM4o64FebC1MRpNz2u8yDYhxy9g9QXVCpjTk +WT7hDe7xqsn4aNulNJeS9K4hED4nksTbyYZ6Ef6O9mTgpcwoP5DlVAOH4uYzGZrT +Ixa/eUI8b4JlDCoip+ifl8Kf+qDYiukGa8fYqPQfhY5Od1e8i0u6lcnTZ8gACLZc +gM3ZQIfMugdhMaijqWvdqteMB4rl02cR9mvTQZAQRSdOHbfZfFxtHQ/HpDT737k2 +faRY7r6c +-----END CERTIFICATE----- + +Subject C=US, ST=CA, L=Somewhere, O=C2PA Test Signing Cert, OU=FOR TESTING_ONLY, CN=C2PA Signer +-----BEGIN CERTIFICATE----- +MIIChzCCAi6gAwIBAgIUcCTmJHYF8dZfG0d1UdT6/LXtkeYwCgYIKoZIzj0EAwIw +gYwxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdoZXJl +MScwJQYDVQQKDB5DMlBBIFRlc3QgSW50ZXJtZWRpYXRlIFJvb3QgQ0ExGTAXBgNV +BAsMEEZPUiBURVNUSU5HX09OTFkxGDAWBgNVBAMMD0ludGVybWVkaWF0ZSBDQTAe +Fw0yMjA2MTAxODQ2NDBaFw0zMDA4MjYxODQ2NDBaMIGAMQswCQYDVQQGEwJVUzEL +MAkGA1UECAwCQ0ExEjAQBgNVBAcMCVNvbWV3aGVyZTEfMB0GA1UECgwWQzJQQSBU +ZXN0IFNpZ25pbmcgQ2VydDEZMBcGA1UECwwQRk9SIFRFU1RJTkdfT05MWTEUMBIG +A1UEAwwLQzJQQSBTaWduZXIwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQPaL6R +kAkYkKU4+IryBSYxJM3h77sFiMrbvbI8fG7w2Bbl9otNG/cch3DAw5rGAPV7NWky +l3QGuV/wt0MrAPDoo3gwdjAMBgNVHRMBAf8EAjAAMBYGA1UdJQEB/wQMMAoGCCsG +AQUFBwMEMA4GA1UdDwEB/wQEAwIGwDAdBgNVHQ4EFgQUFznP0y83joiNOCedQkxT +tAMyNcowHwYDVR0jBBgwFoAUDnyNcma/osnlAJTvtW6A4rYOL2swCgYIKoZIzj0E +AwIDRwAwRAIgOY/2szXjslg/MyJFZ2y7OH8giPYTsvS7UPRP9GI9NgICIDQPMKrE +LQUJEtipZ0TqvI/4mieoyRCeIiQtyuS0LACz +-----END CERTIFICATE----- \ No newline at end of file diff --git a/tests/fixtures/trust-list.pem b/tests/fixtures/trust-list.pem new file mode 100644 index 00000000..4e8b0670 --- /dev/null +++ b/tests/fixtures/trust-list.pem @@ -0,0 +1,241 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA62I1JYGkqQcvvySENhXY +dNiRVSZO7l2CfbQaJSM6NaEDJYrm5kv6K4daYdQ6rTGoLf1H6HJoyRwgBR/9dwlE +rjQmHWzaQnj6QKTv5EHWfXF5l4mOsqEnvMIOEfp4VWo706bemFylnIq3TXIOSF8g +/3kbMIgu4+bmUspgjc/QWakLzOXly16+AbVNclxfxs1Tp+weabDSsGtVdQ5H43Uw +4U7+H76bEiGEtbrTVeuBi7qUNAMgM5z8jHDMSnz5IDMve/o1K0ERgirZ35qVSARQ +A4zwTNmy6eCzR2i8w8Zx/DsI6wbjGt2YscTFFq7gTWDUA1+HZCxh2to0y1L/Bykj +U8boGSpRDjOUyg78IjadnmieKdu4v3H9Qm35rpW8cDR7htMhwklWrf+3r2alm017 +paoN8ZT4otnkclIE1Jb0rJbFIBQW4MUAy0plFmZMGicodKNiVi1P79Y3uf8uAUnY +RjE94VwYY+tWpFPDfyDRADxzIV7Ad9AFlSJIp28xEHXOYiP72eZiyHyBqcnO49pS +CwY1Uqg4lker5hrd7AGPCyVH3WRC0Qarjc9EK/HhV/6qJd4wLMr4vUaDS/6O3zSC +vP7LwKHn8STGK1of96L8dEkbK8Nm7u+tXp9tYt9bKwGgVK0daKtByhxgMawWb2GH +tvAz5bSq80H+FtFDTF+swj0CAwEAAQ== +-----END PUBLIC KEY----- + +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAED2i+kZAJGJClOPiK8gUmMSTN4e+7 +BYjK272yPHxu8NgW5faLTRv3HIdwwMOaxgD1ezVpMpd0Brlf8LdDKwDw6A== +-----END PUBLIC KEY----- + +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAMp5+0e83nNgQhdhBW8Rshkjy90sa1A9JIzkItcDqCuI= +-----END PUBLIC KEY----- + +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE3Gu2j8b6K4pwTZgjILQO+At8TFvs +ojXaCJt/SsdguqLSkDXOsLdRM8LvT0YGEMcaSEXM+7vLsXOj4jOxazR/hA== +-----END PUBLIC KEY----- + +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEXEfQ50yTq8YluTWHPT1ceYpOlXRY +WsNLGo6EFtATq2D8GKyCm3GljmUcGE1bdGsZAhl28TK5pLPD9RcOKpY+Pw== +-----END PUBLIC KEY----- + +-----BEGIN PUBLIC KEY----- +MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEQpS36d6JWBZ+nlyqOGTBRfAo6RT6ZJZT +qxrZgSX38N8v/QmU85H2e2xSayDrv0u1U9hwS2qqqAg6Mn2t53G4Cj3RUszTXFZP +6eSpRR20ekFU9E4NLdmsGkQI1q+V2a9D +-----END PUBLIC KEY----- + +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAoYzyrOI5FNRYsHPmWV82 +UM2sMr1jrqE3H9Q5+tFQtapUk5e/yiLC63EifnR1SvM/vX/6Ze/ZLSucBx4tMLsm +scOX17PMypBOSaq8FN0GBU7Icvo/8nPzXwt8LrxVAH4RDiSgNMQa4Fg/l4pSh/qu +QuIJ0BcIvMy2pd4gecV59kQ4l0Kz1PQg1UqhVVfXfR6XETKLPLs+vbJxBverkQVh +wH322kr77tOhC67pFCOguogNZe2eb+8Kas2gQJQGmuhuKQoTWqBDwmnLutmQk8PV +cA51Kqve1P3k4pyOnKHHxKNtn6eDBYkKdLwMa+OpVlU1B41+3w0GWivhDyxQklMg +csQyHrDsDm65K4y+nEVH6JxxdxvpoSBT//WKNIc2qwtPTqPAaKRXnrLyprtrzQao +Ral3yEmxt1RVCeN2FrsmOCBrGrukIchMJCsEMgYSyK9AxXa54lLIyaugL78rKdeU +NdeZW6Tj3Drx9H0bzAeRjBlm+sixooVT2wSZ7UIO3rX2H2E7cF70Ig0FFeDtNwCJ +10eqscQyHohgBiybmSesg0la7QrKRsZcjqHZ5OujaxkUC0VWMyJk7RHHVphjdvBv +CwxmQRx5oHsrxjJh8QUP47OXm7cgRFfHs55OUXgUC8J7estf6hKRGDgaYwz2tTT5 +DZFxng2pBDjhrzXOYVXPrQ0CAwEAAQ== +-----END PUBLIC KEY----- + +Subject C=US, ST=CA, L=Somewhere, O=C2PA Test Root CA, OU=FOR TESTING_ONLY, CN=Root CA +-----BEGIN CERTIFICATE----- +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 +-----END CERTIFICATE----- + +Subject C=US, ST=CA, L=Somewhere, O=C2PA Test Signing Cert, OU=FOR TESTING_ONLY, CN=C2PA Signer +-----BEGIN CERTIFICATE----- +MIIChzCCAi6gAwIBAgIUcCTmJHYF8dZfG0d1UdT6/LXtkeYwCgYIKoZIzj0EAwIw +gYwxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTESMBAGA1UEBwwJU29tZXdoZXJl +MScwJQYDVQQKDB5DMlBBIFRlc3QgSW50ZXJtZWRpYXRlIFJvb3QgQ0ExGTAXBgNV +BAsMEEZPUiBURVNUSU5HX09OTFkxGDAWBgNVBAMMD0ludGVybWVkaWF0ZSBDQTAe +Fw0yMjA2MTAxODQ2NDBaFw0zMDA4MjYxODQ2NDBaMIGAMQswCQYDVQQGEwJVUzEL +MAkGA1UECAwCQ0ExEjAQBgNVBAcMCVNvbWV3aGVyZTEfMB0GA1UECgwWQzJQQSBU +ZXN0IFNpZ25pbmcgQ2VydDEZMBcGA1UECwwQRk9SIFRFU1RJTkdfT05MWTEUMBIG +A1UEAwwLQzJQQSBTaWduZXIwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQPaL6R +kAkYkKU4+IryBSYxJM3h77sFiMrbvbI8fG7w2Bbl9otNG/cch3DAw5rGAPV7NWky +l3QGuV/wt0MrAPDoo3gwdjAMBgNVHRMBAf8EAjAAMBYGA1UdJQEB/wQMMAoGCCsG +AQUFBwMEMA4GA1UdDwEB/wQEAwIGwDAdBgNVHQ4EFgQUFznP0y83joiNOCedQkxT +tAMyNcowHwYDVR0jBBgwFoAUDnyNcma/osnlAJTvtW6A4rYOL2swCgYIKoZIzj0E +AwIDRwAwRAIgOY/2szXjslg/MyJFZ2y7OH8giPYTsvS7UPRP9GI9NgICIDQPMKrE +LQUJEtipZ0TqvI/4mieoyRCeIiQtyuS0LACz +-----END CERTIFICATE----- + +Subject C=US, ST=CA, L=Somewhere, O=C2PA Test Signing Cert, OU=FOR TESTING_ONLY, CN=C2PA Signer +-----BEGIN CERTIFICATE----- +MIICSDCCAfqgAwIBAgIUb+aBTX1CsjJ1iuMJ9kRudz/7qEcwBQYDK2VwMIGMMQsw +CQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExEjAQBgNVBAcMCVNvbWV3aGVyZTEnMCUG +A1UECgweQzJQQSBUZXN0IEludGVybWVkaWF0ZSBSb290IENBMRkwFwYDVQQLDBBG +T1IgVEVTVElOR19PTkxZMRgwFgYDVQQDDA9JbnRlcm1lZGlhdGUgQ0EwHhcNMjIw +NjEwMTg0NjQxWhcNMzAwODI2MTg0NjQxWjCBgDELMAkGA1UEBhMCVVMxCzAJBgNV +BAgMAkNBMRIwEAYDVQQHDAlTb21ld2hlcmUxHzAdBgNVBAoMFkMyUEEgVGVzdCBT +aWduaW5nIENlcnQxGTAXBgNVBAsMEEZPUiBURVNUSU5HX09OTFkxFDASBgNVBAMM +C0MyUEEgU2lnbmVyMCowBQYDK2VwAyEAMp5+0e83nNgQhdhBW8Rshkjy90sa1A9J +IzkItcDqCuKjeDB2MAwGA1UdEwEB/wQCMAAwFgYDVR0lAQH/BAwwCgYIKwYBBQUH +AwQwDgYDVR0PAQH/BAQDAgbAMB0GA1UdDgQWBBTuLrYRqW4wu6yjIK1/iW8ud7dm +kTAfBgNVHSMEGDAWgBRXTAfC/JxQvRlk/bCbdPMDbsSfqTAFBgMrZXADQQB2R6vb +I+X8CTRC54j3NTvsUj454G1/bdzbiHVgl3n+ShOAJ85FJigE7Eoav7SeXeVnNjc8 +QZ1UrJGwgBBEP84G +-----END CERTIFICATE----- + +Subject C=US, O=Truepic, OU=Vision, CN=Truepic Lens SDK v1.1.3 in Vision Camera v3.1.5 +-----BEGIN CERTIFICATE----- +MIIDQTCCAimgAwIBAgIUb+w58RlzuEmpGkmsoktfi3+IeD0wDQYJKoZIhvcNAQEM +BQAwTjEeMBwGA1UEAwwVQW5kcm9pZENsYWltU2lnbmluZ0NBMQ0wCwYDVQQLDARM +ZW5zMRAwDgYDVQQKDAdUcnVlcGljMQswCQYDVQQGEwJVUzAeFw0yMzAyMTIxNzQ0 +NTdaFw0yMzAyMTMxNzQ0NTZaMGoxCzAJBgNVBAYTAlVTMRAwDgYDVQQKDAdUcnVl +cGljMQ8wDQYDVQQLDAZWaXNpb24xODA2BgNVBAMML1RydWVwaWMgTGVucyBTREsg +djEuMS4zIGluIFZpc2lvbiBDYW1lcmEgdjMuMS41MFkwEwYHKoZIzj0CAQYIKoZI +zj0DAQcDQgAE3Gu2j8b6K4pwTZgjILQO+At8TFvsojXaCJt/SsdguqLSkDXOsLdR +M8LvT0YGEMcaSEXM+7vLsXOj4jOxazR/hKOBxTCBwjAMBgNVHRMBAf8EAjAAMB8G +A1UdIwQYMBaAFNTQ3GEYq9CvbacngtZ+DQECAypsME0GCCsGAQUFBwEBBEEwPzA9 +BggrBgEFBQcwAYYxaHR0cDovL3ZhLnRydWVwaWMuY29tL2VqYmNhL3B1YmxpY3dl +Yi9zdGF0dXMvb2NzcDATBgNVHSUEDDAKBggrBgEFBQcDBDAdBgNVHQ4EFgQUE8X1 +E0n82XhEImuosqlFFp3iBAkwDgYDVR0PAQH/BAQDAgeAMA0GCSqGSIb3DQEBDAUA +A4IBAQC7cGGGVG1QC/FrrjsWZcY+KgLJgrg7V372mt0ZYdDkR9aFyAAUSG+xc922 +ZVuVK1GRg/g98OzOTdH91mfmPV4xFnA77bgp7HYhBjvH/iyZFHXSW7Ivzd10Fnvp +imIUEKRZDUVW+RgYKfNK0Ubrodi5iPFdcl0PpSADbbalngi+XUF9FQybRf+MobKi +J2wfvOJozN9I9RPCbqAjY5idNqHmZZiBlZqUsQ4blSxCWUeDjQe/wiaElbziFhYi +ev9TQP8kxj8VElaXgC8+pxkBmSbvSGeMH2IvEbLiACIGr7Bs2kidpHLfaD1w6Tnz +hBuO/s1sS+1sYhTEsn5Y/9dm3Lqh +-----END CERTIFICATE----- + +Subject C=US, O=Truepic, OU=Vision, CN=Truepic Lens SDK v1.1.3 in Vision Camera v3.1.5 +-----BEGIN CERTIFICATE----- +MIIDQTCCAimgAwIBAgIUTuu/3ye0L+cTPLWjhMw2dLN0d6cwDQYJKoZIhvcNAQEM +BQAwTjEeMBwGA1UEAwwVQW5kcm9pZENsYWltU2lnbmluZ0NBMQ0wCwYDVQQLDARM +ZW5zMRAwDgYDVQQKDAdUcnVlcGljMQswCQYDVQQGEwJVUzAeFw0yMzAyMTExODIy +MDBaFw0yMzAyMTIxODIxNTlaMGoxCzAJBgNVBAYTAlVTMRAwDgYDVQQKDAdUcnVl +cGljMQ8wDQYDVQQLDAZWaXNpb24xODA2BgNVBAMML1RydWVwaWMgTGVucyBTREsg +djEuMS4zIGluIFZpc2lvbiBDYW1lcmEgdjMuMS41MFkwEwYHKoZIzj0CAQYIKoZI +zj0DAQcDQgAEXEfQ50yTq8YluTWHPT1ceYpOlXRYWsNLGo6EFtATq2D8GKyCm3Gl +jmUcGE1bdGsZAhl28TK5pLPD9RcOKpY+P6OBxTCBwjAMBgNVHRMBAf8EAjAAMB8G +A1UdIwQYMBaAFNTQ3GEYq9CvbacngtZ+DQECAypsME0GCCsGAQUFBwEBBEEwPzA9 +BggrBgEFBQcwAYYxaHR0cDovL3ZhLnRydWVwaWMuY29tL2VqYmNhL3B1YmxpY3dl +Yi9zdGF0dXMvb2NzcDATBgNVHSUEDDAKBggrBgEFBQcDBDAdBgNVHQ4EFgQUbJGK +r/Ji09Xgut+YRwQbdAv9i4QwDgYDVR0PAQH/BAQDAgeAMA0GCSqGSIb3DQEBDAUA +A4IBAQAqc0sFRwCvXBiKcumJO/xfYrx+HXKgVO4/9n5bgRw5YgbZk4v/ovFwwjQS +e1bfCYimPDm/H95WBSPcJcTU2vE7loid70ci9HJn+DLu3YSzGIQ1Id8rfq9ymFXk +NzW1IPRqGNA5r0KLnjZM2vNZFvKsi5BAUIMyiJsoA3ScFttoNn2Rq2w6zHy//Dd4 +8ZXC77H2uxgIgJgam+q6XfZZ6ROJHXoYXSu1IN9YsfPL65m4W4Ak0QnzZvWS3Mrz +rhgf/RwblED57U3mkZKLWJAlEPsM0Kj81diW/aN6rghZn2yFkij+W0DlNYkAbKZI +WmWUfh2FnaxzuDvp4YfW+AdfdJlF +-----END CERTIFICATE----- + +Subject C=US, ST=Washington, L=Seattle, O=Amazon Web Services\, Inc., OU=Amazon Bedrock, CN=Amazon Web Services\, Inc. +-----BEGIN CERTIFICATE----- +MIIE4jCCA8qgAwIBAgIQDoTxo1cQcFRgZuCjsA1tYjANBgkqhkiG9w0BAQwFADBm +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSUwIwYDVQQDExxEaWdpQ2VydCBEb2N1bWVudCBTaWdu +aW5nIENBMB4XDTI0MDgxNDAwMDAwMFoXDTI1MDgxNDIzNTk1OVowgZUxCzAJBgNV +BAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdTZWF0dGxlMSIw +IAYDVQQKExlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRcwFQYDVQQLEw5BbWF6 +b24gQmVkcm9jazEiMCAGA1UEAxMZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjB2 +MBAGByqGSM49AgEGBSuBBAAiA2IABEKUt+neiVgWfp5cqjhkwUXwKOkU+mSWU6sa +2YEl9/DfL/0JlPOR9ntsUmsg679LtVPYcEtqqqgIOjJ9redxuAo90VLM01xWT+nk +qUUdtHpBVPRODS3ZrBpECNavldmvQ6OCAggwggIEMB8GA1UdIwQYMBaAFO/ONZPO +9obF+IT1DOdab9kvS+NkMB0GA1UdDgQWBBRkOTg3sqb/0k1bHUE+AqFxzLmpqDAW +BgNVHSAEDzANMAsGCWCGSAGG/WwDFTAOBgNVHQ8BAf8EBAMCBsAwHQYDVR0lBBYw +FAYIKwYBBQUHAwIGCCsGAQUFBwMEMIGNBgNVHR8EgYUwgYIwP6A9oDuGOWh0dHA6 +Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydERvY3VtZW50U2lnbmluZ0NBLWcx +LmNybDA/oD2gO4Y5aHR0cDovL2NybDQuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0RG9j +dW1lbnRTaWduaW5nQ0EtZzEuY3JsMHsGCCsGAQUFBwEBBG8wbTAkBggrBgEFBQcw +AYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEUGCCsGAQUFBzAChjlodHRwOi8v +Y2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnREb2N1bWVudFNpZ25pbmdDQS5j +cnQwEwYKKoZIhvcvAQEJAgQFMAMCAQEwWQYKKoZIhvcvAQEJAQRLMEkCAQGGRGh0 +dHA6Ly9hZG9iZS50aW1lc3RhbXAuZGlnaWNlcnQuY29tLzBFODRGMUEzNTcxMDcw +NTQ2MDY2RTBBM0IwMEQ2RDYyMA0GCSqGSIb3DQEBDAUAA4IBAQBqDwAfeE6JQfbs +S/6hwSNN5i2SWYg7z8W7KV2wxVyP9oSxVALvV2r9Eaomn+JHs8ZDi/667gvUDRzX +BRusP3pSOIyVT5tzMk3oGSzZUM16bM4o64FebC1MRpNz2u8yDYhxy9g9QXVCpjTk +WT7hDe7xqsn4aNulNJeS9K4hED4nksTbyYZ6Ef6O9mTgpcwoP5DlVAOH4uYzGZrT +Ixa/eUI8b4JlDCoip+ifl8Kf+qDYiukGa8fYqPQfhY5Od1e8i0u6lcnTZ8gACLZc +gM3ZQIfMugdhMaijqWvdqteMB4rl02cR9mvTQZAQRSdOHbfZfFxtHQ/HpDT737k2 +faRY7r6c +-----END CERTIFICATE----- + +Subject C=DE, ST=Hamburg, L=Hamburg, O=TrustNXT GmbH, CN=TrustNXT Root CA, E=info@trustnxt.com +-----BEGIN CERTIFICATE----- +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 +wO0n +-----END CERTIFICATE----- \ No newline at end of file diff --git a/tests/trust-list.test.ts b/tests/trust-list.test.ts new file mode 100644 index 00000000..eb3e0b3c --- /dev/null +++ b/tests/trust-list.test.ts @@ -0,0 +1,286 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; +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'; + +interface TestExpectations { + /** + * Asset class to read the file + */ + assetType: AssetType; + + /** + * whether the file contains a JUMBF with a C2PA Manifest + */ + jumbf: boolean; + + /** + * whether the file is valid according to the C2PA Manifest + */ + valid?: boolean; + + /** + * status codes expected in the status entries + */ + statusCodes?: ValidationStatusCode[]; +} + +// test data sets with file names and expected outcomes +const testFiles: Record = { + 'public-testfiles/legacy/1.4/image/jpeg/adobe-20220124-A.jpg': { + assetType: JPEG, + jumbf: false, + }, + 'public-testfiles/legacy/1.4/image/jpeg/adobe-20220124-C.jpg': { + assetType: JPEG, + jumbf: true, + valid: true, + }, + 'public-testfiles/legacy/1.4/image/jpeg/adobe-20220124-CA.jpg': { + assetType: JPEG, + jumbf: true, + valid: true, + }, + 'public-testfiles/legacy/1.4/image/jpeg/adobe-20220124-CACA.jpg': { + assetType: JPEG, + jumbf: true, + valid: true, + }, + 'public-testfiles/legacy/1.4/image/jpeg/adobe-20220124-CACAICAICICA.jpg': { + assetType: JPEG, + jumbf: true, + valid: true, + }, + 'public-testfiles/legacy/1.4/image/jpeg/adobe-20220124-CAI.jpg': { + assetType: JPEG, + jumbf: true, + valid: true, + }, + 'public-testfiles/legacy/1.4/image/jpeg/adobe-20220124-CAICA.jpg': { + assetType: JPEG, + jumbf: true, + valid: true, + }, + 'public-testfiles/legacy/1.4/image/jpeg/adobe-20220124-CAICAI.jpg': { + assetType: JPEG, + jumbf: true, + valid: true, + }, + 'public-testfiles/legacy/1.4/image/jpeg/adobe-20220124-CAIAIIICAICIICAIICICA.jpg': { + assetType: JPEG, + jumbf: true, + valid: false, + statusCodes: [ValidationStatusCode.AssertionActionIngredientMismatch], + }, + 'public-testfiles/legacy/1.4/image/jpeg/adobe-20220124-CI.jpg': { + assetType: JPEG, + jumbf: true, + valid: true, + }, + 'public-testfiles/legacy/1.4/image/jpeg/adobe-20220124-CICA.jpg': { + assetType: JPEG, + jumbf: true, + valid: true, + }, + 'public-testfiles/legacy/1.4/image/jpeg/adobe-20220124-CICACACA.jpg': { + assetType: JPEG, + jumbf: true, + valid: true, + }, + 'public-testfiles/legacy/1.4/image/jpeg/adobe-20220124-CIE-sig-CA.jpg': { + assetType: JPEG, + jumbf: true, + valid: true, + }, + 'public-testfiles/legacy/1.4/image/jpeg/adobe-20220124-CII.jpg': { + assetType: JPEG, + jumbf: true, + valid: true, + }, + 'public-testfiles/legacy/1.4/image/jpeg/adobe-20220124-E-clm-CAICAI.jpg': { + assetType: JPEG, + jumbf: true, + valid: false, + statusCodes: [ + ValidationStatusCode.AssertionHashedURIMismatch, + ValidationStatusCode.AssertionActionIngredientMismatch, + ], + }, + 'public-testfiles/legacy/1.4/image/jpeg/adobe-20220124-E-dat-CA.jpg': { + assetType: JPEG, + jumbf: true, + valid: false, + statusCodes: [ValidationStatusCode.AssertionDataHashMismatch], + }, + 'public-testfiles/legacy/1.4/image/jpeg/adobe-20220124-E-sig-CA.jpg': { + assetType: JPEG, + jumbf: true, + valid: false, + statusCodes: [ValidationStatusCode.ClaimSignatureMismatch, ValidationStatusCode.TimeStampMismatch], + }, + 'public-testfiles/legacy/1.4/image/jpeg/adobe-20220124-E-uri-CA.jpg': { + assetType: JPEG, + jumbf: true, + valid: false, + statusCodes: [ValidationStatusCode.AssertionHashedURIMismatch], + }, + 'public-testfiles/legacy/1.4/image/jpeg/adobe-20220124-E-uri-CIE-sig-CA.jpg': { + assetType: JPEG, + jumbf: true, + valid: true, + }, + 'public-testfiles/legacy/1.4/image/jpeg/adobe-20220124-I.jpg': { + assetType: JPEG, + jumbf: false, + }, + 'public-testfiles/legacy/1.4/image/jpeg/adobe-20220124-XCA.jpg': { + assetType: JPEG, + jumbf: true, + valid: false, + statusCodes: [ValidationStatusCode.AssertionDataHashMismatch], + }, + 'public-testfiles/legacy/1.4/image/jpeg/adobe-20220124-XCI.jpg': { + assetType: JPEG, + jumbf: true, + valid: false, + statusCodes: [ValidationStatusCode.AssertionDataHashMismatch], + }, + 'public-testfiles/legacy/1.4/image/jpeg/nikon-20221019-building.jpeg': { + assetType: JPEG, + jumbf: true, + valid: false, + statusCodes: [ValidationStatusCode.SigningCredentialExpired], + }, + 'public-testfiles/legacy/1.4/image/jpeg/truepic-20230212-camera.jpg': { + assetType: JPEG, + jumbf: true, + valid: false, + statusCodes: [ValidationStatusCode.SigningCredentialUntrusted], + }, + 'public-testfiles/legacy/1.4/image/jpeg/truepic-20230212-landscape.jpg': { + assetType: JPEG, + jumbf: true, + valid: false, + statusCodes: [ValidationStatusCode.SigningCredentialUntrusted], + }, + 'public-testfiles/legacy/1.4/image/jpeg/truepic-20230212-library.jpg': { + assetType: JPEG, + jumbf: true, + valid: false, + statusCodes: [ValidationStatusCode.SigningCredentialUntrusted], + }, + 'public-testfiles/legacy/1.4/video/mp4/truepic-20230212-zoetrope.mp4': { + assetType: BMFF, + jumbf: true, + valid: false, + statusCodes: [ValidationStatusCode.SigningCredentialUntrusted], + }, + 'amazon-titan-g1.png': { + assetType: PNG, + jumbf: true, + valid: true, + }, + 'trustnxt-icon-signed-v2-bmff.heic': { + assetType: BMFF, + jumbf: true, + valid: false, + statusCodes: [ValidationStatusCode.SigningCredentialUntrusted], + }, + 'trustnxt-icon-signed-timestamp.jpg': { + assetType: JPEG, + jumbf: true, + valid: true, + }, +}; + +beforeAll(async () => { + await setTrustList('tests/fixtures/trust-list-wrong.pem'); +}); + +describe('Trust list tests', function () { + for (const [filename, data] of Object.entries(testFiles)) { + describe(`test file ${filename}`, () => { + let buf: Buffer | undefined = undefined; + it(`loading test file`, async () => { + // load the file into a buffer + buf = await fs.readFile(`${baseDir}/${filename}`); + assert.ok(buf); + }); + + let asset: Asset | undefined = undefined; + it(`constructing the asset`, async function () { + if (!buf) return; + + // ensure it's a valid asset + assert.ok(await data.assetType.canRead(buf)); + + // construct the asset + asset = await data.assetType.create(buf); + }); + + let jumbf: Uint8Array | undefined = undefined; + it(`extract the manifest JUMBF`, async function () { + if (!asset) return; + + // extract the C2PA manifest store in binary JUMBF format + jumbf = await asset.getManifestJUMBF(); + if (data.jumbf) { + assert.ok(jumbf, 'no JUMBF found'); + } else { + assert.ok(jumbf === undefined, 'unexpected JUMBF found'); + } + }); + + if (data.jumbf) { + let validationResult: ValidationResult | undefined = undefined; + it(`validate manifest`, async function () { + if (!jumbf || !asset) return; + + // deserialize the JUMBF box structure + const superBox = SuperBox.fromBuffer(jumbf); + + // verify raw content + // Note: The raw content does not include the header (length, type), + // hence the offset 8. + assert.ok(superBox.rawContent); + assert.ok( + BinaryHelper.bufEqual(superBox.rawContent, jumbf.subarray(8)), + 'the stored raw content is different from the stored JUMBF data', + ); + + // Read the manifest store from the JUMBF container + const manifests = ManifestStore.read(superBox); + + // Validate the asset with the manifest + validationResult = await manifests.validate(asset); + + const message = + data.valid ? + `Manifest should be valid but is not (status codes: ${validationResult.statusEntries + .filter(e => !e.success) + .map(e => e.code) + .join(', ')})` + : 'Manifest is valid but should not be'; + assert.equal(validationResult.isValid, data.valid, message); + }); + + data.statusCodes?.forEach(value => { + it(`check status code ${value}`, async function () { + if (validationResult === undefined) return; + + assert.ok( + validationResult.statusEntries.some(entry => entry.code === value), + `missing status code ${value}`, + ); + }); + }); + } + }); + } +}); diff --git a/tests/utils/set-trust-list.ts b/tests/utils/set-trust-list.ts new file mode 100644 index 00000000..8baf613e --- /dev/null +++ b/tests/utils/set-trust-list.ts @@ -0,0 +1,9 @@ +import * as fs from 'node:fs/promises'; +import { TrustList } from '../../src/cose'; + +export const DefaultTrustListPath = 'tests/fixtures/trust-list.pem'; + +export async function setTrustList(trustListFile: string = DefaultTrustListPath): Promise { + const trustListData = (await fs.readFile(trustListFile)).toString(); + TrustList.setTrustAnchors([trustListData]); +} diff --git a/tests/utils/testCertificates.ts b/tests/utils/testCertificates.ts index 8701ed40..fed1bfd2 100644 --- a/tests/utils/testCertificates.ts +++ b/tests/utils/testCertificates.ts @@ -3,12 +3,14 @@ import { X509Certificate } from '@peculiar/x509'; import { CoseAlgorithmIdentifier, LocalSigner, Signer } from '../../src/cose'; import { ValidationStatusCode } from '../../src/manifest'; import { LocalTimestampProvider } from '../../src/rfc3161'; +import { setTrustList } from './set-trust-list'; export interface TestCertificate { name: string; certificateFile: string; privateKeyFile: string; algorithm: CoseAlgorithmIdentifier; + trustListFile?: string; } export const TEST_CERTIFICATES: TestCertificate[] = [ @@ -48,6 +50,8 @@ export async function loadTestCertificate(certificateInfo: TestCertificate): Pro const signer = new LocalSigner(privateKey, certificateInfo.algorithm, x509Certificate); + // Set trust list if provided + await setTrustList(certificateInfo.trustListFile); return { signer, timestampProvider }; } @@ -86,3 +90,92 @@ export function getExpectedValidationStatusEntries(manifestLabel: string | undef }, ]; } + +export function getExpectedValidationStatusEntriesInvalid(manifestLabel: string | undefined) { + return [ + { + code: ValidationStatusCode.TimeStampTrusted, + explanation: undefined, + url: `self#jumbf=/c2pa/${manifestLabel}/c2pa.signature`, + success: true, + }, + { + code: ValidationStatusCode.SigningCredentialInvalid, + explanation: undefined, + url: `self#jumbf=/c2pa/${manifestLabel}/c2pa.signature`, + success: false, + }, + { + code: ValidationStatusCode.ClaimSignatureValidated, + explanation: undefined, + url: `self#jumbf=/c2pa/${manifestLabel}/c2pa.signature`, + success: true, + }, + { + code: ValidationStatusCode.AssertionHashedURIMatch, + explanation: undefined, + url: 'self#jumbf=c2pa.assertions/c2pa.hash.data', + success: true, + }, + ]; +} + +export function getExpectedValidationStatusEntriesUntrusted(manifestLabel: string | undefined) { + return [ + { + code: ValidationStatusCode.TimeStampTrusted, + explanation: undefined, + url: `self#jumbf=/c2pa/${manifestLabel}/c2pa.signature`, + success: true, + }, + { + code: ValidationStatusCode.SigningCredentialUntrusted, + explanation: undefined, + url: `self#jumbf=/c2pa/${manifestLabel}/c2pa.signature`, + success: false, + }, + { + code: ValidationStatusCode.ClaimSignatureValidated, + explanation: undefined, + url: `self#jumbf=/c2pa/${manifestLabel}/c2pa.signature`, + success: true, + }, + { + code: ValidationStatusCode.AssertionHashedURIMatch, + explanation: undefined, + url: 'self#jumbf=c2pa.assertions/c2pa.hash.data', + success: true, + }, + ]; +} + + + +export function getExpectedValidationStatusEntriesWrongTimeStamp(manifestLabel: string | undefined) { + return [ + { + code: ValidationStatusCode.TimeStampOutsideValidity, + explanation: 'Timestamp outside signer certificate validity period', + url: `self#jumbf=/c2pa/${manifestLabel}/c2pa.signature`, + success: false, + }, + { + code: ValidationStatusCode.SigningCredentialExpired, + explanation: undefined, + url: `self#jumbf=/c2pa/${manifestLabel}/c2pa.signature`, + success: false, + }, + { + code: ValidationStatusCode.ClaimSignatureValidated, + explanation: undefined, + url: `self#jumbf=/c2pa/${manifestLabel}/c2pa.signature`, + success: true, + }, + { + code: ValidationStatusCode.AssertionHashedURIMatch, + explanation: undefined, + url: 'self#jumbf=c2pa.assertions/c2pa.hash.data', + success: true, + }, + ]; +}