From b58ec00b7f758f5e717f6f2e22d45d1deaeeb000 Mon Sep 17 00:00:00 2001 From: Colin Murphy Date: Wed, 29 Apr 2026 16:25:05 -0400 Subject: [PATCH 1/8] feat: add redaction support. --- Cargo.lock | 41 +++++++++++++++++++++++++++++++++++------ Cargo.toml | 2 +- js-src/Builder.spec.ts | 11 +++++++++++ js-src/Builder.ts | 4 ++++ js-src/index.node.d.ts | 1 + js-src/types.d.ts | 6 ++++++ src/lib.rs | 4 ++++ src/neon_builder.rs | 14 ++++++++++++++ 8 files changed, 76 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 96c44e3..f689121 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -213,7 +213,7 @@ version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddb939d66e4ae03cee6091612804ba446b12878410cfa17f785f4dd67d4014e8" dependencies = [ - "brotli", + "brotli 8.0.1", "flate2", "futures-core", "memchr", @@ -257,9 +257,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "atree" -version = "0.5.2" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "132573478eb9ff973c6f75d0ed425ac12da77d266506483345f46743ecc83a98" +checksum = "239d25181cb40f1955529367ee495e35d03aab4e578028f41e7abc8b21c367a8" [[package]] name = "autocfg" @@ -397,6 +397,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "brotli" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor 4.0.3", +] + [[package]] name = "brotli" version = "8.0.1" @@ -405,7 +416,17 @@ checksum = "9991eea70ea4f293524138648e41ee89b0b2b12ddef3b255effa43c8056e0e0d" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", - "brotli-decompressor", + "brotli-decompressor 5.0.0", +] + +[[package]] +name = "brotli-decompressor" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", ] [[package]] @@ -495,9 +516,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "c2pa" -version = "0.78.4" +version = "0.80.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cae87cdb2ae6070bf628f889d745797071b194ecb31c711b452617308075c5e" +checksum = "6522391a6e7eab9f599a3421ff1fc50cb71d991c6cb318efddf708c8f30ff792" dependencies = [ "asn1-rs", "async-generic", @@ -505,6 +526,7 @@ dependencies = [ "atree", "base64", "bcder", + "brotli 7.0.0", "byteorder", "byteordered", "bytes", @@ -522,6 +544,7 @@ dependencies = [ "extfmt", "getrandom 0.2.16", "getrandom 0.3.4", + "glob", "hex", "hex-literal", "http", @@ -1426,6 +1449,12 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "group" version = "0.13.0" diff --git a/Cargo.toml b/Cargo.toml index 3603020..d957ce6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ crate-type = ["cdylib"] [dependencies] async-trait = "0.1.77" ciborium = "0.2.2" -c2pa = { version = "0.78.4", default-features = false, features = ["file_io", "pdf", "fetch_remote_manifests", "add_thumbnails", "rust_native_crypto", "default_http"] } +c2pa = { version = "0.80.3", default-features = false, features = ["file_io", "pdf", "fetch_remote_manifests", "add_thumbnails", "rust_native_crypto", "default_http"] } futures = "0.3" image = "0.25.6" neon = { version = "1.0.0", default-features = false, features = [ diff --git a/js-src/Builder.spec.ts b/js-src/Builder.spec.ts index 8dfb090..ace37df 100644 --- a/js-src/Builder.spec.ts +++ b/js-src/Builder.spec.ts @@ -678,6 +678,17 @@ describe("Builder", () => { } }); + it("should add redactions via addRedaction method", () => { + const uri1 = "self#jumbf=/c2pa/test-label/c2pa.assertions/cawg.identity"; + const uri2 = + "self#jumbf=/c2pa/test-label/c2pa.assertions/stds.schema-org.CreativeWork"; + const builder = Builder.new(); + builder.addRedaction(uri1); + builder.addRedaction(uri2); + const definition = builder.getManifestDefinition(); + expect(definition.redactions).toEqual([uri1, uri2]); + }); + it("should test builder remote url", async () => { // This test mirrors the Rust test_builder_remote_url test diff --git a/js-src/Builder.ts b/js-src/Builder.ts index 039c76d..73ae0e8 100644 --- a/js-src/Builder.ts +++ b/js-src/Builder.ts @@ -259,6 +259,10 @@ export class Builder implements BuilderInterface { ); } + addRedaction(uri: string): void { + getNeonBinary().builderAddRedaction.call(this.builder, uri); + } + getHandle(): NeonBuilderHandle { return this.builder; } diff --git a/js-src/index.node.d.ts b/js-src/index.node.d.ts index ccb6426..a52b0bc 100644 --- a/js-src/index.node.d.ts +++ b/js-src/index.node.d.ts @@ -85,6 +85,7 @@ declare module "index.node" { property: string, value: string | ClaimVersion, ): void; + export function builderAddRedaction(uri: string): void; // Reader methods export function readerFromAsset( diff --git a/js-src/types.d.ts b/js-src/types.d.ts index c82e9fb..a20116a 100644 --- a/js-src/types.d.ts +++ b/js-src/types.d.ts @@ -332,6 +332,12 @@ export interface BuilderInterface { */ updateManifestProperty(property: string, value: string | ClaimVersion): void; + /** + * Add a JUMBF URI to the list of assertions to redact from ingredient manifests + * @param uri JUMBF URI of the assertion to redact (e.g. `self#jumbf=/c2pa/{label}/c2pa.assertions/{name}`) + */ + addRedaction(uri: string): void; + /** * Get the internal handle for use with Neon bindings */ diff --git a/src/lib.rs b/src/lib.rs index 438d407..17de09f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -81,6 +81,10 @@ fn main(mut cx: ModuleContext) -> NeonResult<()> { "builderUpdateManifestProperty", neon_builder::NeonBuilder::update_manifest_property, )?; + cx.export_function( + "builderAddRedaction", + neon_builder::NeonBuilder::add_redaction, + )?; // Reader cx.export_function("readerNew", neon_reader::NeonReader::new)?; diff --git a/src/neon_builder.rs b/src/neon_builder.rs index de1236d..2596447 100644 --- a/src/neon_builder.rs +++ b/src/neon_builder.rs @@ -170,6 +170,20 @@ impl NeonBuilder { Ok(promise) } + + pub fn add_redaction(mut cx: FunctionContext) -> JsResult { + let rt = runtime(); + let this = cx.this::>()?; + let uri = cx.argument::(0)?.value(&mut cx); + let mut builder = rt.block_on(async { this.builder.lock().await }); + builder + .definition + .redactions + .get_or_insert_with(Vec::new) + .push(uri); + Ok(cx.undefined()) + } + pub fn add_ingredient(mut cx: FunctionContext) -> JsResult { let rt = runtime(); let this = cx.this::>()?; From 81210d99a9180feee3ec11f96d79807dac982f50 Mon Sep 17 00:00:00 2001 From: Colin Murphy Date: Wed, 29 Apr 2026 18:30:08 -0400 Subject: [PATCH 2/8] chore: add changeset --- .changeset/modern-breads-warn.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/modern-breads-warn.md diff --git a/.changeset/modern-breads-warn.md b/.changeset/modern-breads-warn.md new file mode 100644 index 0000000..3b61a8f --- /dev/null +++ b/.changeset/modern-breads-warn.md @@ -0,0 +1,5 @@ +--- +"@contentauth/c2pa-node": patch +--- + +Add method for adding redactions to builder From 47433156c7c3c7983b42c67aa0576c6607d0b924 Mon Sep 17 00:00:00 2001 From: Colin Murphy Date: Mon, 4 May 2026 10:27:34 -0400 Subject: [PATCH 3/8] feat: redact PII --- Cargo.lock | 4 +- Cargo.toml | 2 +- js-src/Builder.spec.ts | 168 +++++++++++++++++++++-------------------- js-src/Builder.ts | 4 +- js-src/index.node.d.ts | 2 +- js-src/types.d.ts | 7 +- src/neon_builder.rs | 18 +++-- src/neon_reader.rs | 4 +- tsconfig.tsbuildinfo | 2 +- 9 files changed, 114 insertions(+), 97 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f689121..6d7c1dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -516,9 +516,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "c2pa" -version = "0.80.3" +version = "0.82.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6522391a6e7eab9f599a3421ff1fc50cb71d991c6cb318efddf708c8f30ff792" +checksum = "64c0c36ef76968280979d4d7e3c2f79fb834eb0adf9106ba21e20a637430f8b1" dependencies = [ "asn1-rs", "async-generic", diff --git a/Cargo.toml b/Cargo.toml index d957ce6..4199f89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ crate-type = ["cdylib"] [dependencies] async-trait = "0.1.77" ciborium = "0.2.2" -c2pa = { version = "0.80.3", default-features = false, features = ["file_io", "pdf", "fetch_remote_manifests", "add_thumbnails", "rust_native_crypto", "default_http"] } +c2pa = { version = "0.82.0", default-features = false, features = ["file_io", "pdf", "fetch_remote_manifests", "add_thumbnails", "rust_native_crypto", "default_http"] } futures = "0.3" image = "0.25.6" neon = { version = "1.0.0", default-features = false, features = [ diff --git a/js-src/Builder.spec.ts b/js-src/Builder.spec.ts index ace37df..5cff672 100644 --- a/js-src/Builder.spec.ts +++ b/js-src/Builder.spec.ts @@ -571,10 +571,10 @@ describe("Builder", () => { customBool: true, customObject: { nested: "value", - count: 123 + count: 123, }, - customArray: ["item1", "item2", "item3"] - } + customArray: ["item1", "item2", "item3"], + }, }; await builder.addIngredient(JSON.stringify(ingredient)); @@ -583,57 +583,7 @@ describe("Builder", () => { expect(definition.ingredients![0]).toMatchObject(ingredient); }); - it("should perform redaction workflow like test_redaction_async", async () => { - // This test mirrors the Rust test_redaction_async test - - // Create a reader to get the parent manifest label from the existing source - const reader = await Reader.fromAsset(source); - expect(reader).not.toBeNull(); - const parentManifestLabel = reader!.activeLabel(); - expect(parentManifestLabel).toBeDefined(); - - // Create a redacted URI for the assertion we are going to redact - // Using a common assertion label that might exist - const assertionLabel = "stds.schema-org.CreativeWork"; - const redactedUri = `contentauth:urn:uuid:${parentManifestLabel}/c2pa.assertions/${assertionLabel}`; - - // Create a builder with edit intent and redactions - const redactionManifestDefinition = { - claim_generator: "test-generator", - claim_generator_info: [ - { - name: "c2pa_test", - version: "1.0.0", - }, - ], - title: "Test_Redaction_Manifest", - format: "image/jpeg", - instance_id: "1234", - intent: "edit", - redactions: [redactedUri], - assertions: [ - { - label: "org.test.assertion", - data: {}, - }, - ], - resources: { resources: {} }, - }; - - const builder = Builder.withJson(redactionManifestDefinition); - - // Add a redacted action - const redactedAction = { - actions: [ - { - action: "c2pa.redacted", - }, - ], - }; - - builder.addAssertion("c2pa.actions", redactedAction, "Cbor"); - - // Use the callback signer like the other test + it("should perform redaction workflow", async () => { const signerConfig: JsCallbackSignerConfig = { alg: "es256", certs: [publicKey], @@ -644,38 +594,94 @@ describe("Builder", () => { const testSigner = new TestSigner(privateKey); const signer = CallbackSigner.newSigner(signerConfig, testSigner.sign); - // Sign the manifest with the original image as input - const dest = { buffer: null }; - const outputBuffer = await builder.signAsync(signer, source, dest); - expect(outputBuffer.length).toBeGreaterThan(0); + // Step 1: Sign source asset with stds.schema-org.CreativeWork assertion + const assertionLabel = "stds.schema-org.CreativeWork"; + const step1Builder = Builder.withJson({ + claim_generator_info: [{ name: "c2pa_test", version: "1.0.0" }], + title: "Asset With PII", + format: "image/jpeg", + instance_id: "step1-1234", + assertions: [ + { + label: assertionLabel, + data: { + "@context": "http://schema.org/", + "@type": "CreativeWork", + author: [{ "@type": "Person", name: "John Doe" }], + }, + }, + ], + }); + const step1Dest = { buffer: null }; + await step1Builder.signAsync(signer, source, step1Dest); + const step1Asset = { + buffer: step1Dest.buffer! as Buffer, + mimeType: "image/jpeg", + }; - // Verify the result by reading the signed manifest - const signedReader = await Reader.fromAsset({ - buffer: dest.buffer! as Buffer, + // Step 2: Read the signed asset to get manifest label and build JUMBF URI + const parentReader = await Reader.fromAsset(step1Asset); + expect(parentReader).not.toBeNull(); + const parentLabel = parentReader!.activeLabel(); + expect(parentLabel).toBeDefined(); + + // Verify the assertion exists in the parent manifest + const parentManifest = parentReader!.getActive(); + expect( + parentManifest?.assertions?.some( + (a: any) => a.label === assertionLabel, + ), + ).toBe(true); + + // Correct JUMBF URI format: self#jumbf=/c2pa/{label}/c2pa.assertions/{assertionLabel} + const redactedUri = `self#jumbf=/c2pa/${parentLabel}/c2pa.assertions/${assertionLabel}`; + + // Step 3: Create update builder that redacts the PII assertion + const redactionBuilder = Builder.withJson({ + claim_generator_info: [{ name: "c2pa_test", version: "1.0.0" }], + title: "Redacted Manifest", + format: "image/jpeg", + instance_id: "step2-1234", + }); + redactionBuilder.setIntent("update"); + redactionBuilder.addRedaction(redactedUri, "c2pa.PII.present"); + + const step2Dest = { buffer: null }; + const manifestBytes = await redactionBuilder.signAsync( + signer, + step1Asset, + step2Dest, + ); + expect(manifestBytes.length).toBeGreaterThan(0); + + // Step 4: Verify assertion is gone from the ingredient manifest + const finalReader = await Reader.fromAsset({ + buffer: step2Dest.buffer! as Buffer, mimeType: "image/jpeg", }); - expect(signedReader).not.toBeNull(); - expect(signedReader).toBeDefined(); + expect(finalReader).not.toBeNull(); - // Check that the manifest was created successfully - const activeManifest = signedReader!.getActive(); - expect(activeManifest).toBeDefined(); + const store = finalReader!.json(); + const parentInStore = store.manifests?.[parentLabel!]; + expect(parentInStore).toBeDefined(); - // Verify the redacted action was added - const assertions = activeManifest?.assertions; - const actionsAssertion = assertions?.find( - (a: any) => a.label === "c2pa.actions.v2", + // PII assertion removed from parent manifest + const hasPiiAssertion = parentInStore?.assertions?.some( + (a: any) => a.label === assertionLabel, ); - expect(actionsAssertion).toBeDefined(); + expect(hasPiiAssertion).toBe(false); - if (actionsAssertion && isActionsAssertion(actionsAssertion)) { - const actions = actionsAssertion.data.actions; - const redactedAction = actions.find( - (a: any) => a.action === "c2pa.redacted", - ); - expect(redactedAction).toBeDefined(); - expect(redactedAction?.action).toBe("c2pa.redacted"); - } + // Active manifest records c2pa.redacted action and redactions list + const activeLabel = store.active_manifest; + const activeManifest = store.manifests?.[activeLabel!]; + const actionsAssertion = activeManifest?.assertions?.find( + (a: any) => a.label === "c2pa.actions" || a.label === "c2pa.actions.v2", + ); + const hasRedactedAction = (actionsAssertion?.data as any)?.actions?.some( + (a: any) => a.action === "c2pa.redacted", + ); + expect(hasRedactedAction).toBe(true); + expect(activeManifest?.redactions).toContain(redactedUri); }); it("should add redactions via addRedaction method", () => { @@ -683,8 +689,8 @@ describe("Builder", () => { const uri2 = "self#jumbf=/c2pa/test-label/c2pa.assertions/stds.schema-org.CreativeWork"; const builder = Builder.new(); - builder.addRedaction(uri1); - builder.addRedaction(uri2); + builder.addRedaction(uri1, "c2pa.PII.present"); + builder.addRedaction(uri2, "c2pa.PII.present"); const definition = builder.getManifestDefinition(); expect(definition.redactions).toEqual([uri1, uri2]); }); diff --git a/js-src/Builder.ts b/js-src/Builder.ts index 73ae0e8..63a3c36 100644 --- a/js-src/Builder.ts +++ b/js-src/Builder.ts @@ -259,8 +259,8 @@ export class Builder implements BuilderInterface { ); } - addRedaction(uri: string): void { - getNeonBinary().builderAddRedaction.call(this.builder, uri); + addRedaction(uri: string, reason: string): void { + getNeonBinary().builderAddRedaction.call(this.builder, uri, reason); } getHandle(): NeonBuilderHandle { diff --git a/js-src/index.node.d.ts b/js-src/index.node.d.ts index a52b0bc..862baf9 100644 --- a/js-src/index.node.d.ts +++ b/js-src/index.node.d.ts @@ -85,7 +85,7 @@ declare module "index.node" { property: string, value: string | ClaimVersion, ): void; - export function builderAddRedaction(uri: string): void; + export function builderAddRedaction(uri: string, reason: string): void; // Reader methods export function readerFromAsset( diff --git a/js-src/types.d.ts b/js-src/types.d.ts index a20116a..7f1c29b 100644 --- a/js-src/types.d.ts +++ b/js-src/types.d.ts @@ -333,10 +333,13 @@ export interface BuilderInterface { updateManifestProperty(property: string, value: string | ClaimVersion): void; /** - * Add a JUMBF URI to the list of assertions to redact from ingredient manifests + * Redact an assertion from an ingredient manifest and record the reason. + * Adds the URI to `definition.redactions` and appends a `c2pa.redacted` action + * with `parameters.redacted` pointing to the same URI. * @param uri JUMBF URI of the assertion to redact (e.g. `self#jumbf=/c2pa/{label}/c2pa.assertions/{name}`) + * @param reason Why the assertion is being redacted. Use `"c2pa.PII.present"` for PII removal. */ - addRedaction(uri: string): void; + addRedaction(uri: string, reason: string): void; /** * Get the internal handle for use with Neon bindings diff --git a/src/neon_builder.rs b/src/neon_builder.rs index 2596447..16def04 100644 --- a/src/neon_builder.rs +++ b/src/neon_builder.rs @@ -18,7 +18,7 @@ use crate::neon_reader::NeonReader; use crate::neon_signer::{CallbackSignerConfig, NeonCallbackSigner, NeonLocalSigner}; use crate::runtime::runtime; use crate::utils::parse_settings; -use c2pa::{Builder, BuilderIntent, Ingredient}; +use c2pa::{assertions::Action, Builder, BuilderIntent, Ingredient}; use neon::context::Context as NeonContext; use neon::prelude::*; use neon_serde4; @@ -60,7 +60,7 @@ impl NeonBuilder { .with_definition(json.as_str()) .or_else(|err| cx.throw_error(err.to_string()))? } else { - Builder::from_json(&json).or_else(|err| cx.throw_error(err.to_string()))? + Builder::default().with_definition(&json).or_else(|err| cx.throw_error(err.to_string()))? }; Ok(cx.boxed(Self { @@ -175,12 +175,20 @@ impl NeonBuilder { let rt = runtime(); let this = cx.this::>()?; let uri = cx.argument::(0)?.value(&mut cx); + let reason = cx.argument::(1)?.value(&mut cx); let mut builder = rt.block_on(async { this.builder.lock().await }); builder .definition .redactions .get_or_insert_with(Vec::new) - .push(uri); + .push(uri.clone()); + let action = Action::new("c2pa.redacted") + .set_reason(reason) + .set_parameter("redacted".to_owned(), uri) + .or_else(|err| cx.throw_error(err.to_string()))?; + builder + .add_action(action) + .or_else(|err| cx.throw_error(err.to_string()))?; Ok(cx.undefined()) } @@ -268,7 +276,7 @@ impl NeonBuilder { // Block on acquiring the async mutex lock // Settings are automatically applied when runtime() is called let rt = runtime(); - let mut builder = rt.block_on(async { builder.lock().await }); + let builder = rt.block_on(async { builder.lock().await }); dest.write_stream().and_then(|mut dest_stream| { builder.to_archive(&mut dest_stream)?; @@ -316,7 +324,7 @@ impl NeonBuilder { let builder = if let Some(context) = context_opt { Builder::from_context(context).with_archive(source_stream)? } else { - Builder::from_archive(source_stream)? + Builder::default().with_archive(source_stream)? }; Ok(builder) }) diff --git a/src/neon_reader.rs b/src/neon_reader.rs index ce670a5..8251fad 100644 --- a/src/neon_reader.rs +++ b/src/neon_reader.rs @@ -70,7 +70,7 @@ impl NeonReader { .with_stream_async(&format, stream) .await? } else { - Reader::from_stream_async(&format, stream).await? + Reader::default().with_stream_async(&format, stream).await? }; Ok(reader) @@ -130,7 +130,7 @@ impl NeonReader { .with_manifest_data_and_stream_async(&c2pa_data, &format, stream) .await? } else { - Reader::from_manifest_data_and_stream_async(&c2pa_data, &format, stream).await? + Reader::default().with_manifest_data_and_stream_async(&c2pa_data, &format, stream).await? }; Ok(reader) diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index 3ef71dd..c4e010c 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./js-src/Builder.spec.ts","./js-src/Builder.ts","./js-src/IdentityAssertion.spec.ts","./js-src/IdentityAssertion.ts","./js-src/Reader.spec.ts","./js-src/Reader.ts","./js-src/Settings.spec.ts","./js-src/Settings.ts","./js-src/Signer.spec.ts","./js-src/Signer.ts","./js-src/Trustmark.spec.ts","./js-src/Trustmark.ts","./js-src/assertions.ts","./js-src/binary.ts","./js-src/index.node.d.ts","./js-src/index.ts","./js-src/types.d.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./js-src/builder.spec.ts","./js-src/builder.ts","./js-src/identityassertion.spec.ts","./js-src/identityassertion.ts","./js-src/reader.spec.ts","./js-src/reader.ts","./js-src/settings.spec.ts","./js-src/settings.ts","./js-src/signer.spec.ts","./js-src/signer.ts","./js-src/trustmark.spec.ts","./js-src/trustmark.ts","./js-src/assertions.ts","./js-src/binary.ts","./js-src/index.node.d.ts","./js-src/index.ts","./js-src/types.d.ts"],"version":"5.9.3"} \ No newline at end of file From e3b5516b4c48a2d6ad1ed6de666a2ed441e2a143 Mon Sep 17 00:00:00 2001 From: Colin Murphy Date: Tue, 5 May 2026 09:43:30 -0400 Subject: [PATCH 4/8] feat: use C2paReason Type --- js-src/Builder.ts | 3 ++- js-src/index.node.d.ts | 3 ++- js-src/types.d.ts | 5 +++-- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/js-src/Builder.ts b/js-src/Builder.ts index 63a3c36..366ecbc 100644 --- a/js-src/Builder.ts +++ b/js-src/Builder.ts @@ -13,6 +13,7 @@ import type { BuilderIntent, + C2paReason, Ingredient, Manifest, } from "@contentauth/c2pa-types"; @@ -259,7 +260,7 @@ export class Builder implements BuilderInterface { ); } - addRedaction(uri: string, reason: string): void { + addRedaction(uri: string, reason: C2paReason): void { getNeonBinary().builderAddRedaction.call(this.builder, uri, reason); } diff --git a/js-src/index.node.d.ts b/js-src/index.node.d.ts index 862baf9..d3660b9 100644 --- a/js-src/index.node.d.ts +++ b/js-src/index.node.d.ts @@ -14,6 +14,7 @@ import { Buffer } from "buffer"; import type { + C2paReason, CallbackSignerConfig, ClaimVersion, DestinationAsset, @@ -85,7 +86,7 @@ declare module "index.node" { property: string, value: string | ClaimVersion, ): void; - export function builderAddRedaction(uri: string, reason: string): void; + export function builderAddRedaction(uri: string, reason: C2paReason): void; // Reader methods export function readerFromAsset( diff --git a/js-src/types.d.ts b/js-src/types.d.ts index 7f1c29b..1f63098 100644 --- a/js-src/types.d.ts +++ b/js-src/types.d.ts @@ -15,12 +15,13 @@ import { Buffer } from "buffer"; import type { BuilderIntent, + C2paReason, Ingredient, Manifest, ManifestStore, } from "@contentauth/c2pa-types"; -export type { Ingredient } from "@contentauth/c2pa-types"; +export type { C2paReason, Ingredient } from "@contentauth/c2pa-types"; /** * Describes the digital signature algorithms allowed by the C2PA spec @@ -339,7 +340,7 @@ export interface BuilderInterface { * @param uri JUMBF URI of the assertion to redact (e.g. `self#jumbf=/c2pa/{label}/c2pa.assertions/{name}`) * @param reason Why the assertion is being redacted. Use `"c2pa.PII.present"` for PII removal. */ - addRedaction(uri: string, reason: string): void; + addRedaction(uri: string, reason: C2paReason): void; /** * Get the internal handle for use with Neon bindings diff --git a/package.json b/package.json index dfc5773..3436817 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "license": "MIT", "devDependencies": { "@changesets/cli": "^2.31.0", - "@contentauth/c2pa-types": "^0.4.3", + "@contentauth/c2pa-types": "^0.4.4", "@eslint/js": "^9.39.4", "@neon-rs/cli": "0.1.82", "@types/cli-progress": "^3.11.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4695f58..1809d6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,8 +40,8 @@ importers: specifier: ^2.31.0 version: 2.31.0(@types/node@22.19.17) '@contentauth/c2pa-types': - specifier: ^0.4.3 - version: 0.4.3 + specifier: ^0.4.4 + version: 0.4.4 '@eslint/js': specifier: ^9.39.4 version: 9.39.4 @@ -385,8 +385,8 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@contentauth/c2pa-types@0.4.3': - resolution: {integrity: sha512-uJJPpv8kS/wLaokb4lPGycyK9yf663H4Db/QDkdWuxxVM8d0UR4ZaIrJXWBeYO9M8gqH8JYJA/WBRkchboJWSQ==} + '@contentauth/c2pa-types@0.4.4': + resolution: {integrity: sha512-g6Ryna1iK8k+cU3rkDuVb/SHUmrVAXWWgZsy4ByVNT1yRnp9DeyC67VwS+9pIsgLljVbijm4vxS3pU68hnricw==} '@cto.af/wtf8@0.0.5': resolution: {integrity: sha512-LfUFi+Vv4eDzj+XAtR89e3wwjXA/NZjUSwU5NhwbBrLecxPaBYFy3exCuc1j+D4UZeOVdqlsl8G7LmOt18V0tg==} @@ -3709,7 +3709,7 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 - '@contentauth/c2pa-types@0.4.3': {} + '@contentauth/c2pa-types@0.4.4': {} '@cto.af/wtf8@0.0.5': {} From a5d1cbb954fade075eb920235af5b1e2715044d9 Mon Sep 17 00:00:00 2001 From: Colin Murphy Date: Tue, 5 May 2026 09:54:38 -0400 Subject: [PATCH 5/8] fix: fix tests Update c2pa crate dependency --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- js-src/Reader.spec.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6d7c1dd..c84d3f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -516,9 +516,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "c2pa" -version = "0.82.0" +version = "0.82.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64c0c36ef76968280979d4d7e3c2f79fb834eb0adf9106ba21e20a637430f8b1" +checksum = "8e96917bbbabdb4dd15030b67e4694b6751d618768fe88ad57f98aad7fd8e751" dependencies = [ "asn1-rs", "async-generic", diff --git a/Cargo.toml b/Cargo.toml index 4199f89..e9523ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ crate-type = ["cdylib"] [dependencies] async-trait = "0.1.77" ciborium = "0.2.2" -c2pa = { version = "0.82.0", default-features = false, features = ["file_io", "pdf", "fetch_remote_manifests", "add_thumbnails", "rust_native_crypto", "default_http"] } +c2pa = { version = "0.82.1", default-features = false, features = ["file_io", "pdf", "fetch_remote_manifests", "add_thumbnails", "rust_native_crypto", "default_http"] } futures = "0.3" image = "0.25.6" neon = { version = "1.0.0", default-features = false, features = [ diff --git a/js-src/Reader.spec.ts b/js-src/Reader.spec.ts index 4ad3800..37a7979 100644 --- a/js-src/Reader.spec.ts +++ b/js-src/Reader.spec.ts @@ -53,7 +53,7 @@ describe("Reader", () => { "instance_id": "xmp.iid:813ee422-9736-4cdc-9be6-4e35ed8e41cb", "thumbnail": { "format": "image/jpeg", - "identifier": "self#jumbf=c2pa.assertions/c2pa.thumbnail.ingredient.jpeg" + "identifier": "self#jumbf=/c2pa/contentauth:urn:uuid:c2677d4b-0a93-4444-876f-ed2f2d40b8cf/c2pa.assertions/c2pa.thumbnail.ingredient.jpeg" }, "relationship": "parentOf", "label": "c2pa.ingredient" From 21139718a4c5c83e6741831466f2e0565785fe00 Mon Sep 17 00:00:00 2001 From: Colin Murphy Date: Tue, 5 May 2026 10:25:37 -0400 Subject: [PATCH 6/8] fix: pre-download Trustmark models --- .github/workflows/build-test.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 416a6b3..36505d8 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -44,6 +44,21 @@ jobs: SKIP_RUST_BUILD: 1 run: pnpm install --frozen-lockfile + - name: Cache Trustmark models + id: cache-trustmark-models + uses: actions/cache@v4 + with: + path: tmp/trustmark_models + key: trustmark-models-B-v1 + + - name: Download Trustmark models + if: steps.cache-trustmark-models.outputs.cache-hit != 'true' + run: | + mkdir -p tmp/trustmark_models + BASE="https://cai-watermark.adobe.net/watermarking/trustmark-models" + curl -fSL "$BASE/encoder_B.onnx" -o tmp/trustmark_models/encoder_B.onnx + curl -fSL "$BASE/decoder_B.onnx" -o tmp/trustmark_models/decoder_B.onnx + - run: pnpm run ci - run: pnpm run lint From 0a5723c1a9b4b3f7ee777bbd59cf90c5190c454d Mon Sep 17 00:00:00 2001 From: Colin Murphy Date: Tue, 5 May 2026 11:03:06 -0400 Subject: [PATCH 7/8] fix: clippy --- src/neon_builder.rs | 8 +++++--- src/neon_reader.rs | 4 +++- src/neon_trustmark.rs | 10 ++-------- src/utils.rs | 14 ++++++++------ 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/neon_builder.rs b/src/neon_builder.rs index 16def04..70f0fb4 100644 --- a/src/neon_builder.rs +++ b/src/neon_builder.rs @@ -60,7 +60,9 @@ impl NeonBuilder { .with_definition(json.as_str()) .or_else(|err| cx.throw_error(err.to_string()))? } else { - Builder::default().with_definition(&json).or_else(|err| cx.throw_error(err.to_string()))? + Builder::default() + .with_definition(&json) + .or_else(|err| cx.throw_error(err.to_string()))? }; Ok(cx.boxed(Self { @@ -73,7 +75,7 @@ impl NeonBuilder { let this = cx.this::>()?; let intent_str = cx.argument::(0)?.value(&mut cx); let intent: BuilderIntent = serde_json::from_str(&intent_str) - .or_else(|_| cx.throw_error(format!("Invalid intent: {}", intent_str)))?; + .or_else(|_| cx.throw_error(format!("Invalid intent: {intent_str}")))?; let mut builder = rt.block_on(async { this.builder.lock().await }); builder.set_intent(intent); Ok(cx.undefined()) @@ -127,7 +129,7 @@ impl NeonBuilder { // For Json, expect the assertion as a string (JSON) and parse it let assertion_str = cx.argument::(1)?.value(&mut cx); let assertion: serde_json::Value = serde_json::from_str(&assertion_str) - .or_else(|err| cx.throw_error(format!("Invalid JSON: {}", err)))?; + .or_else(|err| cx.throw_error(format!("Invalid JSON: {err}")))?; builder .add_assertion(&label, &assertion) .or_else(|err| cx.throw_error(err.to_string()))?; diff --git a/src/neon_reader.rs b/src/neon_reader.rs index 8251fad..553c717 100644 --- a/src/neon_reader.rs +++ b/src/neon_reader.rs @@ -130,7 +130,9 @@ impl NeonReader { .with_manifest_data_and_stream_async(&c2pa_data, &format, stream) .await? } else { - Reader::default().with_manifest_data_and_stream_async(&c2pa_data, &format, stream).await? + Reader::default() + .with_manifest_data_and_stream_async(&c2pa_data, &format, stream) + .await? }; Ok(reader) diff --git a/src/neon_trustmark.rs b/src/neon_trustmark.rs index 3d27444..596c97c 100644 --- a/src/neon_trustmark.rs +++ b/src/neon_trustmark.rs @@ -242,9 +242,7 @@ pub fn fetch_model(variant: Variant, dir_path: &std::path::Path) -> Result Result Result> { +fn download_model(rt: &tokio::runtime::Runtime, client: &Client, url: &str) -> Result> { rt.block_on(async { let response = client .get(url) diff --git a/src/utils.rs b/src/utils.rs index 7a2f994..6a53855 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -46,25 +46,27 @@ pub fn parse_settings( Some(js_value) => { if js_value.is_a::(cx) { let settings_string = js_value - .downcast::(cx).map_err(|_| Error::Signing(format!("{}: Expected settings string", error_prefix)))? + .downcast::(cx) + .map_err(|_| { + Error::Signing(format!("{error_prefix}: Expected settings string")) + })? .value(cx); // Create context with settings let context = Context::new() .with_settings(settings_string.as_str()) - .map_err(|e| Error::Signing(format!("{}: Invalid settings: {}", error_prefix, e)))?; - + .map_err(|e| { + Error::Signing(format!("{error_prefix}: Invalid settings: {e}")) + })?; Ok(Some(context)) } else if js_value.is_a::(cx) || js_value.is_a::(cx) { Ok(None) } else { Err(Error::Signing(format!( - "{}: Settings must be a string, null, or undefined", - error_prefix + "{error_prefix}: Settings must be a string, null, or undefined", ))) } } None => Ok(None), } } - From a6643152cf820bd63c6ca2e47abf37a3534a08e5 Mon Sep 17 00:00:00 2001 From: Colin Murphy Date: Tue, 5 May 2026 13:30:19 -0400 Subject: [PATCH 8/8] test: Improve tests, add thumbnail redaction test. --- js-src/Builder.spec.ts | 148 +++++++++++++++++++++++++++-------------- 1 file changed, 99 insertions(+), 49 deletions(-) diff --git a/js-src/Builder.spec.ts b/js-src/Builder.spec.ts index 5cff672..7a77a5d 100644 --- a/js-src/Builder.spec.ts +++ b/js-src/Builder.spec.ts @@ -583,7 +583,7 @@ describe("Builder", () => { expect(definition.ingredients![0]).toMatchObject(ingredient); }); - it("should perform redaction workflow", async () => { + it("should redact a thumbnail from an ingredient manifest", async () => { const signerConfig: JsCallbackSignerConfig = { alg: "es256", certs: [publicKey], @@ -594,22 +594,92 @@ describe("Builder", () => { const testSigner = new TestSigner(privateKey); const signer = CallbackSigner.newSigner(signerConfig, testSigner.sign); - // Step 1: Sign source asset with stds.schema-org.CreativeWork assertion - const assertionLabel = "stds.schema-org.CreativeWork"; + // Sign source asset with a thumbnail const step1Builder = Builder.withJson({ claim_generator_info: [{ name: "c2pa_test", version: "1.0.0" }], - title: "Asset With PII", + title: "Asset With Thumbnail", format: "image/jpeg", - instance_id: "step1-1234", + instance_id: "thumb-step1-1234", + thumbnail: { format: "image/jpeg", identifier: "thumbnail.jpg" }, + }); + await step1Builder.addResource("thumbnail.jpg", { + mimeType: "image/jpeg", + buffer: testThumbnail, + }); + const step1Dest = { buffer: null }; + await step1Builder.signAsync(signer, source, step1Dest); + const step1Asset = { + buffer: step1Dest.buffer! as Buffer, + mimeType: "image/jpeg", + }; + + // Verify thumbnail exists in original manifest + const originalReader = await Reader.fromAsset(step1Asset); + expect(originalReader).not.toBeNull(); + const parentLabel = originalReader!.activeLabel(); + expect(parentLabel).toBeDefined(); + const originalManifest = originalReader!.getActive(); + expect(originalManifest?.thumbnail).toBeDefined(); + expect(originalManifest?.thumbnail).not.toBeNull(); + + // Redact the thumbnail + const thumbnailUri = `self#jumbf=/c2pa/${parentLabel}/c2pa.assertions/c2pa.thumbnail.claim`; + const redactionBuilder = Builder.withJson({ + claim_generator_info: [{ name: "c2pa_test", version: "1.0.0" }], + title: "Redacted Thumbnail Manifest", + format: "image/jpeg", + instance_id: "thumb-step2-1234", + }); + redactionBuilder.setIntent("update"); + redactionBuilder.addRedaction(thumbnailUri, "c2pa.PII.present"); + + const step2Dest = { buffer: null }; + await redactionBuilder.signAsync(signer, step1Asset, step2Dest); + + const finalReader = await Reader.fromAsset({ + buffer: step2Dest.buffer! as Buffer, + mimeType: "image/jpeg", + }); + expect(finalReader).not.toBeNull(); + + const store = finalReader!.json(); + const parentManifest = store.manifests?.[parentLabel!]; + expect(parentManifest).toBeDefined(); + expect(parentManifest?.thumbnail).toBeUndefined(); + }); + + it("should redact an assertion from an ingredient manifest", async () => { + const signerConfig: JsCallbackSignerConfig = { + alg: "es256", + certs: [publicKey], + reserveSize: 10000, + tsaUrl: undefined, + directCoseHandling: false, + }; + const testSigner = new TestSigner(privateKey); + const signer = CallbackSigner.newSigner(signerConfig, testSigner.sign); + + // Sign source asset with multiple distinct assertions + const piiLabel = "stds.schema-org.CreativeWork"; + const retainedLabel = "org.contentauth.retained"; + const step1Builder = Builder.withJson({ + claim_generator_info: [{ name: "c2pa_test", version: "1.0.0" }], + title: "Asset With Multiple Assertions", + format: "image/jpeg", + instance_id: "assert-step1-1234", assertions: [ { - label: assertionLabel, + label: piiLabel, data: { "@context": "http://schema.org/", "@type": "CreativeWork", author: [{ "@type": "Person", name: "John Doe" }], }, }, + { + label: retainedLabel, + data: { keep: true }, + }, ], }); const step1Dest = { buffer: null }; @@ -619,42 +689,33 @@ describe("Builder", () => { mimeType: "image/jpeg", }; - // Step 2: Read the signed asset to get manifest label and build JUMBF URI - const parentReader = await Reader.fromAsset(step1Asset); - expect(parentReader).not.toBeNull(); - const parentLabel = parentReader!.activeLabel(); + // Verify both assertions exist in original manifest + const originalReader = await Reader.fromAsset(step1Asset); + expect(originalReader).not.toBeNull(); + const parentLabel = originalReader!.activeLabel(); expect(parentLabel).toBeDefined(); - // Verify the assertion exists in the parent manifest - const parentManifest = parentReader!.getActive(); - expect( - parentManifest?.assertions?.some( - (a: any) => a.label === assertionLabel, - ), - ).toBe(true); - - // Correct JUMBF URI format: self#jumbf=/c2pa/{label}/c2pa.assertions/{assertionLabel} - const redactedUri = `self#jumbf=/c2pa/${parentLabel}/c2pa.assertions/${assertionLabel}`; + const originalStore = originalReader!.json(); + const originalLabels = originalStore.manifests![parentLabel!].assertions!.map( + (a: any) => a.label, + ); + expect(originalLabels).toContain(piiLabel); + expect(originalLabels).toContain(retainedLabel); - // Step 3: Create update builder that redacts the PII assertion + // Redact only the PII assertion + const redactionUri = `self#jumbf=/c2pa/${parentLabel}/c2pa.assertions/${piiLabel}`; const redactionBuilder = Builder.withJson({ claim_generator_info: [{ name: "c2pa_test", version: "1.0.0" }], - title: "Redacted Manifest", + title: "Redacted Assertion Manifest", format: "image/jpeg", - instance_id: "step2-1234", + instance_id: "assert-step2-1234", }); redactionBuilder.setIntent("update"); - redactionBuilder.addRedaction(redactedUri, "c2pa.PII.present"); + redactionBuilder.addRedaction(redactionUri, "c2pa.PII.present"); const step2Dest = { buffer: null }; - const manifestBytes = await redactionBuilder.signAsync( - signer, - step1Asset, - step2Dest, - ); - expect(manifestBytes.length).toBeGreaterThan(0); + await redactionBuilder.signAsync(signer, step1Asset, step2Dest); - // Step 4: Verify assertion is gone from the ingredient manifest const finalReader = await Reader.fromAsset({ buffer: step2Dest.buffer! as Buffer, mimeType: "image/jpeg", @@ -662,26 +723,15 @@ describe("Builder", () => { expect(finalReader).not.toBeNull(); const store = finalReader!.json(); - const parentInStore = store.manifests?.[parentLabel!]; - expect(parentInStore).toBeDefined(); - - // PII assertion removed from parent manifest - const hasPiiAssertion = parentInStore?.assertions?.some( - (a: any) => a.label === assertionLabel, - ); - expect(hasPiiAssertion).toBe(false); + const parentManifest = store.manifests?.[parentLabel!]; + expect(parentManifest).toBeDefined(); - // Active manifest records c2pa.redacted action and redactions list - const activeLabel = store.active_manifest; - const activeManifest = store.manifests?.[activeLabel!]; - const actionsAssertion = activeManifest?.assertions?.find( - (a: any) => a.label === "c2pa.actions" || a.label === "c2pa.actions.v2", - ); - const hasRedactedAction = (actionsAssertion?.data as any)?.actions?.some( - (a: any) => a.action === "c2pa.redacted", + const assertionLabels = parentManifest?.assertions?.map( + (a: any) => a.label, ); - expect(hasRedactedAction).toBe(true); - expect(activeManifest?.redactions).toContain(redactedUri); + // PII assertion removed, retained assertion still present + expect(assertionLabels).not.toContain(piiLabel); + expect(assertionLabels).toContain(retainedLabel); }); it("should add redactions via addRedaction method", () => {