@nulib/c2pa-signing
v0.1.2
Published
C2PA content credential signing for NUL: embedded and sidecar manifests with RFC 3161 time-stamps
Keywords
Readme
@nulib/c2pa-signing
C2PA content-credential signing as used by Northwestern University Libraries — by Meadow for preservation and pyramid TIFFs, and by iiif-processor for derivatives generated on the fly. It's public so iiif-processor can depend on it, but it's maintained for those uses; it isn't a general-purpose C2PA library.
It wraps @contentauth/c2pa-node and adds:
- RFC 3161 time-stamps that actually get embedded. c2pa-node 0.9.x drops a
CallbackSigner'stsaUrlbefore it reaches c2pa-rs, so this package signs withdirectCoseHandlingand builds the COSE_Sign1 (including thesigTst2time-stamp) itself. - Provenance chains that don't skip generations.
addIngredientFromReader()attaches the parent's own first ingredient rather than the parent, so the parentOf ingredient is built by hand. - Sidecar manifests, both producing them and chaining a derivative to a parent that has one.
- Thumbnails generated with sharp, since c2pa-rs can't decode JPEG-compressed (YCbCr) TIFFs to make its own.
Installation
@contentauth/c2pa-node and sharp are peer dependencies:
npm install @nulib/c2pa-signing @contentauth/c2pa-node sharpc2pa-node is pinned to a narrow range (~0.9.8) because the direct COSE signing depends on c2pa-rs's claim v2
format.
Usage
import { signAsset } from '@nulib/c2pa-signing';
const credentials = {
certificate, // PEM chain, end-entity first; EC P-256
key, // PEM private key
tsaUrl: 'http://timestamp.digicert.com', // optional
reserveSize: 20_000 // optional; must fit the cert chain and time-stamp token
};
// A preservation master: sidecar output leaves the asset byte-for-byte unchanged.
const { manifest: sidecar } = await signAsset({
asset: master, mimeType: 'image/tiff',
title: 'Cat on a Blue Couch', // optional; recorded as the manifest's dc:title
intent: { create: 'http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture' },
actions: [{ action: 'c2pa.created', digitalSourceType: 'http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture' }],
output: 'sidecar',
credentials
});
// A derivative of it, with the manifest embedded and chained to the master via its sidecar.
const { asset: pyramid } = await signAsset({
asset: pyramidTiff, mimeType: 'image/tiff',
intent: 'edit',
actions: [{ action: 'c2pa.transcoded', softwareAgent: 'Meadow' }],
parent: { asset: master, mimeType: 'image/tiff', manifest: sidecar },
output: 'embedded',
credentials
});For a parent with an embedded manifest, omit parent.manifest; the chain is read from the parent's bytes.
A derivative's ingredient takes its title from the parent's manifest, falling back to parent.title.
signAsset() throws on any failure — including an unreachable or refusing TSA, so a time-stamp is never silently
dropped. Whether to fall back to an unsigned asset is the caller's decision. Everything is buffers in, buffers out;
reading from and writing to storage is up to the caller.
Why we build our own COSE signature (and when to stop)
We'd rather let c2pa-rs build the COSE_Sign1 signature. We don't because, in @contentauth/c2pa-node 0.9.x, a
CallbackSigner never passes its tsaUrl to c2pa-rs, so signatures silently come out without a time-stamp. No
error is raised; the manifest just lacks signature_info.time and any timeStamp.* validation codes.
The cause is in c2pa-js, packages/c2pa-node/src/neon_signer.rs:
impl AsyncSigner for NeonCallbackSignerdoesn't overridetime_authority_url()ortimestamp_request_headers(), so c2pa-rs's defaults returnNone. The async signing path (builder.signAsync) asks exactly those methods for the TSA, finds none, and skips the time-stamp.- The
tsaUrlis wired into separateTimeStampProvider/AsyncTimeStampProviderimpls, which the signing path doesn't consult, and into the JStimeAuthorityUrl()getter. That's why loggingsigner.timeAuthorityUrl()shows the URL even though it's never used. LocalSignerdoes time-stamp, but it only works with the synchronousbuilder.sign(), which blocks the event loop for the whole signing operation, including the network round trip to the TSA.
The upstream fix is two methods added to that AsyncSigner impl:
fn time_authority_url(&self) -> Option<String> { self.config.tsa_url.clone() }
fn timestamp_request_headers(&self) -> Option<Vec<(String, String)>> { self.config.tsa_headers.clone() }So we set directCoseHandling: true and build the signature ourselves in src/cose.ts (COSE structure, sigTst2
time-stamp, padding to reserveSize) and src/rfc3161.ts (the TSA request). That code closely follows c2pa-rs's
sdk/src/crypto/cose/sign.rs (sign_v2_embedded) and sigtst.rs (add_sigtst_header). In direct mode c2pa-rs
embeds our bytes as-is and skips its own verify-after-sign check, so the integration tests' read-back is the only
check that they're correct.
As of 2026-09-28 (c2pa-node 0.9.8, c2pa-rs 0.91.0) this was still unfixed on c2pa-js main, and we found no open
c2pa-js issue or PR for it.
Watch for, then remove
- Watch c2pa-js releases of
@contentauth/c2pa-nodefor a change toNeonCallbackSigner'sAsyncSignerimpl inneon_signer.rs, or a changelog entry aboutCallbackSignertime-stamps. - Check a new version by bumping c2pa-node, setting
directCoseHandling: falseinsrc/signer.ts(keepingtsaUrlin the config), and runningnpm run test:integration. The time-stamp test must still findtimeStamp.validatedandsignature_info.time. - Remove
src/cose.ts,src/rfc3161.ts, their unit tests, and thecbor-x/@peculiar/*dependencies, and update the rationale comment insrc/signer.ts. - Keep the rest. Hand-built ingredients, sidecar
manifest_data+validation_results, and sharp thumbnails work around separate c2pa-rs behavior (see the list at the top) and don't depend on this fix.
Direct signing also ties us to c2pa-rs's claim v2 signature format, which is why @contentauth/c2pa-node is pinned
to ~0.9.8. If a c2pa-node upgrade breaks the integration tests before the upstream fix lands, check that c2pa-rs
changes to sign.rs / sigtst.rs haven't drifted from what src/cose.ts builds.
Caveats
- Embedded TIFFs carry the manifest in a trailing, manifest-only IFD
(c2pa-rs#1894). libvips/sharp (0.35) tolerates it and
decodes every pyramid level, including from a stream, and the integration tests check that. Strict libtiff tools
such as
tiffinforeportMissingRequired: ImageLength. - Only C2PA claim v2 is supported, which is what c2pa-node's
Builderproduces. - Only es256 signing is wired up.
Development
npm test # unit tests (jest)
npm run test:integration # real c2pa-node addon; the time-stamp test calls a live TSASet C2PA_TEST_TSA_URL to use a different TSA, or to an empty string to skip that test offline.
