Adding and signing a manifest
Accessing a private key and certificate directly from the file system as shown in these examples is fine during development, but doing so in production is not secure. Instead use a Key Management Service (KMS) or a hardware security module (HSM) to access the certificate and key; For more information, see Using a certificate in production.
- Rust
- C++
- Python
- JavaScript
- Node.js
This is an example of how to assign a manifest to an asset and sign the claim using Rust.
Configure a Context with signer settings and use Builder::from_context to create and sign a manifest:
use c2pa::{Context, Builder, Result};
use serde_json::json;
use std::io::Cursor;
fn main() -> Result<()> {
let settings = json!({
"signer": {
"local": {
"alg": "ps256",
"sign_cert": "path/to/cert.pem",
"private_key": "path/to/key.pem",
"tsa_url": "http://timestamp.digicert.com"
}
},
"builder": {
"claim_generator_info": { "name": "My App", "version": "1.0" },
"intent": { "Create": "digitalCapture" }
}
});
let context = Context::new()
.with_settings(settings)?;
let mut builder = Builder::from_context(context)
.with_definition(json!({"title": "My Image"}))?;
let mut source = std::fs::File::open("source.jpg")?;
let mut dest = Cursor::new(Vec::new());
builder.save_to_stream("image/jpeg", &mut source, &mut dest)?;
Ok(())
}
This is an example of how to assign a manifest to an asset and sign the claim using C++:
#include <iostream>
#include <memory>
#include <string>
#include "c2pa.hpp"
using namespace c2pa;
const std::string manifest_json = R"({
"claim_generator_info": [
{
"name": "c2pa-cpp test",
"version": "0.1"
}
],
"assertions": [
{
"label": "c2pa.training-mining",
"data": {
"entries": {
"c2pa.ai_generative_training": { "use": "notAllowed" },
"c2pa.ai_inference": { "use": "notAllowed" },
"c2pa.ai_training": { "use": "notAllowed" },
"c2pa.data_mining": { "use": "notAllowed" }
}
}
}
]
})";
auto context = std::make_shared<c2pa::Context>();
auto builder = c2pa::Builder(context, manifest_json);
Signer signer = c2pa::Signer("es256", certs, private_key, "http://timestamp.digicert.com");
auto manifest_data = builder.sign("source_asset.jpg", "output_asset.jpg", signer);
This is an example of how to assign a manifest to an asset and sign the claim using Python.
Use a Builder object to add a manifest to an asset.
import json
from c2pa import Builder, Context, Signer, C2paSignerInfo, C2paSigningAlg
manifest_json = json.dumps({
"claim_generator": "python_test/0.1",
"assertions": []
})
try:
with open("tests/fixtures/ps256.pub", "rb") as cert_file, \
open("tests/fixtures/ps256.pem", "rb") as key_file:
cert_data = cert_file.read()
key_data = key_file.read()
signer_info = C2paSignerInfo(
alg=C2paSigningAlg.PS256,
sign_cert=cert_data,
private_key=key_data,
ta_url=b"http://timestamp.digicert.com"
)
with Context() as ctx:
with Signer.from_info(signer_info) as signer:
with Builder(manifest_json, ctx) as builder:
# Add an ingredient from a stream.
ingredient_json = json.dumps({
"title": "A.jpg",
"relationship": "parentOf"
})
with open("tests/fixtures/A.jpg", "rb") as ingredient_file:
builder.add_ingredient(ingredient_json, "image/jpeg", ingredient_file)
# Sign and write to the output file.
with open("tests/fixtures/A.jpg", "rb") as source, \
open("target/out.jpg", "w+b") as dest:
builder.sign(signer, "image/jpeg", source, dest)
except Exception as err:
print(err)
Using a local signer
You can use c2pa-web in the browser by implementing the Signer interface. That callback can use local cryptographic material (for example a non-extractable CryptoKey from Web Crypto) so the private key never leaves the client. The current WASM integration uses direct COSE handling: sign receives the claim bytes and must return a complete, encoded COSE signature for C2PA (certificates, timestamps as required by your policy, and the signature itself). A raw ECDSA or RSA signature from crypto.subtle.sign is not sufficient on its own. Teams often implement that COSE step in a hardened signing service; doing it entirely in client JavaScript is possible but requires a COSE/C2PA-aware encoder on top of Web Crypto.
Use @contentauth/c2pa-web to build manifests and sign assets in the browser.
The high-level flow is:
- Call
await createC2pa({ wasmSrc, settings? }) - Call
c2pa.builder.new()/fromDefinition/fromArchive - Add actions, ingredients, thumbnails
- Call
signorsignAndGetManifestBytes.- To publish a remote manifest instead of embedding, call
setRemoteUrlandsetNoEmbed(true)before this call.
- To publish a remote manifest instead of embedding, call
- Call
await builder.free()andc2pa.dispose().
You implement the Signer interface (alg, reserveSize, sign) so the WASM layer can obtain a COSE signature for each claim.
Browser pages are exposed to XSS: any script on the page can try to use keys that JavaScript can reach. Prefer non-extractable Web Crypto keys, user-gated imports (file input, navigator.credentials, or hardware where supported), and hardening such as CSP. Do not ship production private keys as PEM strings or other recoverable secrets in frontend bundles. If you use a remote signer instead, treat it like an API that must authorize exactly what is being signed (for example bind requests to a content hash and tight scopes), not as a generic “sign this blob” endpoint for an authenticated session.
For obtaining and packaging certificates and keys outside the browser, see Signing with local credentials.
Embedding signed manifests into binary formats is handled here for supported web formats; for server-side embedding across all formats, use Node, Python, Rust, or C++.
Local Web Crypto signer example
The following shows how to use a P-256 PKCS#8 key imported into Web Crypto inside Signer.sign. You still need to turn the raw signature and your certificate material into the COSE Sign1 bytes C2PA expects (and honor reserveSize). The library does not ship a browser COSE encoder; use your own implementation or a signing path that already returns COSE.
import { createC2pa, type Signer } from '@contentauth/c2pa-web';
import wasmSrc from '@contentauth/c2pa-web/resources/c2pa.wasm?url';
/** Strip PEM headers and decode base64 DER (PKCS#8 private key). */
function pkcs8PemToArrayBuffer(pem: string): ArrayBuffer {
const b64 = pem
.replace(/-----BEGIN PRIVATE KEY-----/, '')
.replace(/-----END PRIVATE KEY-----/, '')
.replace(/\s/g, '');
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
async function importEs256PrivateKey(pem: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
'pkcs8',
pkcs8PemToArrayBuffer(pem),
{ name: 'ECDSA', namedCurve: 'P-256' },
false,
['sign']
);
}
function createLocalWebCryptoSigner(privateKey: CryptoKey): Signer {
return {
alg: 'es256',
reserveSize: async () => 4096,
sign: async (toBeSigned: Uint8Array, reserveSize: number) => {
const raw = new Uint8Array(
await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
privateKey,
toBeSigned
)
);
// Required for real manifests: build C2PA COSE_Sign1 using `raw`, your
// end-entity certificate chain, timestamp policy, etc., and pad or size
// the result to match `reserveSize`.
void raw;
void reserveSize;
throw new Error(
'Implement COSE Sign1 packaging for C2PA; raw Web Crypto output alone is not enough.'
);
},
};
}
async function run() {
const c2pa = await createC2pa({ wasmSrc });
const resp = await fetch('/image-to-sign.jpg');
const assetBlob = await resp.blob();
// To instead create a minimal manifest definition, use
// const builder = await c2pa.builder.new();
const builder = await c2pa.builder.fromDefinition({
claim_generator_info: [{ name: 'my-app', version: '1.0.0' }],
title: 'My image',
format: 'image/jpeg',
assertions: [],
ingredients: [],
});
await builder.addAction({
action: 'c2pa.created',
digitalSourceType:
'http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture',
});
await builder.setThumbnailFromBlob('image/jpeg', assetBlob);
// Example: load PKCS#8 PEM from a file the user selects (keeps material out of the bundle).
const pem = await new Promise<string>((resolve, reject) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.pem,.key';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) reject(new Error('No file'));
else resolve(await file.text());
};
input.click();
});
const privateKey = await importEs256PrivateKey(pem);
const signer = createLocalWebCryptoSigner(privateKey);
const signedBytes = await builder.sign(signer, assetBlob.type, assetBlob);
const signedBlob = new Blob([signedBytes], { type: assetBlob.type });
const url = URL.createObjectURL(signedBlob);
const a = document.createElement('a');
a.href = url;
a.download = 'signed.jpg';
a.click();
URL.revokeObjectURL(url);
await builder.free();
c2pa.dispose();
}
void run();
Until sign returns valid COSE bytes, builder.sign will not succeed. If you already have a service or module that returns finished COSE for the claim, you can keep using Web Crypto only for the asymmetric step inside that module, or call that module from sign without using fetch to your own backend (for example an in-browser worker with the same origin). The important distinction is where the private key lives and who builds COSE, not whether sign is async.
Using a remote signer
Although you can use c2pa-web to build manifests and sign assets in the browser using a remote signing service, doing so presents a security vulnerability because someone can use an authenticated session to call the signing endpoint to sign any asset.
Use @contentauth/c2pa-web to build manifests and sign assets in the browser.
The high-level flow is:
- Call
await createC2pa({ wasmSrc, settings? }) - Call
c2pa.builder.new()/fromDefinition/fromArchive - Add actions, ingredients, thumbnails
- Call
signorsignAndGetManifestBytes.- To publish a remote manifest instead of embedding, call
setRemoteUrlandsetNoEmbed(true)before this call.
- To publish a remote manifest instead of embedding, call
- Call
await builder.free()andc2pa.dispose().
You implement the Signer interface (alg, reserveSize, sign) so signing can call your backend, WebCrypto, or a test key.
Never embed production private keys in client-side code. Instead use a remote signer (your API, KMS, or HSM) and harden the page against XSS.
Embedding signed manifests into binary formats is handled here for supported web formats; for server-side embedding across all formats, use Node, Python, Rust, or C++.
Remote signer example
import { createC2pa, type Signer } from '@contentauth/c2pa-web';
import wasmSrc from '@contentauth/c2pa-web/resources/c2pa.wasm?url';
function createRemoteSigner(): Signer {
return {
alg: 'es256',
reserveSize: async () => 4096,
sign: async (toBeSigned: Uint8Array) => {
const res = await fetch('/api/sign', {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: toBeSigned,
});
if (!res.ok) throw new Error('Signing failed');
return new Uint8Array(await res.arrayBuffer());
},
};
}
async function run() {
const c2pa = await createC2pa({ wasmSrc });
const resp = await fetch('/image-to-sign.jpg');
const assetBlob = await resp.blob();
// To instead create a minimal manifest defiintion, use
// const builder = await c2pa.builder.new();
const builder = await c2pa.builder.fromDefinition({
claim_generator_info: [{ name: 'my-app', version: '1.0.0' }],
title: 'My image',
format: 'image/jpeg',
assertions: [],
ingredients: [],
});
await builder.addAction({
action: 'c2pa.created',
digitalSourceType:
'http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture',
});
await builder.setThumbnailFromBlob('image/jpeg', assetBlob);
const signer = createRemoteSigner();
const signedBytes = await builder.sign(signer, assetBlob.type, assetBlob);
const signedBlob = new Blob([signedBytes], { type: assetBlob.type });
const url = URL.createObjectURL(signedBlob);
const a = document.createElement('a');
a.href = url;
a.download = 'signed.jpg';
a.click();
URL.revokeObjectURL(url);
await builder.free();
c2pa.dispose();
}
void run();
Use the Builder and LocalSigner classes from @contentauth/c2pa-node to assemble manifest data and sign an asset.
Create a builder
import { Builder } from '@contentauth/c2pa-node';
// Default settings
const builder = Builder.new();
// With settings (JSON object or string; see [Settings](../settings.mdx))
const withSettings = Builder.new({
builder: { generate_c2pa_archive: true },
});
// From an existing manifest definition (see manifest JSON reference)
const fromDefinition = Builder.withJson({
claim_generator_info: [{ name: 'my-app', version: '1.0.0' }],
title: 'My image',
format: 'image/jpeg',
assertions: [],
resources: { resources: {} },
});
Add assertions and resources
builder.addAssertion('c2pa.actions', {
actions: [{ action: 'c2pa.created' }],
});
await builder.addResource('resource://example/thumb', {
buffer: thumbnailBytes,
mimeType: 'image/jpeg',
});
Sign with a local certificate and key
LocalSigner.newSigner takes the signing certificate (PEM), private key (PEM), algorithm (es256, ps256, ed25519, etc.), and an optional RFC 3161 timestamp URL.
import { Builder, LocalSigner } from '@contentauth/c2pa-node';
import { readFile } from 'node:fs/promises';
const cert = await readFile('signer.pem');
const key = await readFile('signer.key');
const signer = LocalSigner.newSigner(cert, key, 'es256');
const builder = Builder.withJson({
claim_generator_info: [{ name: 'my-app', version: '1.0.0' }],
title: 'output.jpg',
format: 'image/jpeg',
assertions: [],
resources: { resources: {} },
});
builder.setIntent('edit');
// Output to a file
builder.sign(signer, { path: 'input.jpg' }, { path: 'signed.jpg' });
Sign to an in-memory buffer
Use a destination object with buffer: null; after sign, the signed asset bytes are written into dest.buffer.
const dest = { buffer: null };
builder.sign(signer, { path: 'input.jpg' }, dest);
const signedBytes = dest.buffer;
Callback signing (signAsync)
For signing in hardware, a remote service, or other custom flows, use CallbackSigner and signAsync:
import { Builder, CallbackSigner } from '@contentauth/c2pa-node';
import { readFile } from 'node:fs/promises';
const cert = await readFile('signer.pem');
const callbackSigner = CallbackSigner.newSigner(
{
alg: 'es256',
certs: [cert],
reserveSize: 1024,
},
async (data) => {
return customSign(data);
},
);
const builder = Builder.new();
await builder.signAsync(
callbackSigner,
{ path: 'input.jpg' },
{ path: 'signed.jpg' },
);
Replace customSign with your implementation that returns the detached signature bytes for the C2PA claim.
For identity assertions (CAWG), see IdentityAssertionBuilder and IdentityAssertionSigner in the c2pa-node-v2 README.