learn / 01·6 — under workbook · sharing

proof thatTRAVELSwith the file

The parent lesson ended on a one-liner: wbx sign shop.htmlprovenance travels with the file. This page is the how. One Ed25519 key per tenant, a did:key that is the public key, a manifest embedded in the HTML, and a verifier that runs in any browser — no account on either end, and honestly, what none of it promises.

the handoff13 min read
Two small figures on bright terraces across a chasm, one handing over a single luminous envelope stamped with one monumental glowing seal shaped like a key — the seal towers over both of them — 1970s sci-fi style

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

shar·ing /ˈʃɛər·ɪŋ/ noun

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 pathdid:key path
identity is proven bya cert chain ending at a CAthe DID — it is the key
verify meanswalk the chain, check each signature, match the subjectdecode the DID, one Ed25519 verify, done
standing infrastructureCAs, trust lists, OCSP / CRL endpointsnone
revocationyes — CRLs, OCSPnone — honest cost, see limits
who can be an issuerwhoever a CA will certifyanyone 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:

the egress doors — all of them sign with the tenant did:key
shippacking a bundle signs workbook.html as part of the pack — the manifest records "signed": true, and a shipped bundle's embedded provenance is checkable on arrival
checkinreturning a borrowed workbook to the library re-signs it with a c2pa.action.updated assertion — the borrow-and-return is on the record
install gatea library can demand require_signature: true — installing a third-party member then refuses on an invalid manifest
provenance is a property of leaving — not a ceremony you remember

The 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.