RFC 005: C2PA roadmap for human-written posts
Phase 1 ships an Ed25519-signed manifest as a stand-in for C2PA-text. Phase 2 migrates to a real C2PA 2.1 manifest once the C2PA-text spec stabilises. This RFC pins the data shape, the migration path, and the key-rotation policy.
Summary
Every published blog post on blog.skill-issue.dev carries a cryptographic attestation that it was authored by Dax. Phase 1 implements this with a per-post Ed25519 signature over the canonical content (title ∥ pubDate ∥ body), exposed as a JSON manifest plus an in-<head> DigitalDocument JSON-LD block. Phase 2 will migrate to a real C2PA 2.1 (ISO/IEC 22144) manifest once the C2PA-text profile stabilises. This RFC pins the data shape, the migration path, and the key-rotation policy so the migration is a one-file change.
Motivation
Generative-AI text is the default in 2026. The signal “this post was written by a human” is dilute precisely because anyone can claim it. C2PA is the only widely-deployed open standard for content provenance — Adobe, Microsoft, OpenAI, and BBC ship it on images, video, and (recently) audio. Adoption on text is essentially zero because:
- There is no canonical bytestream for an article the way there is for a JPEG. Markdown, rendered HTML, and Atom-feed copies of the same post differ at the byte level.
- Most text consumers paste-and-render rather than fetch-and-verify.
- The C2PA-text working group’s draft is still moving; tooling lags the spec.
Phase 1 delivers the same UX — a “verified human-written” badge on every post, a public verify page, an API endpoint — using cryptography that is already universal: an Ed25519 signature over a deterministic SHA-256 of the post content. It is read-compatible with schema.org/DigitalDocument so generic crawlers ingest the metadata today.
Phase 1 — Ed25519 stand-in
Canonical content
Both the signing script and the verifier use the exact same string:
title \n pubDate.toISOString() \n body.trim()
The body is the markdown source as it appears in src/content/blog/<slug>.md after the frontmatter is stripped. We do not sign the rendered HTML because rehype/remark plugins (citations, KaTeX, syntax highlighting) re-render non-deterministically across Astro upgrades. The canonical input is what humans actually edit.
Signature manifest
scripts/sign-posts.mjs runs at prebuild. It writes:
// src/data/post-signatures.json
{
"<slug>": {
"hash": "<hex sha-256>",
"signature": "<base64 ed25519 over hash>",
"signedAt": "<iso8601>"
}
}
The signature covers the hash bytes, not the canonical string. Ed25519 is fast either way; signing the hash means the verifier only needs to re-hash + verify, and the network shape (64-byte signature, 32-byte hash) is small.
Public key distribution
src/data/post-public-key.pem is committed and shipped to the edge. The verify endpoint reads it via Vite’s ?raw import. The private key lives only in the CI environment as POST_SIGNING_KEY (Base64-encoded PKCS#8). The script falls back to an ephemeral keypair on a developer’s laptop with a loud warning.
Embedded metadata
BaseHead.astro emits, for every signed post, a second JSON-LD block alongside the canonical BlogPosting:
{
"@context": "https://schema.org",
"@type": "DigitalDocument",
"name": "...",
"encodesCreativeWork": {
"@type": "Article",
"identifier": "<sha256>",
"creator": { "@type": "Person", "name": "Dax the Dev" }
},
"additionalProperty": [
{ "@type": "PropertyValue", "name": "ed25519-signature", "value": "<base64>" },
{ "@type": "PropertyValue", "name": "signed-at", "value": "<iso8601>" },
{ "@type": "PropertyValue", "name": "verify-url", "value": "https://blog.skill-issue.dev/verify/<slug>" }
]
}
This is enough for any aggregator (LLM crawler, search engine, archive) to discover the attestation without scraping the page body.
Verify surface
Three entry points:
GET /api/verify-signature?slug=<slug>— returns the manifest entry.POST /api/verify-signature { slug, hash, signature? }— verifies a hash against the public key. Ifsignatureis omitted we fall back to the on-record manifest entry; the response includesmatchesRecord: true|falseso the UI can distinguish “signature is valid but the body you pasted differs”.GET /verify/<slug>— a public, mobile-first, AAA-contrast page that lets a reader paste the post body and verify it locally (Web Crypto SHA-256) before round-tripping the hash through the API.
Phase 2 — Real C2PA manifest
Trigger
Phase 2 starts when either:
- The C2PA Text Profile reaches v1.0 and at least one mainstream library (
c2pa-rs,c2pa-node,c2pa-python) ships a stable text encoder + verifier, or - A platform Dax cares about (Mastodon, Bluesky, Substack, Medium) starts surfacing C2PA badges natively for text, or
- Phase 1 reaches feature-parity with what C2PA would deliver and we want to start deprecating it (probably Q4 2026 / Q1 2027 at the earliest).
Migration shape
The migration is a one-file change in scripts/sign-posts.mjs:
// Phase 1
const signature = ed25519.sign(hash, privateKey);
// Phase 2
const manifest = c2pa.signTextManifest({
payload: canonical,
claims: [
{ label: "c2pa.created", actions: [{ action: "c2pa.placed" }] },
{ label: "stds.schema-org/Article", data: { ... } },
],
signer, // P-256 / Ed25519 (TSP-aligned) cert chain
});
src/data/post-signatures.json evolves from the current shape into:
{
"<slug>": {
"hash": "<hex>",
"manifest": "<base64 c2pa manifest store>",
"signedAt": "<iso8601>"
}
}
The SignedAttestation component, BaseHead JSON-LD, and /api/verify-signature all read manifest instead of signature. The /verify/<slug> page swaps the in-page hash → POST flow for a C2PA manifest decoder. The badge, the URL, the UX, and the public-key URL all stay identical.
Identity binding
Phase 2 also binds the signing identity to a verifiable claim:
- A
did:web:blog.skill-issue.devdocument at/.well-known/did.jsonadvertises the Ed25519 (and Phase-2 X.509) keys used to sign posts. - The C2PA manifest’s
signerfield references that DID, so a verifier can chase the DID → public key → the manifest’s signature without trustingblog.skill-issue.devas an oracle. did:webwas chosen overdid:keybecause it lets Dax rotate keys without breaking existing manifests (the DID document points at the current key set; old signatures verify against historic keys we keep on file).
Key-rotation policy
- Frequency: yearly.
- Trigger conditions for off-cycle rotation: any private-key compromise, a CI runner breach, or a credible report of leakage.
- Process:
- Run
scripts/generate-activitypub-key.mjs(works for both AP and post-signing — same Ed25519 algorithm, different env var). - Update
POST_SIGNING_KEYin CF Pages env. - Replace
src/data/post-public-key.pemwith the new public PEM. Commit. - Trigger a fresh build. Every post is re-signed with the new key. The manifest’s
signedAtadvances; thehashdoes not (because the canonical content didn’t change). - Keep the previous PEM in
src/data/post-public-key.<old-fingerprint>.pemfor one full rotation cycle so anyone with a cached signature can still verify.
- Run
Open questions
- Notes collection: Phase 1 does not sign notes. They’re often ephemeral linkblog entries; the badge there would dilute the signal. Worth re-evaluating in Phase 2 after the post UX is settled.
- Pinned signatures in the Atom/RSS feeds: today the feeds emit the rendered HTML which doesn’t carry the signature. Adding
<dax:signature>elements (or piggybacking on the<source>extension) would let federated readers verify without round-tripping. Out of scope for v1. - Cross-post verification on Mastodon: when the ActivityPub outbox emits a
Notefor a post, embedding the signature in theattachment[]orcontentHTML would let anyone in the fediverse verify provenance. Out of scope for v1; revisit alongside the AP outbox once we have follower data.
Status
proposed. Phase 1 ships with this RFC.