npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nulib/c2pa-signing

v0.1.2

Published

C2PA content credential signing for NUL: embedded and sidecar manifests with RFC 3161 time-stamps

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's tsaUrl before it reaches c2pa-rs, so this package signs with directCoseHandling and builds the COSE_Sign1 (including the sigTst2 time-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 sharp

c2pa-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 NeonCallbackSigner doesn't override time_authority_url() or timestamp_request_headers(), so c2pa-rs's defaults return None. The async signing path (builder.signAsync) asks exactly those methods for the TSA, finds none, and skips the time-stamp.
  • The tsaUrl is wired into separate TimeStampProvider / AsyncTimeStampProvider impls, which the signing path doesn't consult, and into the JS timeAuthorityUrl() getter. That's why logging signer.timeAuthorityUrl() shows the URL even though it's never used.
  • LocalSigner does time-stamp, but it only works with the synchronous builder.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

  1. Watch c2pa-js releases of @contentauth/c2pa-node for a change to NeonCallbackSigner's AsyncSigner impl in neon_signer.rs, or a changelog entry about CallbackSigner time-stamps.
  2. Check a new version by bumping c2pa-node, setting directCoseHandling: false in src/signer.ts (keeping tsaUrl in the config), and running npm run test:integration. The time-stamp test must still find timeStamp.validated and signature_info.time.
  3. Remove src/cose.ts, src/rfc3161.ts, their unit tests, and the cbor-x / @peculiar/* dependencies, and update the rationale comment in src/signer.ts.
  4. 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 tiffinfo report MissingRequired: ImageLength.
  • Only C2PA claim v2 is supported, which is what c2pa-node's Builder produces.
  • 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 TSA

Set C2PA_TEST_TSA_URL to use a different TSA, or to an empty string to skip that test offline.

License

MIT