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
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:
| signature | integrity | verdict | what happened |
|---|---|---|---|
| true | true | valid | signed by this key, bytes untouched |
| true | false | invalid | edited after signing — manifest intact, page is not |
| false | true | invalid | manifest itself was tampered, or the key doesn't match |
| no manifest at all | error | no 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:
| segment | bytes | meaning |
|---|---|---|
did:key: | — | the DID method: a key, expressed inline |
z | — | multibase prefix — base58btc follows |
| multicodec | ED 01 | "this is an Ed25519 public key" |
| public key | 32 bytes | the 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-printed | canonical | |
|---|---|---|
| keys | author's order | sorted by string |
| whitespace | indented, spaced | none |
| sha256 | one number | a different number |
| verifies? | no | yes — 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 key | runtime tenant key | |
|---|---|---|
| path | sh.workbooks/keys/local.ed25519 | .workbooks/ in the tenant repo |
| format | hex seed + .pub | hex seed + .pub |
| minted | first wbx sign, via OsRng | per tenant, via :crypto.generate_key |
| protected | private half chmod 0600 | gitignored — never committed |
| persisted | lives in the app dir | WB_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, … }