Content Credentials - engineering & operator guide

signed by Lollycontent-credentials-engineering.htmlHTMLAI generatedgenerated by ClaudeVerifica tu stessoGet the signed filepixels, not shapes11 KB

The engineering companion to Content Credentials identity (the user-facing page). This covers the device/CA architecture, the engine contracts, the CA service, the web-shell wiring, the one-time operator setup, the threat model and the roadmap.

Versioning. CA-issued signing first shipped at engine 1.11. The capability bridge is additive-only, so everything here still holds, but the verifier has grown since: it now reads both C2PA 1.x and 2.x claims (so credentials from Gemini, Adobe and other generators verify), Lolly writes 2.x by default and the trust list bundles the public C2PA/CAI anchors alongside the Lolly root. Read the live ENGINE_VERSION in engine/src/version.ts and its changelog in engine/CHANGELOG.md rather than trusting a pinned number here. Source line numbers are deliberately omitted below - grep the named symbol; offsets drift.

Architecture

┌───────────── device (open source) ─────────────┐   ┌─ serverless function ─┐
│ WebCrypto P-256 keypair (non-extractable, IDB) │   │ /api/ca/* function     │
│ shells/web/src/bridge/identity.ts              │──▶│ services/ca/handler    │
│   enroll: popup OIDC → PoP → cert (cached)     │◀──│  - OIDC verify         │
│   signer(): {sign, chain} while cert valid     │   │  - PoP verify          │
│ export path: embedC2pa(..., {signer})          │   │  - X.509 issue (ES256) │
│ verify path: verifyC2pa(..., {trustAnchors})   │   │ CA key: env/KMS only   │
└────────────────────────────────────────────────┘   └────────────────────────┘

Enrollment is app-level. Signing reached the bridge later, deliberately. No tool can start, observe or inspect enrolment: there is no host.identity, the profile UI owns the flow and the ordinary export path consumes the signer inside the shell's own export implementation. What is on the bridge is host.c2pa.sign (v1.85, widened v1.104) - optional, additive and not gated by a capabilities flag, so a tool feature-detects host.c2pa?.sign. It exists for the case where the tool, not the export pipeline, authors the output file: a redaction that must ship as a new work rather than carry the un-redacted original as an ingredient, or an authorship claim stamped onto a file the user brought in. It routes to the shell's signFreshC2pa, which asks host.identity.signer() first - so it signs with the enrolled identity when a valid certificate is cached, and falls back to the ephemeral key otherwise. Everything stays on-device; nothing is uploaded.

The consequence is worth stating rather than discovering: a hook that calls it can put the user's CA-verified identity onto bytes of its own choosing. That is the point of the API (the tool is the author here), so tool review is the control - the same control that covers a {{{x}}} in a template. Two shipping tools use it: community/redact and community/claim. The engine additions remain ordinary options (opts.signer, opts.trustAnchors) on existing pure functions.

Engine contracts

HostV1 grows only by addition, and host.c2pa (above) is its one identity-adjacent surface. The relevant modules:

engine/src/x509.ts (DER/X.509 authority)

Pure module shared by the ephemeral path, the CA service and tests. No DOM, globalThis.crypto only. It owns the DER writer helpers (der, derSeq, derSet, derOctet, derUint, derOid, derTime, ecdsaRawToDer) and generateSigner (c2pa.ts re-imports them - byte-identical output, so the existing c2pa test suite is the regression harness), plus:

export function pemToDer(pem)                      // -> Uint8Array
export function derToPem(der, label)               // 'CERTIFICATE' | 'PRIVATE KEY'
export async function generateCaRoot({ commonName, organization, days })
  // -> { certDer, pkcs8Der }  self-signed CA:TRUE root, ES256 P-256, keyCertSign
export async function issueLeafCert({
  caCertDer, caPrivateKey,                          // issuer (CryptoKey or pkcs8 Uint8Array)
  spkiDer,                                          // subject public key (CSR-less PoP flow)
  email, commonName, organization,                  // SAN rfc822Name + CN + O
  days,                                             // enrollment default 30
})  // -> Uint8Array cert DER

c2pa-rs compatibility is non-negotiable in the leaf profile (it hard-fails otherwise): subject MUST carry an O= attribute, EKU MUST be id-kp-emailProtection (1.3.6.1.5.5.7.3.4 - anyEKU is rejected), keyUsage digitalSignature critical, SKI + AKI present, plus SAN rfc822Name = the verified email. Serial: random with the ephemeral path's stable-width trick.

engine/src/c2pa.ts

embedC2pa(bytes, format, opts) and embedC2paInPdf(bytes, opts) take one optional signer. buildC2paManifest already threads it; the embedders use opts.signer ?? await generateSigner(dates):

opts.signer = {
  privateKey,                        // CryptoKey (P-256, 'sign') - the normal device path
  // OR sign: async (sigStructureBytes) => Uint8Array(64)  // raw r||s, NOT DER
  certDer,                           // leaf DER (back-compat single-cert shape)
  chain,                             // Uint8Array[] leaf-first - wins over certDer in x5chain
}

Inside the COSE signing step: the x5chain (label 33) becomes signer.chain ?? [signer.certDer], and the sign call becomes signer.sign ? await signer.sign(sigStructure) : subtle.sign(…, signer.privateKey, sigStructure). Two-pass safety: chain bytes are captured once per embed (byte-identical across passes), ES256 signatures are fixed 64 bytes, alg stays hardcoded −7 - P-256 only by contract. sign() runs several times per embed (probe + fixed-point rounds + pass 2), fine for a WebCrypto key; a future user-presence key would need dummy probe signatures.

engine/src/c2pa-verify.ts

verifyC2pa(bytes, { trustAnchors } = {})   // trustAnchors: Uint8Array[] (root cert DER)

Zero-options behaviour stays byte-identical (contract tests guard it). With anchors supplied, it captures the full x5chain and verifies leaf-signed-by-anchor (issuer-name DER bytes match anchor subject bytes + a signature check over the leaf's tbsCertificate using the anchor SPKI). parseCertificate is extended additively (tbsBytes, rawSignature, issuer/subject bytes, SAN emails, signature algorithm) - existing fields unchanged. It reads both c2pa.claim and c2pa.claim.v2 (with created_assertions/gathered_assertions and c2pa.actions.v2), and multi-algorithm chains (ECDSA P-256/384/521, RSA PKCS#1 v1.5, RSA-PSS, Ed25519) walk an arbitrary but bounded depth to a pinned anchor.

Report verdict semantics (surfaced by the /verify view):

CLI

lolly validate <file> [--json] [--deep] [--trust-anchor <root.pem>] [--no-default-anchors] - same verifier, same report; --trust-anchor loads PEM → DER and appends to trustAnchors, and --deep adds the neural pixel-watermark scan (browser tier). The default anchor set is the Lolly CA root plus the vendored C2PA known-certificate list, identical to the web /valid view and to MCP's lolly_verify (plans/73-cli-ga-contract.md, section 12 O1) - so a Lolly-CA-signed export reads the same on every surface. --no-default-anchors drops both built-in sets for a bare-trust check, and every report prints which set produced the verdict.

Beyond C2PA: pixel- and byte-level verify reads

The web /verify view runs several checks around the credential. Each is a pure, DOM-free engine (or shell-lib) module fed a decoded RGBA buffer or the raw file bytes by the shell; none is a bridge capability, and none uploads anything - the deep-scan model download (same-origin, opt-in, one-time) is the only network touch, and it never sends the file. The web shell deliberately passes no SEAL key resolver - a DNS-published key reports "no key resolver" instead of being fetched through a third-party DoH service; the Node shells (CLI/TUI/desktop) resolve keys through the machine's own DNS.

Each read has its own surface in the view, catalogued in the component library beside the module that defines it - here, the verdict-hero entry: the verdict states it covers and the valid.ts source behind them:

The component-library entry for the verify hero - the verdict states it covers (Made with Lolly, Verified, broken) beside the valid.ts source that defines themsigned by Lollyvector SVGVerifica tu stessoGet the signed file15 paths~8.1k nodes15 groups86 KBThe component-library entry for the verify hero - the verdict states it covers (Made with Lolly, Verified, broken) beside the valid.ts source that defines themsigned by Lollyvector SVGVerifica tu stessoGet the signed file15 paths~8.1k nodes15 groups86 KB

engine/src/pixel-watermark.ts - the Lolly Imprint

Block-DCT spread-spectrum watermark (Cox/Kilian/Leighton/Shamoon) on the same 8×8 grid JPEG's own DCT uses, so it survives recompression. embedWatermark(rgba, opts) adds a fixed ±1 chip to mid-band luma coefficients scaled by a perceptual mask; detectWatermark(rgba, {width, height}) correlates and returns a presence score (DetectResult); canCarryWatermark(w, h) gates images too small to hold the mark (MIN_IMPRINT_BLOCKS). Presence-only, no payload. Security posture is obscurity, not a hardened defence - the chip key ships in this public source, so a motivated adversary who reads it can subtract the mark. It's honest cover against casual re-encoding/stripping, framed exactly like the self-signed on-device C2PA key (plans/30-lollys-own-synth.md). The shell embeds it two ways: the standalone raster encoders' opts.imprint branch, and imprintEmbedCanvas baking the mark into each Lolly-rendered raster as it's composited into a PDF page / PPTX slide - so a pure-vector page marks nothing, and the C2PA claim is gated on whether a mark was actually applied, never over-claimed. On by default; ?imprint=0 opts out.

engine/src/steganalysis.ts - LSB chi-square

analyzeLsb(rgba, {width, height}) runs a chi-square test on pixel-pair LSB statistics and returns a likelihood that the image hides LSB-embedded data. It's a heuristic (amber), never a cryptographic verdict; the view renders it as an LSB steganography likely pip plus an LSB analysis metadata row.

engine/src/file-metadata.ts - appended payloads

extractFileMetadata(bytes) reads EXIF/XMP/IPTC and, for provenance, detects bytes appended after a container's real end (PNG IEND, JPEG EOI, GIF trailer, APNG), returning them as an appended field the view renders with view/download of the extracted bytes (rendered only as escaped hex/text - never parsed or executed). The legitimate motion-photo append (kind: 'video (motion photo)') is recognised and shown without a warning. This read is bytes-only, so MCP surfaces it in its metadata output too (it lacks the interactive extractor, but sees the same appended field).

engine/src/seal.ts - SEAL cryptographic signatures

verifySeal(bytes, resolveKey?) parses a hackerfactor SEAL record and verifies its signature over the covered byte ranges. The resolveKey resolver is optional and shell-supplied: the web shell deliberately passes none (zero network requests - a DNS-keyed record reports "no key resolver" there), while the Node shells (CLI/TUI/desktop) can supply one backed by the machine's own system DNS. This is a byte-signature format - not a pixel watermark, and NOT Meta's Content Seal despite the shared word. A key resolved from DNS yields domain attribution (Signed by <domain> (SEAL), proving domain control, not a CA-verified legal identity); a key the file itself carries yields an internally-consistent-but-unattributed result. In the web app there is no network touch at all; in the Node shells the only network touch is a system-DNS key lookup - the file never leaves the device either way.

Deep scan: trustmark.ts + contentseal.ts (web, opt-in)

Two open pixel-watermark decoders behind a one-time consent - a ~90 MB detector download gated by a single batch-level banner, after which the scan is passive per file (shells/web/src/lib/trustmark.ts and contentseal.ts, with engine/src/contentseal.ts for the DOM-free decode math):

Everything is on-device; nothing but the one-time model bytes is fetched.

TrustMark's write side is one switch in the export panel, off by default for the same reason its scan is opt-in: a neural pass plus that one-time model download. It sits inside the Content protection card shown further down this page.

Trust anchors

Trusted verification is live - the pinned Lolly root ships as a real PEM in shells/web/src/ca-root.ts (CA_ROOT_PEM, public by design) and is also served at /api/ca/root.pem. The verifier additionally bundles the public C2PA/CAI trust anchors (engine/src/c2pa-trust.ts) so third-party credentials (camera makers, Adobe, Google's C2PA root, Truepic-signed OpenAI output, …) resolve to their real issuer. Presence in a list never trusts on its own - a verdict of trusted requires the chain to actually verify and no other check to fail.

CA service (services/ca/)

Zero-dependency Node (node:http + WebCrypto), importing engine/src/x509.ts. A workspace package. Runs three ways from one handler.mjs:

Protocol

RoutePurpose
GET /api/ca/healthLiveness plus configured - which OIDC providers this deployment actually has credentials for. The profile view builds its provider buttons from it, so a deployment with no OIDC app says so instead of offering a button that would 501
GET /api/ca/root.pemThe public Lolly root - for c2patool --trust_anchors and humans
GET /api/ca/auth/:provider?origin=Start OIDC (suse \github \google); sets HMAC state cookie, redirects to provider
GET /api/ca/callback/:providerCode exchange → verified email → mints a 10-min enrollment token; returns a tiny page that postMessages the token to origin and closes
POST /api/ca/email/start {email, origin, days?}Magic link via Resend; the link returns the user to the app (/#/profile?enrollToken=…), which finishes enrollment via the normal POST /api/ca/enroll PoP exchange
POST /api/ca/enroll {token, spki, pop, days?}Verify token HMAC+expiry, verify PoP (ECDSA over the token bytes with the presented SPKI), issue a leaf valid for days ∈ {7, 30, 90, 365} (anything else → CA_CERT_DAYS, capped at CA_CERT_MAX_DAYS) → {cert, chain, identity, notAfter} (PEM)

Notes:

Environment variables (set in the host's environment store → also .env for local)

VarWhat
CA_ROOT_KEY_PEMPKCS8 PEM of the root private key (the only secret that matters)
CA_ROOT_CERT_PEMPEM of the root cert (public, also committed)
CA_SERVICE_SECRETRandom 32+ bytes; HMAC for state cookies + enrollment tokens
CA_CERT_DAYSDefault leaf lifetime in days when the client doesn't choose (default 30)
CA_CERT_MAX_DAYSHard cap on any requested lifetime (default 365)
CA_ALLOWED_ORIGINSComma list, e.g. https://lolly.tools,http://localhost:5173
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRETGitHub OAuth app
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRETGoogle OAuth client (web)
SUSE_ISSUERhttps://id.suse.com (Keycloak OIDC discovery)
SUSE_CLIENT_ID / SUSE_CLIENT_SECRETid.suse.com OIDC app
RESEND_API_KEY / EMAIL_FROMEmail magic links
CA_DEV_FAKE_PROVIDER1 only in local dev

Web shell

Everything below surfaces as one card in the export panel: the C2PA switch (plus its ephemeral lifetime picker), the pixel Imprint and the opt-in durable mark, each gated per format by the predicates in views/tool-actions.ts.

The Content protection card as the shell assembles it, with one switch per provenance mechanism rather than a single blanket togglesigned by Lollyvector SVGVerifica tu stessoGet the signed file21 paths~1.5k nodes35 groups3 images24 KBThe Content protection card as the shell assembles it, with one switch per provenance mechanism rather than a single blanket togglesigned by Lollyvector SVGVerifica tu stessoGet the signed file21 paths~1.5k nodes35 groups3 images24 KB

The enrolment card is where the certificate lifetime is chosen, before any popup opens. The provider buttons are built from /api/ca/health.configured, so a deployment that has wired up no OIDC app says so instead of offering a button that would 501.

The Content Credentials section of Profile, expanded from the link, with the 7 / 30 / 90 / 365 day certificate lifetime picker above the provider row, which on a deployment with no OIDC app wired up says so instead of offering a buttonsigned by Lollyvector SVGVerifica tu stessoGet the signed file16 paths~10k nodes16 groups112 KBThe Content Credentials section of Profile, expanded from the link, with the 7 / 30 / 90 / 365 day certificate lifetime picker above the provider row, which on a deployment with no OIDC app wired up says so instead of offering a buttonsigned by Lollyvector SVGVerifica tu stessoGet the signed file16 paths~10k nodes16 groups112 KB

Operator runbook (one-time setup - the parts only you can do)

  1. Generate the root (anywhere, then guard the key):

``bash node services/ca/scripts/gen-root.mjs # writes lolly-root-cert.pem + lolly-root-key.pem ``

Commit/paste lolly-root-cert.pem into shells/web/src/ca-root.ts. Never commit the key. Store it in a password manager, then set the following secrets in your platform's environment store (production scope):

`` CA_ROOT_KEY_PEM # paste the key PEM CA_ROOT_CERT_PEM CA_SERVICE_SECRET # e.g. openssl rand -hex 32 CA_ALLOWED_ORIGINS # https://lolly.tools ``

  1. Register OIDC apps (callback URL for all three: https://lolly.tools/api/ca/callback/<provider>; add the http://localhost:8787/api/ca/callback/<provider> variant for dev):
  1. Set the env vars in your platform's environment store (production scope, and add a preview/staging copy so preview deploys can exercise it):

`` CA_ROOT_KEY_PEM # paste the key PEM CA_ROOT_CERT_PEM CA_SERVICE_SECRET # openssl rand -hex 32 CA_ALLOWED_ORIGINS # https://lolly.tools # …plus the provider creds from step 2. Do NOT set CA_DEV_FAKE_PROVIDER in prod. ``

  1. Deploy. The CA lives at repo-root api/ca/[...path].js (a serverless function) importing services/ca/ + engine/src/x509.ts; both are committed, and the platform compiles api/ independently of the Vite build. The app's catch-all rewrite "/((?!api/).)" → /index.html excludes /api/ by pattern (and functions are served before rewrites anyway), so /api/ca/ resolves to the function first - it is not swallowed by the SPA fallback. Confirm it's live: curl https://lolly.tools/api/ca/health should return JSON ({"ok":true,…}), not the SPA's HTML. If it returns HTML, the function wasn't compiled - check the project's root directory setting is the repo root (so repo-root api/ is in scope), not shells/web.
  1. Local dev (no secrets needed thanks to the dev provider):

``bash CA_DEV_FAKE_PROVIDER=1 CA_SERVICE_SECRET=dev node services/ca/server.mjs & npm run dev:web # Vite proxies /api/ca → :8787 ``

Threat model (abridged)

Roadmap

  1. RFC 3161 timestamp countersignature in the same enroll/export round trip → the expired verdict becomes provable-time trusted.
  2. Public append-only issuance transparency log (Rekor-shaped).
  3. C2PA conformance program: audited KMS custody → the official trust list → green in Adobe Verify et al.

CLI signer support has shipped: --sign-key=<key.pem> --sign-cert=<chain.pem>, with $LOLLY_SIGN_KEY/$LOLLY_SIGN_CERT (paths), $LOLLY_SIGN_KEY_PEM/$LOLLY_SIGN_CERT_PEM (the PEM text itself, for CI secret stores) and $LOLLY_SIGN_KEY_PASSWORD for an encrypted key. No flag ever takes key material. See Signing from the terminal.

SSO - two distinct jobs, one identity provider

id.suse.com (Keycloak) shows up in two places that must not be conflated. Both reuse the same OIDC client, but they answer different questions. Detailed implementation plans (local, under plans/): plans/32-sso-signing.md and plans/33-sso-tool-access.md - the summaries below are the roadmap view.

  1. *Complete SUSE SSO for signing*** (deepens the identity in this doc - "who signed"; full plan → plans/32-sso-signing.md). Today SUSE is one enrolment provider among several and each enrolment is a fresh popup. Bring it to true SSO:
  1. *SSO for tool access*** (a NEW authorization axis - "who may use the app," separate from signing; full plan → plans/33-sso-tool-access.md). Gate who can open the app, specific tools, or gated features behind SUSE SSO: