learn / 01·3 — under workbook · signatures

provenance thatTRAVELSwith the bytes

A signature is an embedded Ed25519 manifest, signed by a self-certifying did:key and read by two independent checks — who signed, and whether the bytes changed. No certificate authority, no registry, no network. The proof rides inside the file it attests, and verifying it needs nothing but the file.

signatures12 min read
A small figure holding a single document up to an immense glowing wax-seal monument that scans and reads the page's own embedded mark — bright, monumental, 1970s sci-fi style

forwarded twice, edited ONCE

The whole workbook pitch is one sentence: hand the file to anyone. The moment you do, you lose the one thing every platform hands you for free — knowing where the bytes came from, and whether anyone touched them on the way. A workbook forwarded through three inboxes looks exactly like one that was quietly edited in the second inbox. There is no tell. The bytes don't remember.

The reflex answers all break the format that made the pitch worth making. A detached .sig file is a second file — and one file was the entire point. A registry lookup needs a server — and the file is supposed to work offline, on a thumb drive, in ten years. Platform-checked provenance needs the platform — and the file just left it. Every classic fix re-attaches a string the workbook was built to cut.

So the requirement is awkward and specific. The proof has to ride inside the artifact, survive being copied byte-for-byte, and be checkable by anyone holding the file alone — no account, no call home. Here is the situation a signature has to answer:

flowchart LR
  A["author signs
workbook.html"] --> M1["inbox 1
forwards it"] M1 --> M2["inbox 2
edits one line, quietly"] M2 --> M3["inbox 3
forwards it"] M3 --> Q{"which copy
is real?"} style A fill:#a8d4f0,stroke:#121316,stroke-width:2.5px style M2 fill:#f3c5a3,stroke:#121316 style Q fill:#ffffff,stroke:#121316,stroke-width:2px

The edit in inbox two is invisible to inbox three. Without something carried in the bytes, the question at the end has no answer. With it, the answer is mechanical.

the DEFINITION

sig·na·ture /ˈsɪɡ·nə·tʃər/ noun

1. an embedded Ed25519 manifest — carried under application/workbooks-c2pa+json, signed by a self-certifying did:key, and verified by two checks: who signed, and whether the bytes changed. Valid means both.

It borrows C2PA's vocabulary — manifests, assertions, actions — and drops C2PA's machinery on purpose. No COSE envelope, no X.509 chain, no c2patool. Just Ed25519 over canonical JSON, with the signer's identity expressed as a did:key. The media type even says so: application/workbooks-c2pa+json is deliberately not a real C2PA claim, which means it won't light up a Content Credentials badge in C2PA-aware tools — and that trade is the whole design, covered honestly further down.

Here is a real signed block, lifted verbatim from a freshly signed file. It sits just before </body> in the workbook it attests:

<script type="application/workbooks-c2pa+json" id="wb-c2pa-manifest">{"assertions":[{"actor":"did:key:z6Msg…","type":"c2pa.action.published"}],"asset_sha256":"a724c232…de698074","claim_generator":"wb-cli/0.1","issued_at":"2026-06-12T18:15:58Z","issuer_did":"did:key:z6Mksg…","signature":"ZC7Of0By…AkgGDA==","version":"v1"}</script>

Seven fields, sorted alphabetically — that ordering isn't cosmetic, it's load-bearing, and the canonical-JSON section explains why. Read them once: assertions is what's being claimed (c2pa.action.published — this was shipped); asset_sha256 is the fingerprint of the page without this block; claim_generator names the signing tool (wb-cli/0.1 for the CLI, workbooks-runtime/0.1 for the engine — a forensic breadcrumb for which tool signed); issued_at is a UTC claim of when; issuer_did is the signer's identity; signature is the Ed25519 bytes, base64; version is v1.

why verify asks TWO questions

Verifying a signature returns a small record, and the field that matters is the last one: valid = signature AND asset_integrity. Two independent checks, ANDed. They answer different questions, and keeping them apart is what makes a failure diagnosable instead of just red.

flowchart TD
  V["wbx verify file.html"] --> S{"check 1 — signature
Ed25519 over the manifest,
key decoded from the did:key"} V --> I{"check 2 — asset integrity
sha256 of the page with
every manifest stripped"} S -->|true| AND(("AND")) I -->|true| AND AND --> OK["valid: true"] S -->|false| BAD["valid: false"] I -->|false| BAD style V fill:#a8d4f0,stroke:#121316,stroke-width:2.5px style OK fill:#13d943,stroke:#121316,stroke-width:2px style BAD fill:#f3c5a3,stroke:#121316

Check one — signature — pops the signature field off the manifest and Ed25519-verifies it over the rest, using a public key decoded from the issuer_did itself. No registry, no certificate, no network — the did:key is self-certifying, a point that earns its own section below. This answers: was this manifest produced by the holder of this key, and is the manifest itself untampered?

Check two — asset integrity — strips every manifest block from the page, takes the sha256 of what remains, and compares it to asset_sha256. This answers a completely different question: do the bytes still match what was signed?

The two-by-two of how those checks can land is the page's teaching moment:

signatureintegrityverdictwhat happened
truetruevalidsigned by this key, bytes untouched
truefalseinvalidedited after signing — manifest intact, page is not
falsetrueinvalidmanifest itself was tampered, or the key doesn't match
no manifest at allerrorno manifest — nothing was ever signed

Row two is the one to hold in your head. Edit a single character in the body of a signed workbook and run verify: signature stays true — the manifest block is exactly as it was signed — while asset_integrity flips false, because the bytes around it moved. One construct telling you the seal is genuine but the envelope was opened. A single boolean could never say that.

signing a file from INSIDE it

There's a chicken-and-egg buried in "the signature rides inside the file." To sign the bytes you need to hash them — but the moment you inject the signature, you've changed the bytes you just hashed. Hash again and you'd need to re-sign; re-sign and the bytes change again. The snake eats its tail.

One trick dissolves it: strip, then hash. Hash the file as if no manifest were there — remove every wb-c2pa-manifest block first, sha256 what remains, and store that in asset_sha256. Now injecting the manifest can't invalidate the hash, because the hash was always computed over the manifest-free page. Verification re-strips and re-hashes the same way, and the numbers line up. Here is the full sign path:

sequenceDiagram
  participant F as workbook.html
  participant K as did:key + private seed
  participant M as manifest
  Note over F: strip any prior manifest blocks
  F->>M: sha256(stripped page) → asset_sha256
  Note over M: fill version, issuer_did,
claim_generator, issued_at, assertions M->>M: canonical(manifest) — sorted, compact K->>M: Ed25519 sign → signature (base64) M->>F: inject script just before </body> Note over F: signed — provenance now travels in the bytes

Two details earn their keep. First, the block is injected just before </body> — or appended if there's no </body> to find. Second, signing is idempotent: it strips all prior manifest blocks before writing the new one, so re-signing replaces, never stacks. Sign a file ten times and it carries exactly one signature — the last. That's what lets check-in re-sign on every save without piling up dead seals.

the name that IS the key

Depth rung — skippable, but it's the idea that let the whole C2PA stack get deleted. A did:key is an identity that is its own public key. There's nothing to look up. The verifier doesn't fetch a certificate or query a registry to learn whose key signed — it reads the key straight out of the identifier string.

The construction is exact. Take a 32-byte Ed25519 public key, prepend the two multicodec bytes 0xED 0x01 that mean "this is an Ed25519 key," base58btc-encode the result with the Bitcoin alphabet, and prefix did:key:z (the z is the multibase tag for base58btc). That's it — about a hundred lines of hand-rolled Elixir, no dependency on a CA or a DID resolver:

segmentbytesmeaning
did:key:the DID method: a key, expressed inline
zmultibase prefix — base58btc follows
multicodecED 01"this is an Ed25519 public key"
public key32 bytesthe actual verifying key

Because every Ed25519 did:key begins with the same 0xED01 prefix, every one of them base58-encodes to a string starting did:key:z6Mk… — that prefix is a tell you can read by eye. The decode side is strict: anything that isn't 34 bytes, or doesn't start 0xED01, is rejected as "not an Ed25519 did:key." This is the same form Radicle uses and the W3C DID spec defines — interoperable, not bespoke.

Self-certifying is the property that pays for everything else. Because the key is the name, check one needs nothing but the manifest string in front of it. It collapses what a certificate scheme keeps as two separate questions — "is this signature valid?" and "does this identity own that key?" — into one operation, because the identity and the key are the same bytes. No CA hierarchy survives that collapse, which is exactly why the X.509 spike was marked superseded and shelved.

the one byte CONTRACT

Depth rung. A signature is over bytes, not over a concept of "the manifest." So two programs that want to make and check each other's signatures must serialize the same manifest to the same bytes, every time, or a signature minted by one fails in the other. That shared serialization is canonical JSON, and it's the load-bearing contract between the Elixir runtime and the Rust CLI.

The rules are short: recursively sort every object's keys by string order, emit compact (no whitespace), UTF-8, scalars JSON-encoded. Same map, serialized pretty versus canonical, hashes to different numbers — and only one of them is the number that was signed:

pretty-printedcanonical
keysauthor's ordersorted by string
whitespaceindented, spacednone
sha256one numbera different number
verifies?noyes — this is what was signed

This is why the signed block earlier had its fields in alphabetical order: assertions, asset_sha256, claim_generator, issued_at, issuer_did, signature, version. That's the canonical sort, written out. The Elixir Workbooks.Manifest.canonical/1 and the Rust canonical() are the same fifteen lines twice — and the Rust file header says it out loud: byte-compatible with Workbooks.Manifest. That comment is the contract written down. If the two ever drifted by a single space, a CLI-signed workbook would fail runtime verify, and the whole cross-tool promise would quietly break.

where the private half LIVES

Depth rung. Every signature needs a private key, and there are two keystores in play — they share an on-disk format but hold independent keys, which means the CLI signs as a different identity than the runtime tenant. That's true by construction, not by accident:

CLI keyruntime tenant key
pathsh.workbooks/keys/local.ed25519.workbooks/ in the tenant repo
formathex seed + .pubhex seed + .pub
mintedfirst wbx sign, via OsRngper tenant, via :crypto.generate_key
protectedprivate half chmod 0600gitignored — never committed
persistedlives in the app dirWB_SIGNING_KEY (primary tenant only)

The runtime side has a durability problem worth stating plainly. A container's filesystem is wiped on redeploy. Without persistence, every redeploy would mint a fresh key, hence a fresh DID, breaking every signature ever made by that tenant. The fix is one secret: the primary tenant's seed is restored deterministically from WB_SIGNING_KEY (base64 of the 32-byte seed, held as a Fly secret), with the primary tenant chosen by WB_PRIMARY_TENANT — default dev. Set that, and the primary tenant's DID survives redeploys. Don't, and it doesn't.

One key, many rails. The same per-tenant Ed25519 key signs artifacts (the manifest on this page), the agent run ledger (a hash-chain head, sealed by the same key), and toolkit manifests (#+AUTHOR_DID: and #+SIGNATURE: lines). That's a deliberate anti-drift stance — one identity key, no second store to keep in sync — and it's why the same DID shows up across the system.

where signing fires for YOU

You can always sign by hand — wbx sign <file> on the CLI, wb sign on the runtime escript, both fully local, no engine required. But signing also fires automatically at the moments where provenance matters most. Here's the artifact lifecycle with the checkpoints marked:

flowchart TD
  edit["author edits
workbook.html"] --> ship ship["ship on egress
Bundle.ship + :tenant"] -->|"sign · c2pa.action.published
manifest.json: signed=true"| out["the .wbundle leaves"] edit --> checkin["library check-in"] checkin -->|"re-sign · c2pa.action.updated"| lib["versioned in the library"] out --> gate{"third-party install
require_signature?"} gate -->|"Bundle.verify must pass"| ok["installed"] gate -->|"valid:false"| reject["refused — bad_signature"] style ship fill:#a8d4f0,stroke:#121316 style checkin fill:#aee5c2,stroke:#121316 style gate fill:#f3c5a3,stroke:#121316 style ok fill:#13d943,stroke:#121316

Walk the three checkpoints. First — ship on egress: when a bundle ships with a tenant in context, it signs workbook.html with the tenant DID, records "signed": true in the bundle's manifest.json, and stamps the assertion c2pa.action.published. (The sibling bundles lesson covers that signed bool from the bundle's side.) Second — library check-in: every check-in re-signs with c2pa.action.updated, so the provenance tracks each revision. Third — the third-party install gate: a toolkit-workbook install can set require_signature: true, and then Bundle.verify must return valid: true or the install fails. It's off by default for first-party local installs — it's the posture you reach for when installing something you didn't make.

Toolkit manifests get the same crypto in a different envelope: wbx toolkit sign/verify signs the manifest body with #+SIGNATURE: and #+AUTHOR_DID: org lines, required for #+TRUST: third-party toolkits — see the toolkit lesson. Pure-local CLI bundle assembly, by contrast, writes "signed": false — signing there is a separate, explicit step, never a silent default.

what it is NOT

Honesty section — and this one has teeth, because a self-signed signature is easy to over-read. The hard truth: this proves continuity, not identity. Verify tells you the bytes are untampered since they were signed, by the holder of this key. It does not tell you who that holder is. There is no trust list, no revocation, no expiry. Self-certifying means self-asserted — the spike doc says it directly: self-signed is provenance only, and trust-listing is a distribution concern for later.

The sharpest consequence: anyone can strip the manifest and re-sign with their own key. The scheme detects tampering under a claimed identity — it does not prevent re-attribution. Strip-and-re-sign always succeeds, and the result verifies cleanly under the new signer's DID. The defense is out-of-band: you compare the issuer_did against one you already trust. And it's worth being exact about today's gap — the install gate checks only that the signature is valid; it does not compare against an allowlisted DID. So the gate currently proves "validly signed by someone," not "signed by an author I chose." An honest gap, named.

Three smaller limits round it out. issued_at is a claim, not a proof — there's no timestamping authority, so the field is the signer's word for when. (The ledger's git-commit anchor is the real external witness, not the manifest.) It won't show a Content Credentials badge in C2PA-aware tools, because the media type is application/workbooks-c2pa+json, not a real C2PA claim — a real badge would need the X.509 chain the spike shelved. And multi-tenant durability is unfinished: only the primary tenant's DID survives a redeploy; other tenants re-mint until bring-your-own-storage lands. None of these is hidden in the code. All of them are provable from it.

What you get, stated without inflation: untampered since signed, by the holder of this key. That is real and useful. It is not "trusted," and the page won't pretend the two are the same.

questions people actually ASK

Is this real C2PA?

No — and that's deliberate. It borrows C2PA's vocabulary (manifests, assertions, c2pa.action.*) and drops C2PA's machinery: no COSE, no X.509, no c2patool. Pure Ed25519 over canonical JSON with a did:key issuer. The media type application/workbooks-c2pa+json announces the difference, and the X.509 path that would make it real C2PA was a spike, marked superseded.

What happens if I edit a signed workbook?

Verify fails on integrity — signature stays true (the manifest block is untouched), asset_integrity flips false (the bytes moved), and valid is false. That's not a bug; it's the point. To re-attest the new bytes, re-sign — signing is idempotent, so the new signature replaces the old one rather than stacking.

Can someone re-sign my workbook as theirs?

Yes. Anyone can strip the manifest and re-sign with their own key, and the result verifies cleanly under their DID. What that gets them is nothing you'd want: it's signed as them, not as you, and a verifier who knows your DID sees a stranger's. The scheme proves tamper-evidence under a claimed identity — it doesn't stop re-attribution. Knowing whose DID to trust is out-of-band, and that's the honest boundary.

Do I need the runtime to verify?

No. wbx verify <file> is fully local — it decodes the public key out of the did:key, strips the manifest, re-hashes, and checks the Ed25519 signature, all without a network or an account. Self-certifying identity is exactly what makes offline verification possible.

Does the signature survive bundling?

Yes — the manifest lives in workbook.html, which sits inside the .wbundle. Bundle.verify unpacks the bundle and runs the same two-check verify over that workbook.html. The bundle also records a signed bool in its manifest.json; the bundles lesson covers that side.

Can I sign on one machine and verify on another?

Yes — the did:key carries the public key, so the verifying machine needs nothing from the signing machine but the file. The private seed never leaves the signer; the identity travels in the manifest. That's the same property that makes a workbook portable in the first place, applied to its provenance.

keep GOING

Signatures are one face of the workbook. These sit closest.

$ wbx sign shop.html
signed shop.html as did:key:z6MksgYVoXUjHkzUNrNHXDgr1bE7p4TJo7DfgSy78UxfYN1C → shop.html
$ wbx verify shop.html
{ "signature": true, "asset_integrity": true, "valid": true, … }
# edit one character in the body, then:
$ wbx verify shop.html
{ "signature": true, "asset_integrity": false, "valid": false, … }