Expand ↗
Page list (55)

Capability URLs

Capability mode is zetl’s answer to: “I want to share my wiki with a dozen friends, read-only, without running a server, but I don’t want the whole internet reading it.” The output is a plain static site — hostable on any CDN, any S3 bucket, any sftp’d directory — where access is gated by a secret in the URL fragment. No accounts, no server-side auth, nothing to rotate but the URLs themselves.

If you want multi-user editing, you want Running a Team Server. Capability mode is for publishing.

The idea in one paragraph

Each page is encrypted at build time with a cohort-specific key. Invite URLs look like https://wiki.example.com/welcome.html#k=<base64-secret>. The #fragment — by design of the HTTP spec — is never sent to the server. The reader’s browser, executing a small JavaScript shim, pulls the key out of the fragment, decrypts the page locally, and verifies a vault-level Ed25519 signature so nobody can serve them a forged page. Revocation is per-cohort: you rotate the cohort’s salt and re-deploy, old URLs stop decrypting.

Formal spec: see docs/capability-mode.md in the zetl repo.

Setting up a capability site

1. Generate the keys

zetl cap genkey

This prints two secrets to stdout, exactly once:

  • ZETL_CAP_SECRET — the 48-byte content-encryption secret.
  • ZETL_CAP_SIGNING_KEY — the Ed25519 vault-signing private key.

Capture them somewhere safe (a secret store, a password manager, a sealed envelope). You’ll need ZETL_CAP_SECRET on every build and ZETL_CAP_SIGNING_KEY whenever you rotate the signing key.

2. Issue an invite

zetl cap invite alice \
  --cohort eng \
  --site-url https://wiki.example.com

--cohort groups grants that share a content key. Rotate a cohort without touching the others. --site-url is the canonical hostname the invite URL points at (also settable via ZETL_CAP_SITE_URL).

The command prints an invite URL like:

https://wiki.example.com/welcome.html#k=TzN1ej...

Send it by whatever channel works — Signal, in-person, a QR code. The part after # is the secret; if you leak the URL, you leak read access.

Narrow an invite to a subset of pages with --pages, and set a TTL with --expires:

zetl cap invite bob \
  --cohort ops \
  --expires 7d \
  --pages 'projects/*' \
  --site-url https://wiki.example.com

3. List, revoke, rotate

# See who you've invited
zetl cap list
zetl cap list --cohort eng

# Revoke a specific grant by id (see list output)
zetl cap revoke <grant-id>

# Rotate a cohort's content-key salt — URLs stay the same by design,
# but old readers need re-issued keys (see SPEC-034 REQ-3402).
zetl cap rotate --cohort eng

After revoke or rotate, rebuild and redeploy. The old ciphertext is still out there on anyone’s browser cache, but new ciphertext won’t decrypt under an old key, and a well-configured CDN will have dropped the stale response by the time a reader returns.

Two-operator handoff: zetl cap pair

For higher-stakes vaults, you don’t want the inviter to see the reader’s private key even briefly. zetl cap pair runs a SPAKE2 pubkey handoff authenticated by a short spoken phrase:

  • Grantor (you) runs zetl cap pair --grantor. It generates a fresh 4-word BIP39 phrase, prints it, and starts a SPAKE2 session. You read the phrase aloud to the other operator.
  • Grantee (the other operator) runs zetl cap pair --grantee --peer <handshake> --phrase "<4 words>" --pubkey <their-pubkey>. They send their outbound handshake and HMAC tag back to you.
  • You paste those into the blocked grantor session. On a matching phrase, SPAKE2 derives the same shared key on both sides and the HMAC verifies; the authenticated pubkey is now yours to use with zetl cap invite --recipient.

If the phrase differs — because someone in the middle tried to substitute one — the HMAC fails and nothing goes through. This is the “high-value vault” workflow; for friends-and-family scale, the default delegated URL mode is fine.

Split-key mode for high-value vaults

A capability URL is bearer-auth: anyone with the URL reads. For truly sensitive cohorts, --split-key divides the private key between the URL fragment and a second factor — either a spoken phrase the reader types in, or a QR code from a separate device — so neither alone unlocks the vault:

zetl cap invite carol \
  --cohort board \
  --split-key \
  --site-url https://wiki.example.com

Split-key mode must be enabled in the vault config first ([access.split_key] enabled = true). Without that, --split-key is rejected. The second factor is a build-time configuration; default is spoken-phrase (the reader types it in), and qr is a planned alternative.

Other useful subcommands

CommandWhat it does
zetl cap finalise <grant>Mark a grant as operator-confirmed after first use, so the capability binds to a known device (TOFU).
zetl cap checkStale-grant and public-safety audit. Catches capabilities drifting past their useful life.
zetl cap sweepBulk-revoke past-expiry grants.
zetl cap audit-diffScan a vault diff for malicious-content patterns (e.g. a hostile theme attempting exfiltration).
zetl cap rotate-signing-keyRotate the vault signing key. Rebuild required — every page is re-signed.
zetl cap emergency-shutdownPrint the operator checklist for pulling a capability site offline in a hurry. Doesn’t modify files.

When to use capability URLs vs a collab server

  • Capability URLs: publishing read-only to a known group. No server to run. No accounts to manage. Hostable anywhere static. Revocation is per-cohort, not per-user-finely — if you need to yank one reader mid-cohort, that’s a rotate.
  • Collab server: two or more people actively editing. Real-time merge. Per-user commits. Needs a box to run zetl serve --collab on.

For the full operator spec — threat model, deploy recipes for Nginx/CloudFront, rotation ordering, CSP headers — see docs/capability-mode.md in the zetl repo.

Last changed by zetl · history

Backlinks