Certificates and keys

Read this if you are choosing how your deployment signs Content Credentials, ordering a certificate for a managed key, or replacing a key that is about to expire. You will pick a signing mode, check a certificate against the leaf profile, create a signing request from a key in AWS KMS or Vault, and rotate keys without a restart. Skip if you only want a first marked image (Quickstart). Time: 20 minutes to read; each of the Vault, request and rotation procedures takes 15 to 30 minutes. Needs: pip install "laidline[kms]" for AWS KMS or Vault, and a key in one of them.

In short

  • Laidline signs C2PA manifests, evidence checkpoints and export bundles with your key; with KMS or Vault the private key never leaves it.
  • laidline init makes a development CA: right for trying Laidline, reported as valid but not trusted by every other validator.
  • A trusted certificate comes from a C2PA trust-list CA, for a generator product you list yourself; Laidline lists nothing for you.
  • The leaf must meet the profile below; a certificate that does not stops the process at start with the failing check named.
  • Rotate with a keyring and laidline keys rotate: no restart, no gap, and a retired key keeps verifying older work.

Laidline signs C2PA manifests, evidence checkpoints and export bundles with your key. Two modes:

  1. Development CA from laidline keys dev-cert (also run by laidline init). Produces dev-key.pem (PKCS#8, mode 600), dev-chain.pem (leaf + CA) and dev-ca.pem. Your own deployment's detect trusts it (it is the default own anchor), every other validator reports the manifest as valid but not trusted. With --kms-key-id or --vault-key the dev CA certifies a key held in AWS KMS or Vault/OpenBao Transit instead, and no private key file is written.
  2. Your certificate on the C2PA trust list. A CA on the C2PA Trust List issues a claim-signing certificate only for a generator product that is on the C2PA Conforming Products List, and writes that listed record's name into the certificate. You get a trusted certificate by listing your own generator product (your AI system, with Laidline as its claim-generator component) with the C2PA Conformance Program and ordering the certificate from a trust-list CA with that record. Laidline lists nothing and is never the certificate's subject. Keep the private key in AWS KMS (signing.backend: aws-kms, key spec ECC_NIST_P256) or in HashiCorp Vault or OpenBao Transit (signing.backend: vault-transit, key type ecdsa-p256, below) and point signing.cert_chain_path at the chain. laidline keys csr creates the certificate signing request from the managed key (below).

Without a listing, a certificate from any CA, or the development CA, works: Laidline signs and logs exactly the same, other validators report the manifest as valid but not trusted, and Laidline's own detect grades it as Grading describes.

Leaf certificate profile

Validated by validate_signing_chain at start, by laidline keys rotate and on every keyring reload; a violation stops the process (or fails the rotate or reload) with the failing check named (subject_organization, ku_digital_signature, eku, key_mismatch, expired).

Requirement Why
Subject contains Organization (O) and CN c2pa-rs 0.90 reports a CN-only signer as claimSignature.mismatch, which looks like a broken signature rather than a certificate problem.
KeyUsage digitalSignature (the dev CA also sets contentCommitment) C2PA signing certificate profile.
ExtendedKeyUsage with at least one of the EKUs below C2PA signing certificate profile; c2pa-rs (which detect also uses) accepts only these.
SubjectKeyIdentifier and AuthorityKeyIdentifier c2pa-rs rejects the chain without them ("the certificate is invalid").
Chain order leaf first, CA last The last certificate is used as the default own anchor for detect.
Public key matches the signing backend key Checked with the SPKI SHA-256 fingerprint from public_key_fingerprint().

ExtendedKeyUsage values validate_signing_chain accepts (the set c2pa-rs accepts, from its c2patool v0.27.22 release; ACCEPTED_EKUS in laidline.c2pa.certs):

OID Name Notes
1.3.6.1.5.5.7.3.4 emailProtection What the dev CA issues and laidline keys csr --eku email (the default) requests.
1.3.6.1.5.5.7.3.36 id-kp-documentSigning (RFC 9336) laidline keys csr --eku document-signing (or both).
1.3.6.1.4.1.311.76.59.1.9 Microsoft C2PA signing
1.3.6.1.4.1.62558.2.1 C2PA signing

Microsoft Document Signing (1.3.6.1.4.1.311.10.3.12) is refused. It is not in c2pa-rs's set, so a leaf that carries only it produces manifests other validators do not trust. A chain with that leaf stops the process at start with eku: 1.3.6.1.4.1.311.10.3.12 is Microsoft Document Signing, .... Ask your CA to re-issue the certificate with emailProtection or id-kp-documentSigning.

Algorithm: ES256 on P-256. c2pa-rs accepts either DER or P-1363 signatures from a callback signer; Laidline always converts to P-1363 (der_to_p1363), which is what the COSE profile specifies. Vault and OpenBao return P-1363 directly (marshaling_algorithm: jws).

Vault or OpenBao Transit

signing.backend: vault-transit signs through the Transit engine of HashiCorp Vault or OpenBao (same API); the private key never leaves Vault. Configuration keys are in Configuration (signing.vault.*); the kms extra brings hvac. Measured sign latency is in Benchmarks.

  1. Enable Transit and create an ECDSA P-256 key (with OpenBao, bao instead of vault):

    vault secrets enable transit
    vault write -f transit/keys/laidline type=ecdsa-p256
    
  2. Give the worker a token with exactly this policy (mount transit, key laidline):

    path "transit/sign/laidline" { capabilities = ["update"] }
    path "transit/keys/laidline" { capabilities = ["read"] }
    

    update on the sign path signs; read on the key lets Laidline check the key type and load the public key of the version it pins. Nothing else: no export, no rotate, no delete. The worker reads the token from the variable named by signing.vault.token_env (default VAULT_TOKEN).

  3. Issue a certificate over the Transit key. For development:

    laidline keys dev-cert --vault-key laidline --vault-addr https://vault.internal:8200 --org "Acme GmbH"
    

    It reads the token from $VAULT_TOKEN (or --vault-token-env), takes --vault-mount (default transit) and --vault-key-version (default: the latest), --vault-namespace and --vault-ca-cert, writes dev-chain.pem and dev-ca.pem, and prints the key version it certified. Set signing.vault.key_name and signing.vault.key_version to match. For production, send laidline keys csr --vault-key laidline ... to your CA instead (below; a CA on the C2PA trust list needs your own listing, see mode 2).

Key-version pinning. A certificate covers one public key, which in Transit means one key version, while Vault signs with the latest version unless told otherwise. Laidline therefore pins a version at start (signing.vault.key_version, or the latest at that moment when it is null), names it in every sign request, and refuses a signature that Vault labels with another version (sign_failed). A rotation in Vault (vault write -f transit/keys/laidline/rotate) thus never produces a manifest signed by a key the certificate does not cover: a running process keeps its version. With key_version: null the next restart (or keyring reload) would pin the new version and fail with key_mismatch, because the chain still certifies the old one. Set key_version explicitly in production, and move to a new version through a keyring entry of its own ("Key rotation" below). The evidence log records the key as vault-transit:<mount>/<key>@v<version>.

Namespaces and private CAs (hvac 2.4). hvac never reads VAULT_NAMESPACE, and it ignores VAULT_CACERT when Laidline passes verify=True. For the server and the library, set signing.vault.namespace and signing.vault.ca_cert. For the CLI (keys dev-cert, keys csr and keys rotate with --vault-key), pass --vault-namespace and --vault-ca-cert (a PEM bundle that Vault's TLS certificate chains to); REQUESTS_CA_BUNDLE also works.

Dev mode. Vault and OpenBao started in dev mode (vault server -dev) keep everything in memory. After a restart, create the Transit key again and re-run laidline keys dev-cert --vault-key laidline: the key is new, so the old certificate no longer matches it.

Trust anchors for detect

  • Own anchors: detect.own_anchors_path, or the last certificate of your chain. A manifest chaining to them is reported as trusted_by: own_anchor and verdict marked_by_this_system.
  • Public list: set detect.trust_anchors_path to a saved copy of https://contentcredentials.org/trust/anchors.pem (27 certificates) to recognise other providers' manifests as marked_by_known_system.
  • OCSP is not fetched; every detect result says so in its caveats.

Timestamps

signing.tsa.url is passed to c2pa-rs, which requests an RFC 3161 token during signing. DigiCert and Sectigo both answered in under half a second when measured. With tsa.required: true an unreachable TSA fails the mark (tsa_unavailable) after the fallbacks; with false the asset is signed without a timestamp. With tsa.mode: checkpoint_only manifests carry no timestamp at all and only the evidence checkpoints are timestamped (Evidence, Grading). Validators may show timeStamp.untrusted as informational: detect does not load a TSA trust list into c2pa-rs.

Checkpoints are timestamped by Laidline's own RFC 3161 client against the same URLs. To let a third party check those tokens, Laidline ships the public roots of the two default TSAs (DigiCert Trusted Root G4 and Sectigo Public Time Stamping Root R46) and uses them unless told otherwise: signing.tsa.roots_path is builtin by default, and the built-in roots apply only while every configured TSA URL points at one of those two hosts. For a different TSA, save the root certificates it chains to as one PEM file and set signing.tsa.roots_path to its path; until you do, that TSA's tokens are stored unverified, no roots file is bundled, and the server logs one warning at start-up. With roots in use the export copies them into every bundle as certs/tsa-roots.pem, and verify.py checks each token's signature and chain against them (a verifier should still prefer roots from an independent source, --tsa-roots). Set roots_path: null to turn the check off. The checkpoint client reads at most 64 KiB of a TSA reply (a token with its full chain is a few KiB); a larger reply fails that attempt with <url>: response too large and the next URL is tried.

Certificate signing requests (laidline keys csr)

A CA issues the leaf from a PKCS#10 CSR. laidline keys csr builds one around a key held in AWS KMS, Vault or OpenBao Transit, or a local PEM file, and has the key itself sign it: only the public key leaves the KMS or Vault.

A CA on the C2PA trust list replaces the Organization and Common Name in the CSR with the values of the listed record, so --org and --cn matter for other CAs and for the leaf profile check above.

laidline keys csr --kms-key-id "$KMS_KEY_ARN" --region eu-central-1 \
  --org "Acme GmbH" --cn "Acme image signer" --country DE --out acme.csr
laidline keys csr --vault-key laidline --vault-addr https://vault.internal:8200 --vault-key-version 3 \
  --org "Acme GmbH" --cn "Acme image signer" --eku both --out acme-v3.csr
# the configured signing key
laidline keys csr --org "Acme GmbH" --cn "Acme image signer" --out acme.csr
openssl req -in acme.csr -noout -verify    # verify OK
Option Meaning
--out The CSR (PEM) to write; refused if it exists, unless --force.
--org, --cn, --country Subject O (required: C2PA validators need it in the leaf), CN (required), and an optional two-letter C.
--eku The ExtendedKeyUsage requested: email (emailProtection, the default), document-signing (id-kp-documentSigning 1.3.6.1.5.5.7.3.36) or both. The CSR also requests KeyUsage digitalSignature (critical).
--kms-key-id / --vault-key / --key-path The key to certify. Without any of them, the configured signing key (--config, LAIDLINE_CONFIG) is used. The Vault options (--vault-addr, --vault-mount, --vault-key-version, --vault-namespace, --vault-ca-cert, --vault-token-env) and --region are shared with keys dev-cert and keys rotate.

It prints the key it used and the SPKI SHA-256 of the public key; validate_signing_chain checks the issued chain against the same fingerprint at start. What the CA issues must still meet the leaf profile above: the CA decides the final EKU and key usage, whatever the CSR requests. For Vault, request a CSR per key version (--vault-key-version), because a certificate covers one version.

What was verified. CSRs written over a real AWS KMS key and a real Vault Transit key were verified with cryptography and openssl req -verify; a test CA issued the leaf from each, and an image was signed with the resulting chain. No public CA was involved.

Key rotation

A certificate is valid for a limited time and a key may need replacing early. Laidline rotates without a restart and without a gap: a keyring (signing.keys_file, Configuration) lists every key with its validity window, the process signs with the key active now, and switches at the boundary. The runbook:

  1. Point the configuration at a keys file (once). Add keys_file: ./keys.yaml under signing. The first keys rotate creates the file and seeds it with the current single key as id default. Run it before the next restart or reload: until the file exists, loading the keys fails (signing.keys_file ... does not exist; run laidline keys rotate to create it). A running process is unaffected until then.

  2. Create the new key: a new KMS key (ECC_NIST_P256, SIGN_VERIFY), or a new Transit key or key version (vault write -f transit/keys/laidline/rotate). Nothing signs with it yet.

  3. Request the certificate: laidline keys csr --kms-key-id ... --org ... --cn ... --out new.csr (or --vault-key laidline --vault-key-version <new>), and send the CSR to your CA.

  4. The CA issues the chain. Save it (leaf first, CA last), for example as certs/chain-2026b.pem.

  5. Add the key with its start time:

    laidline keys rotate --id k2026b --backend vault-transit --vault-key laidline --vault-key-version 3 \
      --cert-chain certs/chain-2026b.pem --not-before 2026-10-01T00:00:00Z \
      --retire-previous-at 2026-10-08T00:00:00Z --pid 4242   # the laidline serve (or ingest) process
    

    rotate builds the new key's backend, checks the chain against the key at --not-before (a certificate that expires within 30 days of it gets a warning), and appends the entry under a lock with an atomic replace (a reader sees the old file or the new one, whole). --retire-previous-at sets not_after on the key active now; it may not be earlier than --not-before. For Vault without --vault-key-version, the version current at rotate time is pinned in the entry, because the certificate certifies that version. --dry-run validates and prints the result without writing. rotate prints the keyring with each key's status.

  6. Reload every process that signs with this configuration: --pid <pid> sends SIGHUP after writing, --admin-url https://host:8080 posts to /admin/reload with the bearer token from admin.token_env (the server answers with the active key and every key's status), or do either by hand. Without either option rotate prints the reminder. A process that is not reloaded keeps its old keyring until it restarts: reload each replica.

  7. Check: laidline keys list (add --json) shows each key's id, backend, key reference, window, status (active, standby, scheduled, retired) and leaf SHA-256, from the configuration alone (no KMS or Vault call); GET /admin/evidence/status shows signing_key. The switch itself is a config_changed evidence entry (Evidence).

What happens at the boundary: each mark takes the key active at its start and finishes with it, so a mark in flight during a switch is signed and logged entirely with one key. A reload builds the complete new keyring outside any lock and swaps it in; if anything fails (a chain that does not match its key, Vault or KMS unreachable), the old keyring stays and the error is returned (POST /admin/reload answers 422 reload_failed; SIGHUP logs it). A scheduled switch is noticed by the next mark or detect after the boundary; checkpoints written by the timer between the boundary and that next mark are still signed by the previous key. Both keys are valid at that moment, so this is a labelling detail, and the next checkpoint uses the new key.

The old key keeps working for verification. A retired key is never used to sign, but its entry stays in the keys file: its CA stays among detect's own anchors (so older manifests still chain to your own anchor), laidline evidence export writes every key's chain into the bundle (certs/<id>.pem), and verify.py looks for each checkpoint's signer among all of them. Keep old entries in the file; rotate never removes one.

Revocation does not un-sign. Revoking a certificate (a compromised key) stops validators from trusting new signatures, but assets signed before stay signed, and a validator that checks revocation may then distrust them too. What proves they were signed before the compromise is time: the per-asset RFC 3161 timestamp in the manifest, and the checkpointed evidence log, whose TSA tokens show that every entry up to a head hash existed at that time. On compromise: rotate to a new key immediately (--not-before now), retire the old one (--retire-previous-at now), reload, then revoke with the CA and export the evidence covering the old key's period while its checkpoints and timestamps are intact. Detect does not check OCSP (every result says so).

Vault key versions. A Transit key rotation (vault write -f transit/keys/laidline/rotate) creates version N+1. The Laidline rotation is then a keyring entry with the same key_name and the new key_version, certified by its own chain; the old entry keeps its version, and both verify. This works only when every Vault entry names its key_version: an entry with key_version: null pins Vault's latest version each time the keyring is built (start and every reload), so after the Transit rotation it no longer matches its certificate and the reload fails with key_mismatch (the old keyring stays). When keys rotate seeds the default entry from the single-key fields and signing.vault.key_version is set, it copies that version; when it is not set, it pins the Transit version whose public key the current certificate (signing.cert_chain_path) certifies, trying the latest version first and then each older one, newest to oldest (trimmed versions are skipped). When no version matches it exits 1 without writing the keys file: signing.vault.key_version is not set and no version of Vault key <key_name> matches signing.cert_chain_path; set signing.vault.key_version to the version the certificate certifies. Setting signing.vault.key_version yourself before the first Transit rotation is still recommended, so the pin is what you chose rather than what the search found; entries keys rotate adds always carry one. Do not raise the Transit key's min_encryption_version above, or trim, a version that an entry inside its window still signs with.

What was verified. A real laidline serve ran against Vault Transit with three keyring entries over two Transit key versions (v1 on version 1; v2 and v3 on version 2 with separate chains). keys rotate --admin-url switched to v2; a SIGHUP-scheduled v3 (--pid, --not-before a few seconds ahead) took over on the first mark after its boundary; both switches wrote config_changed entries; detect recognised the v1 and v2 assets; and an export whose checkpoints were signed by v1, v2 and v3 verified with the bundle's own verify.py.

Reference

  • Configuration: every signing.* key, including signing.vault.* and the keyring file.
  • Command-line reference: laidline keys dev-cert, keys csr, keys rotate and keys list.
  • Grading: how detect grades a manifest, a timestamp and a trust anchor.
  • Evidence: checkpoints, timestamps and the export bundle that carries every key's chain.
  • Production setup: the order of the steps for a registered deployment.
  • Benchmarks: measured sign latency for each backend.