Files

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:

  1. Capability fixed header bytes [0..164) — Kind through Caveats list pointer.
  2. Each Caveat in Caveats, encoded as Kind: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) where canonical_bytes is 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 (Go crypto/sha256, Python hashlib, Rust sha2, 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: RevokerSig must verify against the pubkey hashed into the revoked cap's Issuer.
  • 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 Sig widening documented for the v1.0 → v1.1 transition)
  • changing the meaning of an existing CapKind or CaveatKind
  • changing the signature scope or canonical-bytes encoding

The following are allowed without a new struct:

  • assigning new CapKind values in the reserved ranges
  • assigning new CaveatKind values
  • assigning new Permission bits in unassigned positions
  • assigning new algorithm tags in sig3408's final byte (Sig[3407]) per the Scheme* table in capabilities_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 from capabilities.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; Sig footer 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 — Sig footer widened to 3408 bytes (struct size 3572). ML-DSA-65 + hybrid schemes now fit inline. Algorithm tag byte moved to Sig[3407]. Wire-incompatible with v1.0; no compat layer ships.