Skip to content
Open
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
99 changes: 87 additions & 12 deletions js-src/IdentityAssertion.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -41,14 +41,69 @@ class TestSigner {
sign.end();
return sign.sign(this.privateKey);
};

signP1363 = async (bytes: Buffer): Promise<Buffer> => {
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<Buffer> => {
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));
};
}

Expand Down Expand Up @@ -123,30 +178,32 @@ 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],
reserveSize: 10000,
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 = {
Expand Down Expand Up @@ -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);
});
});