Vote for your favorite SkillMD. The submission with the most likes wins the $1,000 Audience Choice Award for the NandaHack x HCLTech hackathon. Voting is open through September 25.Vote now →
in reviewhumanPR #118Auth

auth-engineer: delegatable capability tokens with cascading revocation

The default auth plugin (jwt_auth.py) issues flat HMAC tokens and revokes by

Author

PranavPipariya avatar

@PranavPipariya

github profile →
Status
In review
Opened on
Jul 9
Branch
hackathon/auth-engineer-delegatable-tokens

Description

The pitch.

## Problem 04 — Delegatable capability tokens with cascading revocation

The default `auth` plugin (`jwt_auth.py`) issues flat HMAC tokens and revokes by
*exact token string* (`JwtAuth._revoked: set[str]`). It cannot model the most
common multi-agent authorization pattern: an orchestrator hands a worker a
**narrowed, time-bounded sub-capability without going back to the issuer**, and
revoking the orchestrator's own token silently withdraws every sub-capability
underneath it.

This PR ships `delegatable` — a macaroon-style auth plugin (Birgisson et al.,
2014) that does exactly that.

### How it works

A token is not a flat blob: it carries its **entire delegation chain**
(root → leaf) plus a single running HMAC signature where each link is keyed on
its parent's signature:

```
s0  = HMAC(secret, link0)
s1  = HMAC(s0,     link1)      # the previous signature is the next key
sig = s_n
```

Two properties fall out of that construction — and they are the whole point:

- **Tamper-evidence.** Changing any byte of any link (a widened scope, a
  stretched expiry, a swapped audience) breaks the recomputed `sig`.
- **Cascading revocation by construction.** Every descendant embeds its
  ancestors' links, so `verify` walks the whole ancestry offline. Revoking a
  parent link's id invalidates every descendant at the next verify — **no
  per-child revocation list.**

### API (extends `Auth`, stays protocol-compatible)

- `delegate(parent, audience, scopes_subset, ttl, caveats=None) -> Token` — any
  holder mints a child **without the issuer**. Attenuation-only: child scopes
  must be a subset of the parent's (`ScopeEscalationError` otherwise) and child
  TTL must be ≤ the parent's (`TtlExpansionError`).
- `verify(token, presenter=None, context=None) -> AuthContext` — checks the
  chain signature, then per link: revocation (`RevokedAncestorError`), expiry
  (`ExpiredTokenError`), scope/expiry monotonicity, and first-party caveats
  (`CaveatUnsatisfiedError`); binds the presenter to the leaf audience
  (`AudienceMismatchError`).
- `revoke(token)` — O(1); adds the leaf link id to the shared revoked set, which
  collapses the entire subtree beneath it.

All errors subclass one `DelegationError(ValueError)`, mirroring
`EscrowError` in the escrow plugin.

### Adversarial validators (`delegated_auth`)

Three validators, each derived purely from trace evidence so they catch a
*buggy plugin that lets an attack through*, not just self-reported denials:

| Validator | Catches |
|---|---|
| `authz_no_scope_escalation` | a delegated token holding a scope its parent never held |
| `authz_cascading_revocation` | a token that verifies OK **after** an ancestor was revoked (order-aware) |
| `authz_audience_binding` | a token that verifies OK when presented by an agent other than its audience |

**All three PASS under `delegatable` and all three FAIL under the default `jwt`
plugin** (no `delegate` surface → the tree never forms → "no delegation
observed"). Same discrimination model as the merged escrow PR.

### Scenario `scenarios/delegated_auth.yaml`

Coordinator → 3 intermediaries → 12 leaves, exactly as the problem's success
criteria require, plus adversarial probes that the plugin rejects:

- **scope escalation** — an intermediary delegates a scope it does not hold;
- **stale parent** — both an **expired** parent (deterministic via a fixed
  clock and a pre-minted past-dated token) and a **revoked** parent (the
  coordinator revokes `intermediary-2`, cascading to its four leaves);
- **audience confusion** — `leaf-0`'s token is handed to `leaf-1`, which tries
  to present it.

### Determinism

No wall clock, no unseeded RNG. Like `JwtAuth`, the plugin takes a fixed
`clock`; the scenario pins it so **same seed → byte-identical trace** (asserted
in the test suite, verified across seeds `[42, 7, 1337]`).

### Tests

- `test_delegatable.py` — 16 unit tests: attenuation, 3-deep cascading revoke,
  sibling isolation, audience mismatch, expiry, delegate-from-dead-

…

Try it

Open PR on GitHubView diff

Checkout locally

git fetch origin pull/118/head:pr-118
git checkout pr-118