diff --git a/js-src/IdentityAssertion.spec.ts b/js-src/IdentityAssertion.spec.ts index 0b1221a..50d993f 100644 --- a/js-src/IdentityAssertion.spec.ts +++ b/js-src/IdentityAssertion.spec.ts @@ -16,7 +16,7 @@ import type { Manifest } from "@contentauth/c2pa-types"; import * as fs from "fs-extra"; import * as crypto from "crypto"; -import { encode } from "cbor2"; +import { encode, Tag } from "cbor2"; import type { JsCallbackSignerConfig, @@ -41,14 +41,69 @@ class TestSigner { sign.end(); return sign.sign(this.privateKey); }; + + signP1363 = async (bytes: Buffer): Promise => { + const sign = crypto.createSign("SHA256"); + sign.update(bytes); + sign.end(); + return sign.sign({ key: this.privateKey, dsaEncoding: "ieee-p1363" }); + }; } -class TestCawgSigner { - constructor(private manifestSigner: TestSigner) {} +/** + * COSE_Sign1 credential holder for cawg.x509.cose identity assertions. + * + * The sig_type "cawg.x509.cose" requires the credential holder callback to + * return a complete COSE_Sign1 envelope (RFC 9052), not a raw signature. + * + * Protocol: + * 1. CBOR-encode the SignerPayload → detached payload + * 2. Build protected header { 1: alg_id } as CBOR bstr + * 3. Build Sig_structure = ["Signature1", protected, external_aad, payload] + * 4. Sign CBOR(Sig_structure) with IEEE P1363 format (COSE requirement) + * 5. Return CBOR(Tag(18, [protected, {}, nil, signature])) + * + * IMPORTANT: Use Uint8Array (not Buffer) for byte strings passed to cbor2's + * encode(). Buffer causes cbor2 to unwrap CBOR content instead of encoding + * it as a bstr, which produces "got map, expected bstr" errors in the + * COSE parser. + */ +class CoseCawgSigner { + private static readonly ES256_ALG_ID = -7; + + constructor(private signer: TestSigner) {} sign = async (payload: SignerPayload): Promise => { - const cborBytes = Buffer.from(encode(payload)); - return this.manifestSigner.sign(cborBytes); + // CBOR-encode the SignerPayload (detached content) + const payloadCbor = new Uint8Array(encode(payload)); + + // Protected header: { 1: -7 } (alg = ES256) — must be Uint8Array for bstr + const protectedBytes = new Uint8Array( + encode(new Map([[1, CoseCawgSigner.ES256_ALG_ID]])), + ); + + // Sig_structure per RFC 9052 §4.4 + const sigStructure = [ + "Signature1", + protectedBytes, + new Uint8Array(0), // external_aad + payloadCbor, + ]; + const sigStructureCbor = Buffer.from(encode(sigStructure)); + + // Sign with IEEE P1363 format (required by COSE ES256) + const rawSignature = new Uint8Array( + await this.signer.signP1363(sigStructureCbor), + ); + + // COSE_Sign1 = Tag(18, [protected, unprotected, payload(nil), signature]) + const coseSign1 = new Tag(18, [ + protectedBytes, + new Map(), + null, + rawSignature, + ]); + return Buffer.from(encode(coseSign1)); }; } @@ -123,9 +178,9 @@ describe("IdentityAssertionBuilder", () => { "./tests/fixtures/certs/es256.pem", ); const c2paPublicKey = await fs.readFile("./tests/fixtures/certs/es256.pub"); - // Use the same signer for both C2PA manifest and COSE signing - // Create signer configurations + // Create signer configurations — use directCoseHandling: false with P1363 + // signatures for the manifest signer const c2paConfig: JsCallbackSignerConfig = { alg: "es256" as SigningAlg, certs: [c2paPublicKey], @@ -133,20 +188,22 @@ describe("IdentityAssertionBuilder", () => { tsaUrl: undefined, tsaHeaders: undefined, tsaBody: undefined, - directCoseHandling: true, + directCoseHandling: false, }; // Create signers const c2paTestSigner = new TestSigner(c2paPrivateKey); const c2paSigner = CallbackSigner.newSigner( c2paConfig, - c2paTestSigner.sign, + c2paTestSigner.signP1363, ); - const cawgTestSigner = new TestCawgSigner(c2paTestSigner); + + // COSE_Sign1 credential holder — returns a proper COSE envelope + const coseSigner = new CoseCawgSigner(c2paTestSigner); const cawgSigner = CallbackCredentialHolder.newCallbackCredentialHolder( 10000, "cawg.x509.cose", - cawgTestSigner.sign, + coseSigner.sign, ); const source = { @@ -183,9 +240,27 @@ describe("IdentityAssertionBuilder", () => { await builder.signAsync(iaSigner, source, dest); // Verify the manifest - await Reader.fromAsset({ + const reader = await Reader.fromAsset({ buffer: dest.buffer! as Buffer, mimeType: "image/jpeg", }); + + // Verify cawg.identity assertion is present + const active = reader!.getActive() as any; + const assertions: string[] = + active?.assertions?.map((a: any) => a.label) ?? []; + expect(assertions).toContain("cawg.identity"); + + // Verify no integrity errors (trust warnings from self-signed certs are OK) + const store = reader!.json() as any; + const validationStatus: Array<{ code: string }> = + store?.validation_status ?? []; + const integrityErrors = validationStatus.filter( + (s) => + !s.code.startsWith("signingCredential.") && + !s.code.startsWith("trust.") && + !s.code.startsWith("certificate."), + ); + expect(integrityErrors).toHaveLength(0); }); });