11 KiB
ZAP Capability Specification v1.1
1. Why capabilities, not bearer tokens
A bearer token is an ambient authority — anyone holding the bytes can exercise any right encoded in them, against any service that trusts the issuer, in any context, until expiry. JWT is the worst-case archetype: opaque scope strings, no narrowing primitive, no cryptographic binding to the wielder, no in-band revocation. Compromise of a JWT is total and silent until the verifier next refreshes its blacklist.
A capability is a specific authority: it names the Target object,
the Holder keypair, the Issuer, the Permission bitmask, and the parent
in a signed chain. It is unforgeable (signature), non-replayable
(NonceHash caveat), non-transferable (Holder binds to a key the
wielder must possess and prove possession of at use time), and
revocable (publishing the parent's ID kills the entire subtree).
Attenuation — issuing a narrower child cap — is a normal operation,
not an escape hatch. This is the model Cap'n Proto calls level-4
sturdy refs and that zcap-ld encodes in JSON-LD.
ZAP capabilities trade JSON-LD's flexibility for fixed-offset
zero-copy reads. A verifier parses a CapProof in O(chain length)
with no allocation and no schema lookup; the verifier code-path is the
same regardless of which service issued the cap. The result is auth
that is cheaper at runtime than JWT validation while being
qualitatively stronger as a security primitive.
2. Lifecycle
2.1 Issue
+---------+
| Issuer |
| (org A) |
+----+----+
| 1. mint Capability with Holder=hash(Alice.pub),
| Permissions=P, Caveats=C, Parent=zero
| 2. sign over fixed[0..164) || Caveats canonical bytes
| 3. write Scheme tag at sig[3407]
v
+---------+
| root |
| cap |---> wielded by Alice
+---------+
Root caps have Parent = all-zero id32 and a signature that verifies
against Issuer's registered pubkey (resolved via IAM's pubkey registry
or an on-chain mapping). The root cap is the entire trust anchor — there
is no implicit "the issuer pubkey is trusted because it's signed by a
CA"; the verifier must directly know Issuer.
2.2 Attenuate
+---------+ +---------+
| Alice | derive child cap with: | Bob |
| cap | - Permissions' subset of Alice's | cap |
| (root) | - Caveats' superset (add only) | |
+----+----+ - Issuer = Alice +---------+
| - Holder = hash(Bob.pub)
+-------+ Parent = hash(Alice's cap bytes)
| Sign with Alice's privkey
v
Attenuation requires the parent to have PermAttenuate set (or to be a
CapKindDelegate cap). The child's Issuer MUST equal the parent's
Holder pubkey hash; the verifier enforces this. Permissions must
be a bitwise subset of the parent's. Caveats may only add new entries
or strengthen existing ones (the verifier's caveat checker MUST treat
the union of all chain caveats as AND-composed).
2.3 Verify
The verifier receives CapProof{Leaf, Chain} from the wielder along
with an out-of-band possession proof (a fresh signature by Leaf.Holder
over a server-provided nonce; this binds the cap to the live session).
verify(proof, nonce, holderSig):
1. fail if holderSig does not verify against pubkey-for(Leaf.Holder) over nonce
2. let chain = [Leaf] ++ proof.Chain
3. for i, cap in enumerate(chain):
a. fail if revoked(hash(cap)) # revocation check
b. fail if cap.ExpiresAt != 0 and now > cap.ExpiresAt
c. let tag = cap.Sig[3407] # algorithm tag
fail if tag not in {0x01,0x02,0x03,0x04} # unknown scheme
fail if cap.Sig does not verify under tag's primitive over
cap[0..164) || canonical(cap.Caveats)
using pubkey-for(cap.Issuer)
d. if i < len(chain) - 1:
parent = chain[i+1]
fail if cap.Issuer != hash(parent.Holder-pubkey)
fail if (cap.Permissions & ~parent.Permissions) != 0
fail if not parent.Permissions has PermAttenuate
and parent.Kind != CapKindDelegate
else:
fail if cap.Parent != zero32 # last must be root
4. evaluate every Caveat in union(chain.Caveats); fail on any unsatisfied
5. return ok(Leaf.Permissions)
Verifiers MUST refuse on unknown CaveatKind (fail-closed). Verifiers
MUST refuse caps whose Sig algorithm tag they do not implement.
2.4 Revoke
issuer publishes Revocation{CapID=hash(cap), RevokedAt=now,
RevokerSig=sign(CapID || RevokedAt, issuer.priv)}
-> revocation log (on-chain LP-XXX or IAM endpoint)
The revocation log is append-only and broadcast. Verifiers maintain a
cache of revoked CapIDs with a freshness budget (default 60s) and
consult it at step 3a above. Revoking a cap kills it and every
transitive descendant because the verifier walks every parent in the
chain.
3. Signature chain
Each capability is signed by its Issuer, who is the pubkey hashed
into Issuer. For non-root caps, the Issuer is by construction the
Holder of the immediate parent (enforced at verify step 3d). This
means the trust chain is purely bottom-up: a verifier who trusts the
root Issuer can validate a leaf cap by walking signatures up.
The signed bytes are the canonical concatenation of:
Capabilityfixed header bytes[0..164)— Kind through Caveats list pointer.- Each
CaveatinCaveats, encoded asKind:u32-LE || len(Value):u32-LE || Value, in list order.
This excludes Sig itself (bytes [164..3572)) and the list payload
indirection bytes in the ZAP heap area; the canonical caveat encoding
is recomputed by the signer and verifier independently, so heap-area
layout cannot be tampered with without breaking the signature.
The signed bytes are independent of which scheme produced them. A
2-byte signed-payload digest is the same whether the resulting Sig
holds an Ed25519 64-byte signature padded to 3408 bytes or an ML-DSA-65
3309-byte signature padded to 3408 bytes; the verifier reads the
scheme tag at Sig[3407], decodes the leading L_scheme bytes as the
real signature, and ignores the zero pad.
4. Revocation semantics
- Revocation is by
CapID = SHA-256(canonical_bytes(cap) || Sig)wherecanonical_bytesis exactly the bytes signed at issue time (header[0..164)plus canonical caveat encoding). SHA-256 is mandated (not BLAKE3) because it is in every target language's standard library (Gocrypto/sha256, Pythonhashlib, Rustsha2, JS WebCrypto, C++ OpenSSL) — keeping the canonical Go runtime zero-dependency and making the cross-language CapID trivially reproducible. The CapID is an opaque content identifier; treat it as 32 bytes. - Revoking the root revokes everything.
- Revoking a mid-chain cap revokes that cap and every descendant; the ancestor of the revoked cap is unaffected.
- Only the original Issuer can revoke:
RevokerSigmust verify against the pubkey hashed into the revoked cap'sIssuer. - Revocations are append-only. Un-revoke does not exist — issue a new cap if you change your mind.
5. Threat model
| Threat | Mitigation |
|---|---|
| Replay of intercepted cap | CaveatKindNonceHash binds the cap to a one-time-use server nonce; out-of-band holderSig over a per-session nonce binds use to the live keypair |
| Cap exfiltration (stolen bytes) | Holder binds to a pubkey; the attacker must also have the private key + produce holderSig |
| Issuer compromise | Damage is bounded by the issuer's level in the chain: compromise of a leaf issuer affects only its subtree; root compromise = recovery requires out-of-band re-anchor |
| Stale revocation cache | Caveats ExpiresAt short by default (≤ 1 hour for sessions); verifier MAY require fresh revocation fetch for high-value ops |
| Permission escalation by child cap | Verify step 3d rejects any cap with bits not in parent's Permissions |
| Caveat stripping by child | Verifier evaluates the union of caveats across the entire chain; child cannot remove parent caveats |
| Forged signature | Sig covers fixed header + canonical caveats; algorithm tag prevents downgrade |
| Algorithm downgrade | Verifier refuses unknown algorithm tags; ML-DSA-65 tag (0x03) is mandatory-to-implement for new issuers as of v1.0 |
6. Wire stability
The schemas in capabilities.zap define the v1.1 wire format. v1.1
widens the Sig footer from 96 bytes (v1.0) to 3408 bytes, taking the
struct size from 260 bytes to 3572 bytes; the field offset of Sig
(at byte 164) is unchanged. v1.0 and v1.1 buffers do NOT round-trip
through each other's parsers — the field-width change shifts the
struct size by 3312 bytes. Per the project's "no backwards
compatibility, only forwards perfection" rule, every encoder and
decoder is updated in lockstep at the v1.1 tag.
Any of the following requires a new struct name (Capability2, etc.)
and a new ShapeKind allocation:
- adding, removing, or renaming a field
- changing a field's offset (other than the implicit
Sigwidening documented for the v1.0 → v1.1 transition) - changing the meaning of an existing
CapKindorCaveatKind - changing the signature scope or canonical-bytes encoding
The following are allowed without a new struct:
- assigning new
CapKindvalues in the reserved ranges - assigning new
CaveatKindvalues - assigning new
Permissionbits in unassigned positions - assigning new algorithm tags in
sig3408's final byte (Sig[3407]) per theScheme*table incapabilities_kinds.md
Codegen consumers pin to a tagged commit of this repo. New consumers MUST implement the v1.1 verifier exactly as specified in §2.3.
7. Implementation notes for the Go runtime
The parallel work landing in github.com/zap-proto/go/cap will provide:
cap.Capability,cap.Caveat,cap.Revocation,cap.CapProof— typed accessors over a*zap.Message, generated fromcapabilities.zap.cap.Verify(proof, ctx) (Permissions, error)— implements §2.3 exactly.cap.CanonicalBytes(c) []byte— produces the signature scope from §3.cap.CapID(c) [32]byte—SHA-256(CanonicalBytes(c) || c.Sig()).cap.Attenuate(parent, child Permissions, addCaveats []Caveat) (*Capability, error)— validates the subset / add-only rules at mint time, not just verify time.
The runtime MUST refuse to build a cap that would fail its own verifier (no asymmetric "build relaxed, verify strict" footguns).
8. Changelog
- v1.0 — initial release;
Sigfooter 96 bytes, struct size 260. Ed25519 + secp256k1 supported by padding into the 96-byte footer. ML-DSA-65 declared unsupported (signature 3309 B does not fit 96 B). - v1.1 —
Sigfooter widened to 3408 bytes (struct size 3572). ML-DSA-65 + hybrid schemes now fit inline. Algorithm tag byte moved toSig[3407]. Wire-incompatible with v1.0; no compat layer ships.