learn / 03·5 — under toolkit · trust

the signatureBINDSthe promise

Every registry says signed, and supply chains burn anyway. So the question isn't is it signed — it's what does the signature bind, and what refuses to run when it fails. Here the answer is exact: an Ed25519 did:key signature over the canonical manifest, binding the declared contract to an author identity — backed, not replaced, by the sandbox.

trust11 min read
A small figure standing before a monumental wax seal of bright green light pressed into a vast stone gate — the seal glows, the gate stands shut, a single keyhole of pure light at its center — 1970s sci-fi style, bright and ceremonial

the most abused word in SOFTWARE

This is a sub-lesson under toolkit, and it exists to cash one cheque the parent page wrote. The toolkit lesson promised every toolkit is sandboxed, signed, shareable, and its FAQ said the signature tells you who shipped it. Fair — but you've heard that before. npm has signatures. App stores have signatures. Package registries have signatures. And supply-chain attacks happen anyway, constantly, on signed ecosystems.

So the honest reader's question isn't is it signed — that word has been drained of meaning. The question is sharper, two parts: what does the signature actually bind, and what concretely refuses to run when it fails. A signature that proves "someone with an account uploaded these bytes" stops almost nothing. A signature that proves "this exact set of promises was made by this exact author, and the build aborts the instant a byte of those promises changes" stops a real class of attack. This page is the difference between the two, in mechanism.

the DEFINITION

trust /trʌst/ noun

1. for a toolkit: an Ed25519 did:key signature over the canonical manifest body, binding the declared contract — EXEC, CAPS, BUILD_SRC — to a self-certifying author identity; enforced by a build gate that refuses any unsigned or tampered third-party toolkit, and backed by the sandbox that bounds what runs regardless.

Read that definition twice, because the surprising word is manifest, not code. The signature doesn't wrap the compiled bytes — it wraps the document where the toolkit states what it does. Everything else on this page falls out of that one choice.

two layers, distinct JOBS

The parent FAQ gave the compressed answer: "the signature tells you who shipped it, and the sandbox bounds what it can do regardless of who shipped it." That sentence is this whole page folded up. Unfolded, it's two layers that answer two different questions and defeat two different threats — and crucially, neither one substitutes for the other.

layerquestion it answerswhat defeats itwhat catches that
provenance
the signature
who promised exactly what?an author who mislabels their own toolkit, or runs code you imported knowinglythe sandbox — layer 2 bounds it regardless of the label
sandbox
caps + isolation
what can happen, no matter who?nothing about identity — it doesn't ask who, only whatprovenance — layer 1 tells you who to blame and detects tampering

The shape of that table is the thesis. Provenance is identity-aware and tamper-detecting, but it cannot stop a malicious author who honestly signs malicious code — it only proves the code is theirs. The sandbox is identity-blind, but it doesn't care whose code it is: a granted-caps box bounds a stranger's toolkit and a saint's toolkit identically. Stack them and the gap in each is covered by the other. The mistake every "we're signed, you're safe" pitch makes is shipping layer one and implying it does layer two's job. It can't. They're different jobs.

an identity that carries its own PROOF

Before the signature, the identity. In most signing systems the identity is a name that points at a key — you look up "acme-corp" in a registry, a server hands you their public key, and you trust the lookup. That lookup is the attack surface: a compromised registry hands you the wrong key. Here there is no lookup, because the identity is the public key. A tenant's identity is a real W3C did:key, built like this:

flowchart LR
  pk["Ed25519 public key
32 raw bytes"] mc["prefix multicodec
0xED 0x01"] b58["base58btc encode
(Bitcoin alphabet)"] tag["prepend multibase tag
did:key:z…"] pk --> mc --> b58 --> tag did["did:key:z6Mk…"] tag --> did style pk fill:#f3c5a3,stroke:#121316 style did fill:#13d943,stroke:#121316,stroke-width:2.5px

Walk that chain: take the 32-byte Ed25519 public key, glue the multicodec prefix 0xed01 in front of it, base58btc-encode the result with the Bitcoin alphabet, and prepend the multibase tag z. The string you get — did:key:z6Mk…is the key, encoded. It's the same construction Radicle uses, the same the W3C spec describes. Nothing was registered anywhere.

Which makes verification almost shockingly small. To check a signature, the verifier decodes the public key straight back out of the DID string — pattern-matching the 0xED,0x01 prefix off the front — and runs one Erlang crypto call. That's the entire PKI:

def verify_sig(did, msg, sig),
  do: :crypto.verify(:eddsa, :none, msg, sig, [pub_of_did(did), :ed25519])

defp pub_of_did("did:key:z" <> b58),
  do: (<<0xED, 0x01, pub::binary-size(32)>> = decode58(b58); pub)

No certificate authority. No registry server. No account. No network call. The verifier needs nothing but the manifest in front of it — the DID carries its own proof, so anyone, including a browser with no server behind it, can re-implement verification in a few lines. did:key is self-certifying; the identifier and the key it certifies are the same object. (One detail for the operators: WB_SIGNING_KEY, a base64 seed held as a Fly secret, restores the same keypair — and therefore the same DID — across deploys, so a toolkit signed last month still verifies this month.)

what the signature actually BINDS

Now the first aha. The signed bytes are the whole manifest body — and the manifest body is the declared contract. It's where the toolkit states, in plain org keywords, everything a consumer would audit before trusting it. The design comment in the source is blunt about why: the signature binds the declared contract to an author identity "so tampering with a vetted third-party toolkit is detectable." Here is a real manifest, with the signed region bracketed:

┌─ signed region (every byte below is under the signature) ─┐
#+TITLE: icons
#+EXEC: command            ← what it runs
#+TRUST: third-party       ← its declared posture
#+CLI_BIN: icons           ← the name it claims on the PATH
#+BUILD_LANG: js
#+BUILD_SRC: path:src      ← where its bytes come from
#+ARG_MODE: stdin1
#+CAPS:                    ← (empty — claims no capabilities)
#+AUTHOR_DID: did:key:z6Mk…   ← the identity, INSIDE the signed bytes
└──────────────────────────────────────────────────────────┘
#+SIGNATURE: pSEsQk9…base64…  ← the only line OUTSIDE

Every promise a consumer cares about is inside that bracket. EXEC (the shape it runs as), CAPS (what it may touch), BUILD_SRC and BUILD_LANG (where its bytes come from and how they're built), CLI_BIN and ARG_MODE (how it's invoked), SHA256 and WASM_PATH (the pinned artifact), and TRUST itself. Flip any one of them after signing and the bytes no longer match the signature.

Notice the empty #+CAPS: line is also inside the bracket. That's the point of the whole scheme made concrete: an attacker's most valuable edit — quietly adding net-fetch to a toolkit that declared no network access — is exactly the byte change the signature catches. The thing worth protecting was never the binary. It's the claim, because the claim is what the consumer audited.

And the binding is transitive. When #+BUILD_SRC points at a prebuilt artifact — wasm:<url> or archive:<url> — the #+SHA256: pin sits inside the signed manifest too. The source comment calls it plainly: "the sha pin is the supply-chain gate … a compromised mirror cannot swap the binary." An unpinned fetch doesn't fail silently either — it prints (UNPINNED — add #+SHA256: <hash> to the manifest). So the signature reaches past the manifest to the exact bytes the manifest fetches.

strip-then-SIGN

Depth rung — skippable, but it's where a subtle attack gets closed. There's a chicken-and-egg problem in signing a file that must contain its own signature: the moment you append the #+SIGNATURE: line, you've changed the bytes you just signed. The answer is a small canonicalization dance called strip-then-sign, and it's worth watching closely because one detail in it defeats a real forgery.

sequenceDiagram
  participant A as author · wbx toolkit sign
  participant M as manifest.org
  participant C as consumer · manifest_provenance
  A->>M: read manifest body
  A->>A: strip existing SIGNATURE + AUTHOR_DID lines
  A->>A: append fresh #+AUTHOR_DID, then trim → canonical body
  A->>A: Ed25519 sign(canonical body)
  A->>M: write body + #+SIGNATURE line
  Note over M: AUTHOR_DID is INSIDE the signed body;
SIGNATURE is the only line outside C->>M: read manifest C->>C: strip only SIGNATURE lines, trim → same canonical body C->>C: verify_sig(AUTHOR_DID, body, sig) C-->>C: {:ok, did} or a named failure atom

Read the exchange as a story. The author's side strips any existing signature and author lines first — that's what makes re-signing idempotent, so signing the same toolkit twice yields a clean result instead of stacking lines. Then it appends a fresh #+AUTHOR_DID, trims, and signs that exact string. The consumer's side strips only the SIGNATURE line, trims, and gets byte-for-byte the same canonical body the author signed — so verification can succeed.

Here's the load-bearing detail. The #+AUTHOR_DID line is inside the signed bytes. Why that matters: imagine an attacker who wants to claim a vetted toolkit as their own. They swap the AUTHOR_DID to their DID — but now the signed body has changed, so the original signature no longer verifies, and they can't produce a new one without the original author's private key. The DID can't be swapped without re-signing the whole manifest, and re-signing needs a key they don't have. The identity is welded to the contract, not stapled beside it.

the gate that says REFUSED

A signature nobody enforces is decoration. This one has teeth at three points in a toolkit's life — and the strongest one stops a bad toolkit before a single byte compiles. Trace a manifest through them:

flowchart TD
  m["manifest.org"] --> t{"#+TRUST?"}
  t -- "first-party
(or absent)" --> b1["build — no signature checked"] t -- "third-party" --> p{"provenance valid?"} p -- "ok :ok, did" --> b2["build · set_trust(:node)"] p -- "fail" --> r["REFUSED — third-party toolkit
with invalid provenance"] b2 --> esc["run escalated to :node
a separate BEAM VM"] style m fill:#f3c5a3,stroke:#121316 style r fill:#e8857a,stroke:#121316,stroke-width:2.5px style esc fill:#13d943,stroke:#121316

Follow the branches. A first-party toolkit (or one with no #+TRUST line at all — absent defaults to first-party) takes the left path and builds with no signature check. A third-party toolkit takes the right: its provenance must verify, and the three enforcement points are these.

  • Verifywbx toolkit verify appends one line to its report: ✓ third-party signature valid (did:key:…) or ✗ third-party provenance: <why> (sign with wb toolkit sign <id>). First-party toolkits get no such line — there's nothing to check. This is the advisory point; it tells you, but doesn't stop you.
  • Build — the hard gate. A third-party toolkit whose provenance doesn't verify is refused with an exact string: <id>: REFUSED — third-party toolkit with invalid provenance (<why>); author must wb toolkit sign <id>. The source comment: "an unsigned/tampered third-party toolkit never builds." Nothing downstream happens — the bytes never compile.
  • Run — even after a clean build, posture follows the toolkit into execution. set_trust records it, and a third-party command escalates one isolation rung to :node — a separate BEAM VM — instead of the normal local subprocess. If distribution can't come up it falls back gracefully to the local path: "fail-open on availability, never on the work." A stranger's toolkit runs one wall further out by default.

reading a FAILURE

Depth rung. When a build prints REFUSED … (<why>), the <why> is one of four atoms, checked in order, each telling you exactly which rung of verification fell through and how to fix it:

atomwhat it meansthe fix
:no_author_didno #+AUTHOR_DID line — the manifest was never signed at allwb toolkit sign <id>
:no_signaturean author DID is present but no #+SIGNATURE linewb toolkit sign <id>
:bad_signature_encodingthe signature line isn't valid Base64 — corrupted or hand-editedre-sign (wb toolkit sign) to overwrite it cleanly
:bad_signaturedecodes fine, but doesn't verify against the body — the manifest was changed after signingre-sign — and ask who changed which line

The verdict of that table: the first two atoms mean never signed, and the fix is just to sign. The fourth, :bad_signature, is the one that matters — it means the bytes and the signature disagree, which is precisely the tamper case the whole scheme exists to catch. If you see it on a toolkit you didn't change, the manifest changed under you, and the next question isn't "how do I make this build" — it's "what got edited."

Put it all together as one real round trip. You sign a toolkit; an attacker edits a single line — say flipping an empty #+CAPS: to #+CAPS: net-fetch; the build refuses:

$ wbx toolkit sign huniq
signed huniq as did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH

# attacker edits one line:  #+CAPS:  →  #+CAPS: net-fetch

$ wbx toolkit build huniq
huniq: REFUSED — third-party toolkit with invalid provenance (bad_signature); author must `wb toolkit sign huniq`

The edit could have been any manifest byte — EXEC, BUILD_SRC, the SHA256 pin, even the AUTHOR_DID itself. Every one of them lands you at bad_signature, and every one of them is refused before compilation.

one key, three RAILS

Depth rung. The same tenant did:key isn't just a toolkit thing — it signs three surfaces across the ecosystem from one keypair, with three verify points. One identity, three rails:

flowchart TD
  k["one tenant keypair
Ed25519 · did:key"] k --> r1["toolkit manifests
Toolkits.sign_text"] k --> r2["published workbook HTML
embedded c2pa-style manifest
+ asset_sha256"] k --> r3["agent run ledgers
the attributable signed half"] r1 --> v1["build gate · wb toolkit verify"] r2 --> v2["install gate · require_signature: true
wbx verify <file.html>"] r3 --> v3["ledger verification"] style k fill:#13d943,stroke:#121316,stroke-width:2.5px style r2 fill:#f3c5a3,stroke:#121316

Read the fan-out. The middle rail is the one a consumer meets at install time: a published workbook carries an embedded application/workbooks-c2pa+json manifest — an Ed25519 signature over canonical key-sorted JSON plus an asset_sha256 of the file itself. At install, Library.install(blob, require_signature: true) checks both the signature and that the artifact hasn't changed since: a tampered bundle fails {:error, {:bad_signature, _}}, an unsigned one {:error, {:unverifiable, _}}. A test proves it the honest way — append a comment after signing and the integrity check catches it. On the CLI, wbx verify <file.html> is pure-read, no key needed, and prints valid=… signature=… asset_integrity=… issuer=did:key:….

The deliberate part: the manifest module states it outright — "no C2PA/X.509 stack, no Rust signer binary." The did:key supersedes the planned X.509 path. It is, roughly, C2PA's idea — provenance that travels with the bytes — without C2PA's certificate machinery. One small self-certifying key, three places it proves authorship, zero infrastructure to stand up.

why your own toolkits SKIP this

The obvious objection: if signing is so important, why does a first-party toolkit skip every check? The answer is a deliberate threat-model decision, and the source is candid about the tension. The discovery root — $WB_TOOLKITS_ROOT or ./toolkits — is, in the comment's own words, "an UNAUTHENTICATED, writable directory. A toolkit dropped there is UNTRUSTED supply-chain input — NOT first-party by virtue of its location."

So location does not confer trust. What confers it is something else: for your own in-tree toolkits, the repo's own review-and-commit process is the trust mechanism. The comment finishes the thought — "location-trust is fine for our own repo." The signature exists for code that crossed an org boundary: a toolkit from a stranger, fetched from somewhere, that your review process never touched. For that, you need cryptographic provenance. For code that went through your pull requests, you already have a trust mechanism, and it's a better one than a signature: human review with history.

This is why promotion writes #+TRUST: first-party — when you promote a toolkit you authored, it's yours, and trust is relative to who's running it. The same toolkit a third-party consumer installs would have them granting its #+CAPS at install time. Trust isn't a property baked into the toolkit; it's the relationship between the toolkit and the person running it.

the honest EDGES

This page owes some real limits, and stating them is the whole reason the thesis is two layers and not one.

  • The trust label is self-declared. #+TRUST lives in the manifest the author controls. The gate detects tampering with a vetted third-party toolkit — it does not detect mislabeling. A malicious author who writes first-party skips the signature check entirely. What saves you then isn't provenance — it's that you imported it knowingly, and that the sandbox and granted caps still bound it. Layer two, doing its job.
  • The signature covers the manifest, not the prose. Skills (skills/*.org) and path: source directories aren't under the signature. What bounds them is the sha-pinned content-addressed artifact for prebuilts, the sandbox and granted caps at run time, and registration containment that rejects wasm outside build/commands/.
  • There's no signed third-party catalog yet. Honestly: no in-tree toolkit currently carries a signature — all of them are first-party, by design. The third-party gate is exercised by tests and the install rail, not yet by a public signed catalog. The mechanism is real and tested; the ecosystem around it is young.
  • Isolation fails open. The run-time escalation to :node falls back to the local subprocess if distribution can't start. That's a deliberate availability choice — fail-open on availability, never on the work — but it means the extra wall is best-effort, not a guarantee.

Every one of those edges points at the same conclusion: the signature is a sharp tool for one job — who promised exactly what, and has it been changed — and a useless tool for the other — what can this code actually do to me. That second job belongs to the sandbox, explicit capability grants, reserved built-in names that can never be shadowed, and the sha pins. Provenance and sandbox: distinct jobs, layered, neither pretending to be the other.

questions people actually ASK

Do I need to sign my own first-party toolkits?

No — and not because we got lazy. Your repo's review-and-commit process is the trust mechanism for code you authored, and it's stronger than a signature (it has human judgment and history). The signature is for code that crossed an org boundary, where you have no review relationship with the author. Sign before you share a toolkit as third-party; you don't sign your own in-tree work.

What if I lose my signing key?

A fresh keypair mints a fresh DID — a new identity, with no claim on anything the old one signed. That's why WB_SIGNING_KEY exists: a base64 seed, held as a deploy secret, restores the same keypair and therefore the same DID across redeploys, so prior signatures keep verifying. Lose the seed and you don't lose the toolkits — you lose the ability to re-sign as the same author.

Can someone re-sign my toolkit as theirs?

Yes — and that's honest, not a hole. They can strip your signature, write their own DID, and sign it with their key. What they cannot do is keep your signature while claiming authorship, because your DID is inside the signed bytes. The signature proves who shipped this exact contract, not copyright. If they re-sign, the manifest now provably says they shipped it — which is exactly what a provenance system should record.

Is this C2PA?

It's C2PA's idea without C2PA's machinery. Provenance that travels with the bytes — yes. Certificate chains, X.509, a Rust signer binary — deliberately no. The manifest module says so in as many words. A self-certifying did:key does the job a cert chain was going to do, in one crypto call and zero infrastructure.

What exactly breaks the signature?

Any byte of the manifest body. Flip an empty CAPS to grant network, change the SHA256 pin, edit the EXEC shape, even alter the AUTHOR_DID — every one lands at bad_signature and is refused before a build. The SIGNATURE line itself is the one line outside the signed region; everything above it is welded shut.

If the signature can't stop a malicious author, what's the point?

Different job. The signature stops silent tampering with a toolkit you already vetted — the supply-chain attack where a mirror swaps bytes under a trusted name. It does not stop a known-bad author shipping known-bad code; the sandbox does that, by bounding what any toolkit can touch regardless of who wrote it. Two layers, distinct jobs — install neither alone.

keep GOING

Trust is one cheek of a two-layer face — provenance here, the sandbox next door. The parent page holds both.