skip to content
Skill Issue Dev | Dax the Dev
search
← all docs

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.

proposed Up for review. Open to feedback before adoption.

by Dax the Dev

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:

  1. 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.
  2. Most text consumers paste-and-render rather than fetch-and-verify.
  3. 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. If signature is omitted we fall back to the on-record manifest entry; the response includes matchesRecord: true|false so 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:

  1. 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
  2. A platform Dax cares about (Mastodon, Bluesky, Substack, Medium) starts surfacing C2PA badges natively for text, or
  3. 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.dev document at /.well-known/did.json advertises the Ed25519 (and Phase-2 X.509) keys used to sign posts.
  • The C2PA manifest’s signer field references that DID, so a verifier can chase the DID → public key → the manifest’s signature without trusting blog.skill-issue.dev as an oracle.
  • did:web was chosen over did:key because 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:
    1. Run scripts/generate-activitypub-key.mjs (works for both AP and post-signing — same Ed25519 algorithm, different env var).
    2. Update POST_SIGNING_KEY in CF Pages env.
    3. Replace src/data/post-public-key.pem with the new public PEM. Commit.
    4. Trigger a fresh build. Every post is re-signed with the new key. The manifest’s signedAt advances; the hash does not (because the canonical content didn’t change).
    5. Keep the previous PEM in src/data/post-public-key.<old-fingerprint>.pem for one full rotation cycle so anyone with a cached signature can still verify.

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 Note for a post, embedding the signature in the attachment[] or content HTML 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.