Sign with your key
@sealpixel/c2pa-sign writes a C2PA manifest into a rendered file and signs it with a key that you hold. It takes the manifest definition from @sealpixel/c2pa, attaches the original as the parent, gives the bytes to sign to your signer and returns the signed file. The key never enters the module. Your function signs, and it can call a KMS, a subprocess or a file.
C2PA, explained for developers shows the parts of a signed file, the other ways to sign and the pitfalls.
The package has 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. Everything else stays free too.
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. It returns undefined for a file that carries no manifest store. 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. The minimum is a CA with a certificate under it. The signing certificate needs digitalSignature and an extended key usage such as emailProtection |
sign(bytes) | Returns the raw signature over the bytes that it gets, directly or as a promise. For ES256 that is the fixed-width r || s. Node gives that form with dsaEncoding: 'ieee-p1363'. A KMS that returns DER needs a conversion |
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 module calls the signer once per file. The time of a signing goes into the hard binding, which hashes the whole file: about 3 ms per MB when warm, on an Apple M2. The first call also pays the start of the module.
What the signed file holds
One manifest. It holds your claim_generator_info when 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 that the validator consults. That is the trust policy of the validator, 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.
c2pa-rs writes a version 2 claim by default. The public Content Credentials Verify site shows your claim_generator_info as "App or device used" only for a version 1 claim. For a version 2 claim it shows the subject of your certificate instead. A version 1 claim validates the same and carries the same actions. Set claim_version: 1 in the definition when that viewer matters to you:
definition: { ...definition, claim_generator_info: [{ name: 'Your product', version: '3.1.0' }], claim_version: 1 },Runtime
The module is c2pa-rs compiled to WebAssembly, beside the core, so the size of the core does not change. It loads on Node, Deno, Bun and Lambda through loadC2paSign(). The tests of the package sign and read back on Node, Deno and Bun, with the local signer over node:crypto on each. A browser entry exists so that 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.