from: you (UNVERIFIED)
The parent lesson convinced you of something genuinely unusual: you can email someone your app. The moment you believe that, two fears arrive — one on each side of the handoff.
The sender's fear: once the file leaves, anyone can alter it, and the altered thing still carries your name. The recipient's mirror image: this arrived as an HTML attachment from the internet — why would anyone trust an HTML attachment's claim about who made it?
Every answer software has built so far authenticates something other than the file. HTTPS authenticates the pipe — forward the download and the proof evaporates. App stores authenticate a distributor — and charge an account, a review queue, and a platform for the privilege. Code signing authenticates through certificate chains that lead back to an authority someone paid. All of them are channel trust or distributor trust, and all of them need infrastructure standing by at verification time. A file forwarded over email has no channel left and no distributor at all. If the proof doesn't ride in the bytes, there is no proof.
the DEFINITION
1. handing over a workbook with the maker's cryptographic identity riding in the file — signed on the way out, verifiable by anyone, no account on either end.
It happens on two rails, and the rest of this page keeps them distinct: the source rail — the unpacked repo, pushed as plain git to any host — and the artifact rail — the packed, signed workbook, handed to any surface. History rides the source; proof rides the artifact. We'll take the proof apart first, because it's the part nothing else in software does this way.
one key, every CLAIM
Everything starts smaller than you'd expect: every tenant on an engine
has exactly one Ed25519 keypair. It's generated by the BEAM's own crypto
(Erlang's :crypto.generate_key — no bespoke curve code) and
stored as a hex seed plus hex public key in the tenant's
.workbooks/ keystore. The private half never enters version
control: the same module that guards every egress — git, bundle, library —
writes a .gitignore over the whole keystore automatically.
The CLI mints its own local key in the identical
on-disk format, chmod 0600 on unix.
The public half becomes a name — and this is the load-bearing move of
the entire lesson. Take the 32-byte public key, prefix it with the
two-byte multicodec tag for Ed25519 (0xED01), encode in
base58btc, prepend the multibase letter z:
did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH
└─ z = base58btc · 0xED01 = Ed25519 · the rest = the public key itself
That's a real W3C did:key — the same
did:key:z6Mk… form Radicle and the W3C test suites use,
interoperable rather than a bespoke hex string. And it's
self-certifying: the DID is the public key. Verification
decodes the key straight out of the DID string — it needs nothing but
the DID itself. No registry maps name to key, because the name and the
key never separated.
One key, and the ecosystem spends it everywhere a claim needs backing:
flowchart TD key(["one tenant key — Ed25519, in .workbooks/"]) did["did:key:z6Mk… — the public half, as a name"] key --> did m["artifact manifests
signed workbook.html"] l["the agent ledger head
what the agents did"] w["did.json
the did:web document"] t["toolkit provenance
third-party installs"] did --> m did --> l did --> w did --> t style key fill:#a8d4f0,stroke:#121316,stroke-width:2.5px style did fill:#13d943,stroke:#121316,stroke-width:2.5px style m fill:#ffffff,stroke:#121316 style l fill:#f2ddb0,stroke:#121316 style w fill:#ffffff,stroke:#121316 style t fill:#f3c5a3,stroke:#121316
This page is about the first branch. The ledger head — the same key signing a hash chain over everything the agents did — has its own lesson; toolkit signing is the trust page's subject. One more branch deserves a sentence: the same identity can also receive secrets — an Ed25519 key converts to an X25519 encryption key by a standard mapping, so a sealed bundle's content key can be wrapped to a recipient's DID with no second published key. That rung is escrow-mediated today, and its mechanics live in the escrow lesson, not here.
what happens at the HANDOFF
Now the whole transaction, end to end. Signing embeds a manifest —
one <script> block, injected just before
</body>. Crack open a signed shop.html
and here it is, verbatim in shape:
<script type="application/workbooks-c2pa+json" id="wb-c2pa-manifest">
{"version":"v1",
"issuer_did":"did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH",
"claim_generator":"workbooks-runtime/0.1",
"issued_at":"2026-06-12T09:14:03.412Z",
"asset_sha256":"9f3c1a… 64 hex chars …b2e0",
"assertions":[{"type":"c2pa.action.published",
"actor":"did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH"}],
"signature":"q4xT… base64 Ed25519 …=="}
</script>
Every field, in order. version tags the format.
issuer_did is the maker's did:key — and because the DID is
the public key, this field doubles as the verification key.
claim_generator names the signer's software
(workbooks-runtime/0.1 from an engine,
wb-cli/0.1 from the local CLI — same manifest either way).
issued_at is the timestamp. asset_sha256 is
the hash of the HTML with this block removed — that's how a
file can contain its own hash without a paradox. assertions
records what is being claimed, in C2PA's action vocabulary —
c2pa.action.published, by this actor. And
signature is Ed25519 over the manifest's canonical JSON
form — recursively key-sorted, compact, UTF-8, the one cross-implementation
byte contract, implemented byte-identically in Elixir, Rust, and
JavaScript. (The byte-level internals are the
signatures lesson's whole subject; this page
stays at the handshake.)
Then the handoff itself:
sequenceDiagram participant S as sender — wbx sign participant F as shop.html — the file participant R as recipient — any verifier S->>F: embed the manifest block before the closing body tag Note over F: travels by mail, chat, USB —
the channel proves nothing, and doesn't need to F->>R: arrives as bytes R->>R: check 1 — Ed25519 signature, under
the key the DID itself encodes R->>R: check 2 — sha256 of the file minus the
manifest equals asset_sha256 Note over R: verdict — valid, issuer_did, assertions.
the sender was never contacted
Verification is exactly those two checks, and either failing means
invalid. Check one proves the manifest was written by the holder of the
key the issuer_did encodes. Check two proves the bytes
around it haven't moved since. Nothing else is consulted — not a server,
not a clock authority, not the sender. Two details worth keeping: signing
is idempotent — re-signing strips any prior manifest first, so a
file never accumulates stale claims — and both CLI lanes
(wbx sign / wbx verify on an engine, and the
local Rust CLI) are byte-compatible: either can verify the other's
output.
single AUTHENTICATION
depth rung · skippable — why there's no certificate chain
The manifest's shape is borrowed from C2PA — the content-provenance standard the camera and creative industries use. But full C2PA authenticates identity through X.509: your signing key is vouched for by a certificate, the certificate by an authority, the authority by a trust list. The design here deliberately superseded that path. The spike's own words: we adopt the scheme, drop the machinery.
| X.509 / full C2PA path | did:key path | |
|---|---|---|
| identity is proven by | a cert chain ending at a CA | the DID — it is the key |
| verify means | walk the chain, check each signature, match the subject | decode the DID, one Ed25519 verify, done |
| standing infrastructure | CAs, trust lists, OCSP / CRL endpoints | none |
| revocation | yes — CRLs, OCSP | none — honest cost, see limits |
| who can be an issuer | whoever a CA will certify | anyone with 32 bytes of entropy |
That table is the decoded meaning of single authentication: a self-certifying DID collapses what would be two checks — is the signature valid? does the key belong to the claimed identity? — into one operation, because there is no gap between key and identity for a second check to cover. The right column's empty infrastructure row is what makes the next section possible at all. The left column's revocation row is the honest price, and it gets its own paragraph later.
verify with NOTHING installed
The recipient's side is the page's load-bearing promise, so let's keep it literal. The round trip first, with real commands and real verdict shapes:
$ wbx sign shop.html
signed shop.html as did:key:z6MkpTHR8…dvktH → shop.html
$ wbx verify shop.html
{ "valid": true, "signature": true, "asset_integrity": true,
"issuer_did": "did:key:z6MkpTHR8…dvktH",
"issued_at": "2026-06-12T09:14:03.412Z", "assertions": […] }
# the recipient — or anyone — edits one character of the page, then:
$ wbx verify shop.html
{ "valid": false, "signature": true, "asset_integrity": false, … }
Read the failed verdict carefully, because the split is the
diagnostic. signature: true with
asset_integrity: false means: the manifest is genuinely
hers — and the bytes were touched after she signed. If the tamperer
instead re-signs the altered file with their own key, both booleans go
green — but issuer_did flips to their DID. The
attacker can have your name or a valid signature, never both.
And the verifier itself: the entire consumer side is
verify.mjs — roughly 77 lines, zero dependencies, WebCrypto
plus atob. It runs in modern browsers and Node 20+,
returns a plain verdict object, and never throws. Its skeleton is
exactly the two checks: decode the public key from the DID
string, crypto.subtle.importKey with
{name: "Ed25519"}, crypto.subtle.verify over
the canonical manifest, then SHA-256 of the stripped HTML against
asset_sha256. One line, no install:
$ node -e 'import("./verify.mjs").then(async m =>
console.log(await m.verifyHtml(require("fs").readFileSync("shop.html","utf8"))))'
{ valid: true, signature: true, asset_integrity: true, issuer_did: "did:key:z6Mk…" }
No account. No runtime. No npm install. The engine-side
check is the same shape — the verify function is a pure read, no key
material needed for the asset half. And the 77 lines are not a toy
edition of something bigger: this verifier replaced a 483-line
one from the archive, dropping the claim-chain walker, the base64
envelope, and per-assertion canonicalization — all machinery whose only
job was to bridge the key-to-identity gap a self-certifying DID
doesn't have.
signed on every way OUT
depth rung · skippable — provenance as a property of leaving
Here's the part that keeps signing from becoming one more step humans
forget: you mostly don't run wbx sign. The engine
signs as part of leaving — every door out passes the artifact through
the same signing seam:
workbook.html as part of the pack — the manifest records "signed": true, and a shipped bundle's embedded provenance is checkable on arrivalc2pa.action.updated assertion — the borrow-and-return is on the recordrequire_signature: true — installing a third-party member then refuses on an invalid manifestThe library leans on this hardest: every member is did:key-linked, and provenance is one of its pillars rather than an optional extra. The consequence reads small and isn't: by the time a workbook is anywhere another person could receive it, the question did anyone remember to sign this? has no unsigned answer.
the two RAILS
Back to the frame from the definition. A workbook leaves home in two forms, and they answer different questions:
flowchart TD repo[["the workbook repo — your work, with history"]] repo --> src["SOURCE rail — git push, plain"] repo --> art["ARTIFACT rail — pack + sign"] src --> s1["github · gitlab · gitea
any remote — wbx mirror"] art --> a1["pages / self-host
the signed .html"] art --> a2["an ATproto feed
public, C2PA-verified"] style repo fill:#a8d4f0,stroke:#121316,stroke-width:2.5px style src fill:#fbfaf6,stroke:#121316 style art fill:#fbfaf6,stroke:#121316 style s1 fill:#ffffff,stroke:#121316 style a1 fill:#13d943,stroke:#121316 style a2 fill:#ffffff,stroke:#121316
The source rail is deliberately boring: the unpacked repo, as
plain git, to any host. wbx mirror <url>
pushes anywhere; pass --forge github|gitlab|gitea and the
remote is auto-provisioned through the host's own CLI. No proprietary
sync protocol, no blessed forge — history rides this rail, in the format
every code host already speaks.
The artifact rail is this page's subject: the packed, signed workbook, handed to whatever surface fits — a static page, your own server, or an ATproto feed via the publish toolkit, which binds a Bluesky account and publishes workbook records into a C2PA-verified feed. Who may fetch from a live engine is the postures lesson; the catalogue of surfaces is publishing. The split to remember: history rides the source, proof rides the artifact — a git host vouches for nothing, and doesn't need to, because the artifact carries its own vouching.
And underneath both rails, one precondition this ecosystem treats as non-negotiable: sharing exposes the work, never the session that produced it. The manifest travels; the agent session ledger and telemetry stay home. The bundles lesson showed the strip mechanically; privacy owns the rule. Proof of origin and exposure of process are different things, and only the first one ships.
share by IDENTITY, not bytes
depth rung · skippable — the upper rung: a DID as the address
Everything so far handed over bytes. The upper rung hands over a
name: a workspace member can be a DID reference — a
:DID: property on a headline in workspace.org —
and checkout resolves it to bytes when needed. For that to work, a DID
has to be findable, and here the design is honest about its own
primitive: a did:key has no inherent location. The resolver says so in
its error string — locating a bare did:key needs a registry or ledger
anchor, which is an extension, not a shipped promise.
What is shipped is the HTTPS rail: did:web. The same
Ed25519 key — not a second identity — is republished as a DID document
at /.well-known/did.json:
$ curl https://shop.example/.well-known/did.json
{ "id": "did:web:shop.example",
"verificationMethod": [{ "publicKeyMultibase": "z6MkpTHR8…dvktH" }],
"alsoKnownAs": ["did:key:z6MkpTHR8…dvktH"],
"service": [{ "type": "WorkbookHost",
"serviceEndpoint": "https://shop.example/docs" }] }
Three things to notice. publicKeyMultibase is lifted
byte-for-byte from the did:key — same z6Mk… string, minus
the prefix. alsoKnownAs cross-links the did:key, so a
signature verifies under either DID. And the
WorkbookHost service entry makes the identity an address:
sequenceDiagram participant V as resolver — any HTTPS client participant H as shop.example V->>H: GET /.well-known/did.json H-->>V: the DID document — key, alsoKnownAs, service V->>H: GET the WorkbookHost service endpoint H-->>V: the workbook bytes V->>V: verify the manifest — valid under either DID
One more federation rail exists and gets the honest caveat: Radicle.
The engine can publish a repo to the peer-to-peer network and hand back
a rad: ID — but Radicle's keys are per-device (an upstream
limitation, so the tenant keypair isn't the delegate DID yet), and a
cross-machine clone needs at least two nodes or a seed peer. Real,
reachable, not yet the smooth rail did:web is.
what a signature doesn't PROMISE
Honesty section. Five edges, each one load-bearing.
Signed ≠ safe. A signature proves origin and integrity — who, and untouched-since — never goodness. A signed workbook can still be malicious; the maker just can't deny making it. The layer that bounds the damage is the sandbox, and that argument lives in toolkit trust. Two layers, on purpose: provenance tells you who to blame, isolation limits what there is to blame them for.
Signed ≠ private. The manifest authenticates the bytes; it does
nothing to hide them. What egress strips is
privacy's job; what ships as ciphertext is
sealed sections. Don't read a green
valid: true as anything about confidentiality.
No revocation. The X.509 machinery this design dropped included the one thing it did well: a way to say stop trusting this key. A did:key has no such channel. A signature, once out, verifies forever under that DID.
Lose the seed, lose the identity. The flip side of
self-certifying: no authority can reissue you. Concretely — a redeploy
wipes the container filesystem, and the primary tenant's keypair is
restored deterministically from WB_SIGNING_KEY, a secret
holding the base64 of the 32-byte seed. Without it, the redeploy
mints a new DID, and every prior signature stops being yours.
That one environment variable is the identity's continuity; treat it
like the key it literally is.
Carriers keep their own identities. Publishing to ATproto means a second DID — Bluesky's, on a different curve entirely — and the two do not unify. The design's answer: sign with the did:key before the carrier, and the embedded manifest binds the two — the post attributes, the file proves. And Radicle, as above, signs per-device for now. Bound, not merged, is the honest verb on both.
questions people actually ASK
Do I need a Workbooks account to verify?
No — that's the point of the whole design. The DID is the public key, so verification needs the file and nothing else. The reference verifier is 77 lines of WebCrypto that run in a browser tab or Node 20+, with zero dependencies and no network calls.
What if I lose my key?
Then that identity is over — no recovery, no reissue, because
there's no authority to do the reissuing. A new key is a new DID; your
old signatures still verify under the old DID, you just can't add to
them. On an engine, continuity is the WB_SIGNING_KEY
secret. Guard it accordingly.
Can I have the same identity on two machines?
Yes — the identity is 32 bytes of seed, and the publish toolkit's
identity verbs export and import it (the private half only when you
say --include-private). Same seed, same DID, same
signatures, wherever the key lands.
Is this C2PA?
The scheme, not the machinery. The manifest speaks C2PA's vocabulary — claim generator, assertions, action types — so the shape is recognizable to that world. What's dropped is the X.509 chain, replaced by a self-certifying did:key. One check instead of a chain walk; no CA instead of a paid one.
Does signing make my workbook tamper-proof?
Tamper-evident, not tamper-proof. Anyone can edit the bytes or strip the manifest — what they can't do is keep a valid manifest under your DID afterward. Touched bytes fail integrity; a re-sign changes the issuer. The file can be altered; your name can't be kept on the alteration.
Can someone re-sign my work and claim it?
They can publish your bytes under their own DID — a signature proves who signed, not who authored first. What they can never produce is a valid manifest under your DID, and what you always have is your earlier signature with its earlier timestamp. Provenance here is a chain of who-vouched-for-what, not a copyright office.
keep GOING
Sharing sits between the unit it ships and the proofs around it — each neighbor has its own lesson.