diff --git a/README.md b/README.md index a05155f..cf7d5c9 100644 --- a/README.md +++ b/README.md @@ -404,6 +404,54 @@ const definition = restoredBuilder.getManifestDefinition(); console.log(definition.ingredients); // Contains the ingredient ``` +#### Signing a data-hashed embeddable manifest + +When the asset bytes are not available at sign time but you already know the +asset's hash, use `signDataHashedEmbeddable` (sync) or +`signDataHashedEmbeddableAsync` (async, callback signer). The Builder produces +a signed embeddable manifest binary from the supplied `DataHash`, with no +asset I/O. The caller embeds the result, ships it as a `.c2pa` sidecar, or +hosts it for remote-manifest fetch. + +With `exclusions: []` and `alg: "sha256"`, the resulting `c2pa.hash.data.hash` +equals `SHA-256(asset)` — a verifier with the original file recomputes the +same digest and the manifest validates. + +```javascript +import * as crypto from 'node:crypto'; +import { Builder, LocalSigner } from '@contentauth/c2pa-node'; + +const signer = LocalSigner.newSigner(cert, key, 'es256'); + +const builder = Builder.withJson(manifestDefinition); + +// Pre-computed SHA-256 of the asset (e.g. taken from a manifest, DB row, etc.) +const hash = crypto.createHash('sha256').update(assetBytes).digest(); + +const manifestBytes = builder.signDataHashedEmbeddable( + signer, + { name: 'raw asset', alg: 'sha256', hash, exclusions: [] }, + 'image/jpeg', +); + +// `manifestBytes` is preformatted for the target format (e.g. JPEG APP11 +// segment, MP4 uuid box). Embed it, write a sidecar, or serve as a remote +// manifest. +await fs.writeFile('asset.jpg.c2pa', manifestBytes); +``` + +The async variant takes a `CallbackSigner` (e.g. KMS-backed): + +```javascript +const manifestBytes = await builder.signDataHashedEmbeddableAsync( + callbackSigner, + { alg: 'sha256', hash, exclusions: [] }, + 'image/jpeg', +); +``` + +`DataHash` mirrors the c2pa-rs `DataHash` serde struct: `{ name?, alg?, hash, pad?, exclusions? }`. `hash` and `pad` accept `Buffer`, `Uint8Array`, or `number[]`. `exclusions` is `{ start, length }[]`; pass `[]` for whole-asset hashing. + For complete type definitions, see the [@contentauth/c2pa-types](https://www.npmjs.com/package/@contentauth/c2pa-types) package. ### Signers diff --git a/js-src/Builder.spec.ts b/js-src/Builder.spec.ts index 7a77a5d..8618845 100644 --- a/js-src/Builder.spec.ts +++ b/js-src/Builder.spec.ts @@ -445,6 +445,59 @@ describe("Builder", () => { expect(activeManifest?.title).toBe("Test_Manifest"); }); + it("should sign a pre-computed DataHash (LocalSigner, sync)", async () => { + const signer = LocalSigner.newSigner(publicKey, privateKey, "es256"); + + // Raw SHA-256 of the source asset bytes. With exclusions=[], the + // resulting manifest's c2pa.hash.data.hash equals this digest. + const hash = crypto.createHash("sha256").update(source.buffer).digest(); + + const manifestBytes = builder.signDataHashedEmbeddable( + signer, + { + name: "raw asset", + alg: "sha256", + hash, + exclusions: [], + }, + "image/jpeg", + ); + + expect(Buffer.isBuffer(manifestBytes)).toBe(true); + expect(manifestBytes.length).toBeGreaterThan(0); + // Returned bytes are preformatted for the target format (JPEG APP11 + // segment). The c2pa JUMBF box label "c2pa" appears inside. + expect(manifestBytes.toString("binary")).toContain("c2pa"); + }); + + it("should sign a pre-computed DataHash (CallbackSigner, async)", 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); + + const hash = crypto.createHash("sha256").update(source.buffer).digest(); + + const manifestBytes = await builder.signDataHashedEmbeddableAsync( + signer, + { + alg: "sha256", + hash, + exclusions: [], + }, + "image/jpeg", + ); + + expect(Buffer.isBuffer(manifestBytes)).toBe(true); + expect(manifestBytes.length).toBeGreaterThan(0); + expect(manifestBytes.toString("binary")).toContain("c2pa"); + }); + it("should preserve JSON assertion characters without escaping", async () => { const fingerprintAssertion = JSON.stringify({ alg: "sha256", diff --git a/js-src/Builder.ts b/js-src/Builder.ts index 366ecbc..6b8131b 100644 --- a/js-src/Builder.ts +++ b/js-src/Builder.ts @@ -24,6 +24,7 @@ import type { C2paSettings, CallbackSignerInterface, ClaimVersion, + DataHash, DestinationAsset, FileAsset, IdentityAssertionSignerInterface, @@ -36,6 +37,29 @@ import type { } from "./types.d.ts"; import { IdentityAssertionSigner } from "./IdentityAssertion.js"; +// c2pa-rs `DataHash` uses `#[serde(with = "serde_bytes")]` for `hash` and +// `pad`, which deserializes from a JSON array of u8 (not a `Buffer` object, +// whose default JSON shape is `{ type: "Buffer", data: [...] }`). Normalize +// any byte-like input to `number[]`. `pad` is a required field on the Rust +// side, so default it to an empty array if the caller omits it. +function bytesToNumberArray( + input: Buffer | Uint8Array | number[] | undefined, +): number[] { + if (input === undefined) return []; + if (Array.isArray(input)) return input; + return Array.from(input); +} + +function serializeDataHash(dataHash: DataHash): string { + return JSON.stringify({ + name: dataHash.name, + alg: dataHash.alg, + hash: bytesToNumberArray(dataHash.hash), + pad: bytesToNumberArray(dataHash.pad), + exclusions: dataHash.exclusions, + }); +} + export class Builder implements BuilderInterface { constructor(private builder: NeonBuilderHandle) {} @@ -246,6 +270,32 @@ export class Builder implements BuilderInterface { }); } + signDataHashedEmbeddable( + signer: LocalSignerInterface, + dataHash: DataHash, + format: string, + ): Buffer { + return getNeonBinary().builderSignDataHashedEmbeddable.call( + this.builder, + signer.getHandle(), + serializeDataHash(dataHash), + format, + ); + } + + async signDataHashedEmbeddableAsync( + signer: CallbackSignerInterface, + dataHash: DataHash, + format: string, + ): Promise { + return getNeonBinary().builderSignDataHashedEmbeddableAsync.call( + this.builder, + signer.getHandle(), + serializeDataHash(dataHash), + format, + ); + } + getManifestDefinition(): Manifest { return JSON.parse( getNeonBinary().builderManifestDefinition.call(this.builder), diff --git a/js-src/index.node.d.ts b/js-src/index.node.d.ts index d3660b9..4d0780e 100644 --- a/js-src/index.node.d.ts +++ b/js-src/index.node.d.ts @@ -81,6 +81,16 @@ declare module "index.node" { input: SourceAsset, output: DestinationAsset, ): Promise; + export function builderSignDataHashedEmbeddable( + signer: NeonLocalSignerHandle, + dataHashJson: string, + format: string, + ): Buffer; + export function builderSignDataHashedEmbeddableAsync( + signer: NeonCallbackSignerHandle, + dataHashJson: string, + format: string, + ): Promise; export function builderManifestDefinition(): string; export function builderUpdateManifestProperty( property: string, diff --git a/js-src/types.d.ts b/js-src/types.d.ts index 1f63098..0461f03 100644 --- a/js-src/types.d.ts +++ b/js-src/types.d.ts @@ -106,6 +106,33 @@ export type SourceAsset = SourceBufferAsset | FileAsset; */ export type DestinationAsset = DestinationBufferAsset | FileAsset; +/** + * A pre-computed data hash assertion. Pass to `signDataHashedEmbeddable` to + * produce a signed embeddable manifest without needing the asset bytes at + * sign time (e.g. for sidecar / remote-manifest workflows). + * + * For a spec-compliant raw SHA-256 of the whole asset, use: + * `{ alg: "sha256", hash: , exclusions: [], pad: Buffer.alloc(0) }` + * + * The shape mirrors the c2pa-rs `DataHash` serde struct. `hash` and `pad` + * are serialized as byte arrays (serde_bytes). + */ +export interface DataHash { + /** Optional friendly name (e.g. `"raw asset"`). */ + name?: string; + /** Hash algorithm. Use `"sha256"` for sidecar / spec-compliant flows. */ + alg?: "sha256" | "sha384" | "sha512"; + /** Hash bytes. */ + hash: Buffer | Uint8Array | number[]; + /** Padding bytes. May be empty. */ + pad?: Buffer | Uint8Array | number[]; + /** + * Byte ranges (in the target asset) excluded from the hash. Empty means + * the hash covers the entire asset. + */ + exclusions?: { start: number; length: number }[]; +} + /** * The return type of resourceToAsset. * When the asset is a file, returns the number of bytes written. @@ -321,6 +348,45 @@ export interface BuilderInterface { output: DestinationAsset, ): Buffer; + /** + * Produce a signed embeddable manifest from a pre-computed DataHash. + * + * Unlike `sign`, this does **not** require the asset bytes at sign time — + * the caller has already computed the hash externally. The returned + * manifest binary can be embedded by the caller, or shipped as a sidecar + * / referenced via a remote-manifest URL. + * + * Typical use: sidecar / remote-manifest signing where only the SHA-256 digest of + * the asset is available. With `exclusions: []` and `alg: "sha256"`, + * `c2pa.hash.data.hash` in the resulting manifest equals `SHA-256(asset)`, + * which a verifier can recompute from the original file. + * + * @param signer The local signer + * @param dataHash The pre-computed data hash + * @param format MIME type of the target asset (e.g. `"image/jpeg"`) + * @returns The signed `c2pa_manifest` bytes (preformatted for `format`) + */ + signDataHashedEmbeddable( + signer: LocalSignerInterface, + dataHash: DataHash, + format: string, + ): Buffer; + + /** + * Async variant of `signDataHashedEmbeddable` that uses a CallbackSigner + * (e.g. for KMS-backed or otherwise asynchronous signing). + * + * @param signer The callback signer + * @param dataHash The pre-computed data hash + * @param format MIME type of the target asset + * @returns The signed `c2pa_manifest` bytes + */ + signDataHashedEmbeddableAsync( + signer: CallbackSignerInterface, + dataHash: DataHash, + format: string, + ): Promise; + /** * Getter for the builder's manifest definition * @returns The manifest definition diff --git a/src/lib.rs b/src/lib.rs index 17de09f..d3e9135 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -73,6 +73,14 @@ fn main(mut cx: ModuleContext) -> NeonResult<()> { "builderIdentitySignAsync", neon_builder::NeonBuilder::identity_sign_async, )?; + cx.export_function( + "builderSignDataHashedEmbeddable", + neon_builder::NeonBuilder::sign_data_hashed_embeddable, + )?; + cx.export_function( + "builderSignDataHashedEmbeddableAsync", + neon_builder::NeonBuilder::sign_data_hashed_embeddable_async, + )?; cx.export_function( "builderManifestDefinition", neon_builder::NeonBuilder::manifest_definition, diff --git a/src/neon_builder.rs b/src/neon_builder.rs index 70f0fb4..82e769e 100644 --- a/src/neon_builder.rs +++ b/src/neon_builder.rs @@ -18,7 +18,10 @@ use crate::neon_reader::NeonReader; use crate::neon_signer::{CallbackSignerConfig, NeonCallbackSigner, NeonLocalSigner}; use crate::runtime::runtime; use crate::utils::parse_settings; -use c2pa::{assertions::Action, Builder, BuilderIntent, Ingredient}; +use c2pa::{ + assertions::{Action, DataHash}, + AsyncSigner, Builder, BuilderIntent, Ingredient, Signer, +}; use neon::context::Context as NeonContext; use neon::prelude::*; use neon_serde4; @@ -604,6 +607,82 @@ impl NeonBuilder { Ok(promise) } + /// Sign a pre-computed `DataHash` with a `LocalSigner`. Produces a + /// signed embeddable manifest binary without requiring asset bytes at + /// sign-time. + /// + /// Internally calls `Builder::data_hashed_placeholder` first (required + /// by upstream c2pa-rs to reserve the DataHash assertion slot), then + /// `Builder::sign_data_hashed_embeddable`. The placeholder bytes are + /// discarded — this binding targets sidecar / remote-manifest flows + /// where the caller does not need to embed the placeholder. + pub fn sign_data_hashed_embeddable(mut cx: FunctionContext) -> JsResult { + let rt = runtime(); + let this = cx.this::>()?; + let signer = cx.argument::>(0)?; + let data_hash_json = cx.argument::(1)?.value(&mut cx); + let format = cx.argument::(2)?.value(&mut cx); + + let data_hash: DataHash = serde_json::from_str(&data_hash_json) + .or_else(|err| cx.throw_error(format!("Invalid DataHash JSON: {err}")))?; + + let mut builder = rt.block_on(async { this.builder.lock().await }); + let signer = signer.signer(); + let reserve_size = signer.reserve_size(); + + // Required preamble: reserves DataHash slot in the builder definition. + builder + .data_hashed_placeholder(reserve_size, &format) + .or_else(|err| cx.throw_error(err.to_string()))?; + + let bytes = builder + .sign_data_hashed_embeddable(&**signer, &data_hash, &format) + .or_else(|err| cx.throw_error(err.to_string()))?; + + let buffer = JsBuffer::from_slice(&mut cx, bytes.as_slice())?; + Ok(buffer) + } + + /// Async variant of `sign_data_hashed_embeddable` for `CallbackSigner` + /// (e.g. KMS-backed signing). + pub fn sign_data_hashed_embeddable_async(mut cx: FunctionContext) -> JsResult { + let rt = runtime(); + let channel = cx.channel(); + + let this = cx.this::>()?; + let signer = cx.argument::>(0)?; + let signer_ref: &NeonCallbackSigner = signer.deref(); + let signer = signer_ref.clone(); + let data_hash_json = cx.argument::(1)?.value(&mut cx); + let format = cx.argument::(2)?.value(&mut cx); + + let data_hash: DataHash = serde_json::from_str(&data_hash_json) + .or_else(|err| cx.throw_error(format!("Invalid DataHash JSON: {err}")))?; + + let reserve_size = ::reserve_size(&signer); + + let builder = Arc::clone(&this.builder); + let (deferred, promise) = cx.promise(); + rt.spawn(async move { + let result = async { + let mut b = builder.lock().await; + b.data_hashed_placeholder(reserve_size, &format)?; + b.sign_data_hashed_embeddable_async(&signer, &data_hash, &format) + .await + } + .await; + + deferred.settle_with(&channel, move |mut cx| match result { + Ok(bytes) => { + let buffer = JsBuffer::from_slice(&mut cx, bytes.as_slice())?; + Ok(buffer.upcast::()) + } + Err(err) => cx.throw_error(err.to_string()), + }); + }); + Ok(promise) + } + pub fn manifest_definition(mut cx: FunctionContext) -> JsResult { let rt = runtime(); let this = cx.this::>()?;