Sign with your key
@sealpixel/c2pa-sign writes a C2PA manifest into a rendered file and signs it with a key you hold. It takes the manifest definition from @sealpixel/c2pa, attaches the original as the parent, hands the bytes to sign to your signer and returns the signed file. The key never enters the module: your function signs, whether it calls a KMS, a subprocess or a file.
The parts of a signed file, the other ways to sign and the pitfalls are on C2PA, explained for developers.
The package is licensed under the Business Source License 1.1 with an Additional Use Grant of "None": every production use needs the Sealpixel Provenance License, LICENSE-PROVENANCE.md in the repository. The definition in @sealpixel/c2pa stays free, as does everything else.
From render to signed file
import { manifestDefinition } from '@sealpixel/c2pa';
import { loadC2paSign } from '@sealpixel/c2pa-sign';
import { defaultPlugins } from '@sealpixel/plugins';
import { createRenderer } from '@sealpixel/render';
const renderer = createRenderer({ plugins: defaultPlugins });
const result = await renderer.render({ original, editLog, output: { format: 'image/jpeg' } });
const { definition } = await manifestDefinition({ log: editLog, plugins: defaultPlugins });
const c2pa = await loadC2paSign();
const { bytes } = await c2pa.sign({
image: result.bytes,
format: 'image/jpeg',
definition,
parent: { bytes: original, format: 'image/jpeg', title: 'original.jpg' },
signer: {
alg: 'es256',
certificates: chainPem, // end-entity first, then the CA
sign: (data) => kms.sign(data), // the raw signature, r || s for ES256
timestampAuthority: 'http://timestamp.digicert.com',
},
});
await save('signed.jpg', bytes);c2pa.read(bytes, 'image/jpeg') returns the manifest store as c2pa-rs reports it, with validation_state and the validation results, for your own checks before the file leaves your pipeline, or undefined for a file that carries none. A file that only points at a remote manifest throws REMOTE_MANIFEST with the URL: the module reads nothing over the network. The parent is required: without it the manifest would name the rendered file as its own original.
The signer
| Field | What it is |
|---|---|
alg | es256, es384, es512, ps256, ps384, ps512 or ed25519. The key and the certificates must match |
certificates | The chain as PEM text or as DER bytes, end-entity first. c2pa-rs refuses a self-signed signing certificate; a CA with a certificate under it is the minimum, and the signing certificate needs digitalSignature and an extended key usage such as emailProtection |
sign(bytes) | Returns the raw signature over the bytes it is given, synchronously or as a promise. For ES256 that is the fixed-width `r |
timestampAuthority | An RFC 3161 authority, reached with fetch. Without one the signature carries no trusted time |
@sealpixel/c2pa-sign/local has localSigner({ privateKey, certificates, alg }) over node:crypto, for development and tests. In production the key belongs in a KMS or an HSM, and sign calls it.
The signer is called once per file. A render's time is in the hard binding, which hashes the whole file: about 3 ms per MB warm on an Apple M2, with a first call that pays the module's start.
What the signed file holds
One manifest with your claim_generator_info, if the definition carries it, the c2pa.actions assertion from the definition with c2pa.opened added for the parent, the parent as the parentOf ingredient with its hash, the hard binding over the file, and your signature with the chain. A validator shows signingCredential.untrusted until your certificate is on a trust list the validator consults; that is the validator's trust policy, not a defect of the file.
Sealpixel is not a conformant claim generator and does not sign as one: the product that signs is yours, and the C2PA Conformance Program evaluates that product.
Runtime
The module is c2pa-rs compiled to WebAssembly, beside the core, so the core's size does not change. It loads on Node, Deno, Bun and Lambda through loadC2paSign(); the package's tests sign and read back on Node, Deno and Bun, with the local signer over node:crypto on each. A browser entry exists so a bundle resolves, but signing belongs where the key is. The timestamp authority is reached through the global fetch; its test needs the network and runs on request, with SEALPIXEL_TSA=http://timestamp.digicert.com. The module is about 9 MB of WebAssembly, about 2.1 MB over the wire with brotli; node scripts/check-wasm-budget.mjs prints the current numbers.