Skip to content
Open
Show file tree
Hide file tree
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
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions js-src/Builder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
50 changes: 50 additions & 0 deletions js-src/Builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
C2paSettings,
CallbackSignerInterface,
ClaimVersion,
DataHash,
DestinationAsset,
FileAsset,
IdentityAssertionSignerInterface,
Expand All @@ -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) {}

Expand Down Expand Up @@ -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<Buffer> {
return getNeonBinary().builderSignDataHashedEmbeddableAsync.call(
this.builder,
signer.getHandle(),
serializeDataHash(dataHash),
format,
);
}

getManifestDefinition(): Manifest {
return JSON.parse(
getNeonBinary().builderManifestDefinition.call(this.builder),
Expand Down
10 changes: 10 additions & 0 deletions js-src/index.node.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ declare module "index.node" {
input: SourceAsset,
output: DestinationAsset,
): Promise<Buffer | { manifest: Buffer; signedAsset: Buffer }>;
export function builderSignDataHashedEmbeddable(
signer: NeonLocalSignerHandle,
dataHashJson: string,
format: string,
): Buffer;
export function builderSignDataHashedEmbeddableAsync(
signer: NeonCallbackSignerHandle,
dataHashJson: string,
format: string,
): Promise<Buffer>;
export function builderManifestDefinition(): string;
export function builderUpdateManifestProperty(
property: string,
Expand Down
66 changes: 66 additions & 0 deletions js-src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <raw sha256 bytes>, 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.
Expand Down Expand Up @@ -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<Buffer>;

/**
* Getter for the builder's manifest definition
* @returns The manifest definition
Expand Down
8 changes: 8 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
81 changes: 80 additions & 1 deletion src/neon_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<JsBuffer> {
let rt = runtime();
let this = cx.this::<JsBox<Self>>()?;
let signer = cx.argument::<JsBox<NeonLocalSigner>>(0)?;
let data_hash_json = cx.argument::<JsString>(1)?.value(&mut cx);
let format = cx.argument::<JsString>(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<JsPromise> {
let rt = runtime();
let channel = cx.channel();

let this = cx.this::<JsBox<Self>>()?;
let signer = cx.argument::<JsBox<NeonCallbackSigner>>(0)?;
let signer_ref: &NeonCallbackSigner = signer.deref();
let signer = signer_ref.clone();
let data_hash_json = cx.argument::<JsString>(1)?.value(&mut cx);
let format = cx.argument::<JsString>(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 = <NeonCallbackSigner as AsyncSigner>::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::<JsValue>())
}
Err(err) => cx.throw_error(err.to_string()),
});
});
Ok(promise)
}

pub fn manifest_definition(mut cx: FunctionContext) -> JsResult<JsValue> {
let rt = runtime();
let this = cx.this::<JsBox<Self>>()?;
Expand Down