Capability Auth

The triblespace-net crate ships a chain-of-trust capability system on top of iroh's TLS-verified peer identities. Every connection on the /triblespace/pile-sync/4 ALPN must present a capability before any other op is served. This chapter explains the team model, the CLI lifecycle, and the two-tier scope gate the relay enforces.

For the design rationale (single team root vs multi-root web-of-trust, sign-the-bytes convention, embedded parent sig optimisation), see the companion design notes in triblespace-core/src/repo/capability.rs's module-level docs.

Model

A team has one immutable root keypair, generated once at team creation and used to sign exactly one capability — the founder's. After that the root keypair is archived; it never operates online. Like a CA: bootstrapping authority, not runtime authority.

All other capabilities chain off the founder's via delegation. Any holder of a capability can sign a sub-capability for someone else, as long as the sub-cap's scope is a subset of their own. Verification walks the chain back to the team root.

Each capability is two blobs stored in the pile:

  • A cap blob — a TribleSet carrying cap_subject (the pubkey this cap authorises), cap_issuer (the pubkey that signed it), cap_scope_root (the entity id anchoring the scope facts inside the same blob), and metadata::expires_at.
  • A sig blob — a TribleSet with sig_signs (handle of the cap blob) plus repo::signed_by + signature_r + signature_s, reusing the existing commit-signature attribute conventions.

Signatures attest to the cap blob's canonical bytes (matching how Workspace::commit signs commit metadata), not to a hash of those bytes — keeping signatures hash-agnostic across any future change to the handle scheme.

Non-root caps embed their parent's signature inline as a sub-entity within the cap blob (cap_embedded_parent_sig). This halves cold-cache verification fetch counts: at chain depth N, the verifier needs N+1 blobs instead of 2N+1.

Team Lifecycle (CLI)

The trible team subcommands cover the full lifecycle. All four operations work directly against a pile file — they don't require the network thread.

trible team create --pile PATH [--key KEY_PATH]
    Mint a new team root keypair, sign the founder's capability with
    it, and write both into the pile. Prints the team root pubkey
    (publish this to peers), the team root SECRET (archive offline),
    and the founder's cap-sig handle (the founder's "credential" for
    OP_AUTH).

trible team invite --pile PATH --team-root HEX --cap HEX --key ISSUER
                   --invitee HEX --scope (read|write|admin)
                   [--branch HEX]...
    Issue a sub-capability to another peer. ISSUER must hold a cap
    that subsumes the requested scope. The invitee's pubkey appears
    on its own (use `trible pile net identity` on the invitee's
    machine to print it). Prints the invitee's cap-sig handle.

trible team request-join --admin HEX --scope (read|write|admin)
                         [--key PATH] [--pile PATH]
    Send an OP_REQUEST_CAP to an admin's running daemon asking to
    be issued a capability. The admin sees the request on their
    pending-requests pin (`team list-pending`); after `team approve`
    the freshly-signed cap arrives via the auth-handshake ALPN.

trible team approve --pile PATH --entry HEX --team-root HEX
                    --cap HEX [--key PATH]
    Approve a pending request, sign the cap, dispatch it back to
    the requester, and add a renewal-policy entry so the local
    daemon keeps the cap renewed.

trible team retract --pile PATH --entry HEX
    Stop auto-renewing one (subject, scope) entry. The peer's
    chain dies at its next natural expiry. Pure local decision —
    no broadcast, no transitive cascade. This is the eviction
    primitive: there is no team-root-signed revocation blob in
    the descriptive-caps model.

trible team list --pile PATH
    Audit summary: per-cap detail line (issuer → subject, scope,
    expiry — sorted soonest-expiry-first).

trible team list-pending --pile PATH
    Incoming join requests awaiting approval.

trible team list-issued --pile PATH
    Renewal-policy entries this node is keeping renewed.

trible team show --pile PATH --cap HEX [--verify TEAM_ROOT_HEX]
    Walk one chain end-to-end. Prints each level with subject,
    issuer, scope, expiry, sig blob handle, cap blob handle, and
    a signer-matches-issuer (`✓` / `✗ MISMATCH`) check. Bounded
    by MAX_DEPTH=32; chains beyond root render the embedded
    parent sig as `(embedded in level above)`. Use when `list`
    shows a cap is present but a connection still fails — `show`
    surfaces structural mismatches (signer ≠ issuer, missing
    parent sig fields) that the summary view hides.

    `--verify <TEAM_ROOT_HEX>` (or env `TRIBLE_TEAM_ROOT`)
    additionally runs `verify_chain` against the given team root
    and reports `✓ VERIFIED` or `✗ FAILED — <VerifyError>` —
    the same code path the relay's `OP_AUTH` uses, so the
    result is the local-side rehearsal of what a real connection
    attempt would produce. Add `--expected-subject HEX` to
    override the default subject check (the leaf cap's declared
    `cap_subject`) for subject-substitution-attack detection.

A typical bootstrap flow:

# Founder, on machine A:
$ trible team create --pile team.pile --key founder.key
team root pubkey: 1a8a6a9d8ca1da67facab373de21233b...
team root SECRET: <archive offline>
founder cap (sig): 4e6e02d51c3676ece1eea9094f8e9d76...

# Invitee, on machine B:
$ trible pile net identity --key invitee.key
node: e825b3a8d387b4dae1720b0edcbfaa9e...

# Founder, on machine A:
$ trible team invite --pile team.pile \
    --team-root 1a8a6a9d... \
    --cap       4e6e02d5... \
    --key       founder.key \
    --invitee   e825b3a8... \
    --scope     read
issued cap (sig): 7afe59e7f895b23f05452ff7919e12e4...

The invitee then runs the relay (or any pile-net peer) with TRIBLE_TEAM_ROOT and TRIBLE_TEAM_CAP set:

$ TRIBLE_TEAM_ROOT=1a8a6a9d... \
  TRIBLE_TEAM_CAP=7afe59e7... \
  trible pile net sync /path/to/their.pile --peers <founder-id>

Without those env vars the peer falls back to a single-user team-of-one (team_root = signing_key.verifying_key()), which means only their own caps will pass — useful for solo workflows but rejects every other peer's cap.

Wire Protocol

Protocol v4 (/triblespace/pile-sync/4) makes auth mandatory:

OpByteMeaning
OP_LIST0x01List all branches and heads
OP_GET_BLOB0x02Fetch one blob by hash
OP_CHILDREN0x03List blob hashes referenced by a parent
OP_HEAD0x04Head hash of one branch
OP_AUTH0x05Present a capability sig handle

The first stream on every connection must be OP_AUTH. The server fetches the referenced sig blob, walks back to the team root through embedded parent sigs and cap_parent handles, and either accepts (AUTH_OK = 0x00) or rejects (AUTH_REJECTED = 0x01). Subsequent streams on the same connection inherit that verified capability for the lifetime of the connection — there's no per-stream re-auth.

Streams sent before OP_AUTH or after AUTH_REJECTED are silently closed. The server doesn't leak a "you sent the wrong thing" error back to the client.

Two-Tier Scope Gate

Capabilities encode their scope as tribles hung off cap_scope_root:

  • One or more metadata::tag: PERM_* triples granting permissions (PERM_READ, PERM_WRITE, PERM_ADMIN).
  • Zero or more scope_branch: <branch_id> triples restricting the permission to a specific branch. An empty branch-restriction set means "all branches".

The relay enforces scope at two levels:

Branch level (OP_LIST, OP_HEAD)

VerifiedCapability::grants_read_on(branch) filters which branches the peer can see. Out-of-scope branches are silently dropped from OP_LIST responses; OP_HEAD for an out-of-scope branch returns NIL_HASH (indistinguishable from "branch doesn't exist", as far as the wire is concerned).

Blob level (OP_GET_BLOB, OP_CHILDREN)

A peer with branch-X-only scope could otherwise circumvent the branch gate by guessing or probing raw blob hashes from branch Y. The blob-level gate closes that hole: a hash is in scope only if it's reachable (via 32-byte child chunks) from at least one branch head the cap grants read on. Out-of-scope blobs surface as None (length = u64::MAX) on OP_GET_BLOB; OP_CHILDREN filters its returned list to in-scope hashes only.

Unrestricted caps (granted_branches() == None — no scope_branch tribles) short-circuit to "every present blob is in scope".

Permission semantics mirror scope_subsumes: PERM_WRITE and PERM_ADMIN imply PERM_READ; PERM_ADMIN is required to delegate sub-capabilities. The reachability scan is recomputed per request today; per-stream caching is a future optimisation for chain-walk-heavy workloads.

Eviction

There is no team-root-signed revocation blob. The descriptive-caps model evicts peers via per-issuer non-renewal: every cap carries a short natural expiry (default 30 days), the issuer's running daemon refreshes the cap before that expiry as long as a renewal-policy entry says it should, and team retract deletes the entry. The peer's chain dies at the next natural expiry. The decision is local to the issuer — nothing propagates, nothing cascades, nothing has to be signed by the team root.

This trades the "instant network-wide revocation" property for several real wins:

  • No revocation rescan on every snapshot refresh. Previously update_snapshot walked every blob looking for (rev, sig) pairs signed by the team root; that was a CPU hotspot on quiescent peers. The refresh path is now a near-no-op snapshot swap.
  • No HashSet<VerifyingKey> shared state. The old model needed a process-wide revocation set, written from the snapshot scanner and read from every chain verification. Removing it dropped a cross-thread synchronisation point.
  • No team-root keypair in normal operation. Issuing a revocation required the team root SECRET to sign. Now the root SECRET lives in cold storage; every day-to-day operation (invite, approve, retract) uses a regular admin cap.
  • Monotonic gossip. The wire protocol no longer has to gossip revocation blobs as a special category that has to land before the affected cap is verified. Caps and renewals are first-class blobs; everything else is local issuer policy.

The trade-off: there's no way to immediately invalidate a compromised key network-wide. The mitigation is to keep natural expiries short (the 30-day default is a starting point, not a hard rule) and to ensure issuers stop renewing the moment they notice. For acutely sensitive teams the natural-expiry window can be tightened to hours.

Renewal happens via the same OP_DELIVER_CAP path that team approve uses: the issuer's daemon signs a fresh cap with a later expiry, dispatches it to the subject's daemon over the auth-handshake ALPN, and the subject pins it on the team-cap pin. team list-issued shows the renewal-policy entries this node is keeping renewed; team retract --entry HEX removes one.

PeerConfig Surface

use triblespace::net::peer::{Peer, PeerConfig};

let pile = triblespace::core::repo::pile::Pile::open(path)?;
let peer = Peer::new(pile, signing_key.clone(), PeerConfig {
    peers: vec![bootstrap_endpoint_id],
    gossip: true,                           // false = pull/serve-only
    team_root: team_root_pubkey,            // 32 bytes — the team's CA AND
                                            // the gossip mesh id when gossip=true
    self_cap: my_own_cap_sig_handle,        // what we present on OP_AUTH
});

There's no Default impl: every peer construction site must specify a team root because auth is mandatory. The CLI's single-user team-of-one fallback sets team_root = signing_key.verifying_key() and self_cap = [0u8; 32] (which the remote rejects, signalling that multi-user operation needs the env vars).

For a hosted relay running for a team, the operator only needs:

  • 32 bytes: the team root pubkey
  • 32 bytes: the relay's own cap-sig handle (the team grants it a read-or-better cap and the operator pastes that handle into the config)

That's it. No per-user accounts, no shared secrets, no team configuration database. Caps live in the pile alongside everything else and gossip propagates them naturally.