« Phase 08 · Warmup · Track Overview
Deep Dive — Mechanism & Internals
Table of Contents
- 1. Canonical serialization, and why it is not optional here
- 2. The nested actor chain
- 3. The verifier's ordering
- 4. Clock skew is two-sided
- 5. The narrowing algebra as a type-level guarantee
- 6. Single-use codes: the order of the checks
- 7. The exchange, line by line
- 8. Attestation as a set-containment problem
- 9. A traced three-hop flow
- 10. Invariants, complexity, determinism
1. Canonical serialization, and why it is not optional here
def _json_b64(value):
return b64url_encode(json.dumps(value, sort_keys=True, separators=(",", ":")).encode())
JOSE does not require canonical JSON. The signature covers the encoded bytes, so any serialization works as long as you verify the bytes you received rather than re-serializing.
The lab canonicalizes for a different reason: determinism. Without sort_keys, the same claims
produce different tokens depending on dict insertion order, and test_signing_is_deterministic
fails — as does every downstream test that compares tokens for equality.
There is a real lesson hiding in the difference. A verifier that parses a token, re-serializes the
payload, and re-computes the signature will fail on any token whose original encoding differed —
different key order, different whitespace, a +0 versus 0. Verify the bytes as received. The
lab's verify_signature splits the string and HMACs header_b64 + "." + payload_b64 directly,
never touching the parsed objects.
separators=(",", ":") removes the spaces Python's default json.dumps inserts. Cosmetic for
correctness, and it makes tokens noticeably shorter — which matters when a token travels in a
header on every request.
2. The nested actor chain
RFC 8693 does not store the chain as a list. It nests:
{ "sub": "u-42",
"act": { "sub": "payments-investigator",
"act": { "sub": "orchestrator" } } }
The outermost act is the most recent actor. That is the opposite of the intuitive
reading, and getting it backwards produces a chain that looks plausible and is reversed — which
you will not notice until an incident.
The lab keeps the chain as a flat tuple internally, earliest-first, and converts at the boundary:
def _nest_actors(actors): # actors[0] is the earliest
node = {}
for actor in actors:
node = {"sub": actor.sub, "kind": actor.kind, **({"act": node} if node else {})}
return node
The loop builds inside-out: each iteration wraps the previous node, so the last actor processed
ends up outermost. The if node else {} guard omits the act key entirely for the first actor,
rather than emitting "act": {} — an empty object would round-trip into a phantom actor with an
empty sub.
_flatten_actors walks the nesting and reverses, which is why the round-trip test passes for a
three-element chain in both directions.
Why a flat tuple internally. Every operation the platform performs on a chain is a list operation: check the length against a depth limit, check membership for a cycle, append an actor, render for audit. Doing those on a nested structure is possible and unpleasant. Convert at the edge, exactly as Phase 03 converts protocol shapes into an internal task model.
3. The verifier's ordering
Ten checks, and the order is the design:
1. parse header → select signer by `kid`
2. VERIFY SIGNATURE → nothing below is trustworthy before this
3. iss ∈ trusted_issuers
4. aud == policy.audience → the confused-deputy defence
5. exp / nbf / iat → within skew
6. exp - iat ≤ max → a "short-lived" token minted with a long life is not short-lived
7. required scopes present
8. chain depth ≤ max
9. proof of possession
10. replay (jti)
Signature first, absolutely. Every claim is attacker-controlled until the signature verifies.
A verifier that checks exp before the signature is reading a number an attacker chose — harmless
in isolation, and it establishes a habit that is not.
Key selection happens before verification and must not trust the token. The lab reads kid from
the unverified header to select a signer — which is unavoidable, since you need a key to verify —
but the selected signer then imposes its own algorithm. The token influences which key, never
how it is checked. An unknown kid is refused outright rather than falling back to a default.
aud before time. Both are cheap; putting audience first means the most security-relevant
rejection is also the earliest, which keeps it visible in logs when someone is probing.
Lifetime policy (check 6) is the one people omit. An issuer under your control could mint a
token with exp - iat of a year. Every other check passes. The resource server's own policy is the
backstop, and it is what makes "we use 60-second credentials" an enforced property rather than a
convention.
Error codes are for the log, not the caller. Each raise carries a distinct code, and the
TokenError docstring says why the caller must get a generic 401: distinct errors are an oracle.
Try an expired token, try a wrong audience, and you have mapped the trust boundaries without a
single valid credential.
4. Clock skew is two-sided
if claims.exp <= current - skew: raise expired
if claims.nbf and claims.nbf > current + skew: raise not_yet_valid
if claims.iat > current + skew: raise bad_iat
Three comparisons, and the sign of skew differs in each. The reasoning:
exp— accept a token slightly past expiry, because the issuer's clock may be behind ours. Subtract skew from now.nbf— accept a token slightly before its start, because the issuer's clock may be ahead. Add skew to now.iat— refuse a token issued meaningfully in the future, which indicates a badly-skewed issuer or a forgery attempt. Add skew.
Getting a sign wrong produces a system that works in testing (where clocks agree) and fails intermittently in production, at a rate proportional to fleet clock drift. The lab tests both directions and the limit of each, because "we allow skew" without a bound is just a longer expiry.
30 seconds is the conventional default. Larger values weaken expiry meaningfully when tokens live for 60 seconds — at 30 seconds of skew on a 60-second token you have accepted a 50% extension, which is an argument for tightening skew as you shorten lifetimes.
5. The narrowing algebra as a type-level guarantee
def narrow_scopes(held, requested):
return normalize_scopes(r for r in requested if any(scope_covers(h, r) for h in held))
Read what this function can return: only elements of requested that pass a predicate against
held. There is no branch that adds, no default, no fallback. It is incapable of returning a
scope outside what held covers, for any input at all.
That is meaningfully stronger than "we check for escalation before granting". A check can be
bypassed by a new code path; a function with no widening branch cannot. The lab's test asserts the
property over several adversarial inputs including ["*"], and the reason it passes is structural
rather than defensive.
scope_covers supports exactly one wildcard form — a trailing .*:
if held.endswith(".*"):
return requested.startswith(held[:-1])
Note held[:-1], not held[:-2]: for payments.* this leaves payments. including the dot, so
payments.read matches and paymentsX does not. Dropping the dot would make payments.* cover
paymentsandmore, which is a prefix-matching bug of the same family as the redirect-URI one in
§6.
Why not a richer scope language? Because a scope's job is to be legible: an engineer reading a credential should be able to say what it permits. Once scopes support real pattern matching you have a policy engine with no test suite, and the question "what can this token do?" needs evaluation rather than reading. Policy lives in Phase 09.
6. Single-use codes: the order of the checks
if record is None: raise invalid_grant # unknown
if record.used: raise invalid_grant # replay
record.used = True # ← BEFORE the expiry check
if record.expires_at <= now: raise invalid_grant # expired
if record.client_id != ...: raise invalid_grant
if record.redirect_uri != ...: raise invalid_grant
if code_challenge(verifier) != record.challenge: raise invalid_grant
Marking the code used before validating anything else is deliberate. Consider the alternative: an attacker presents a stolen code with a wrong PKCE verifier. If the code is only marked used on success, the attacker can retry indefinitely — and while PKCE makes brute force infeasible, the principle is that a code is consumed by presentation, not by successful redemption.
The mirror consideration: a legitimate client that fails PKCE (a bug in its own code) has now burned its code and must restart the flow. That is correct — a code that survived a failed redemption would be a code an attacker gets to keep guessing at.
Every failure returns the same invalid_grant. Unknown code, used code, expired code, wrong
client, wrong redirect, wrong verifier — one error code. Distinguishing them tells an attacker
which part of their forgery is wrong.
7. The exchange, line by line
subject = self.verifier.verify(request.subject_token) # (1)
if not subject.may_delegate: raise not_delegable
if not request.audience: raise invalid_request
if request.audience == subject.aud: raise no_narrowing # (2)
if not request.delegation and not allow_impersonation: raise ... # (3)
granted = require_no_escalation(subject.scope, request.scopes) # (4)
chain = subject.act
if delegation:
if actor.sub in chain or actor.sub == subject.sub: raise chain_cycle # (5)
chain = chain + (actor,)
if len(chain) > max_depth: raise chain_too_deep
else:
chain = () # (6)
lifetime = min(requested, policy_max, subject.exp - now) # (7)
if lifetime <= 0: raise expired
-
Verify, never parse. The chain in the output is derived from a verified assertion. If this line were
Claims.from_payload(decode(token)), a caller could hand in any chain it liked, and the entire model collapses. This single line is why the lab's identity chain is unforgeable and Phase 03'sCallerContextparameter is not. -
Same audience is refused, not permitted-but-pointless. An exchange that does not narrow has achieved nothing and usually indicates a caller that meant to forward. Failing loudly turns a silent no-op into a design conversation.
-
Impersonation is opt-in at construction, not per request. A per-request flag would let any caller choose to erase the chain, which is exactly the decision that should be a deployment policy.
-
Scope narrowing raises rather than silently intersecting, per §5.
-
The cycle check includes
subject.sub. An agent must not be able to append the user as an actor — that would produceu-42 → u-42and, worse, allow an agent to launder its own actions as the user's. -
Impersonation erases the chain entirely and sets
subto the actor. The lab's test asserts this, because the erasure is the semantics: after impersonation there is no record that a user was ever involved. -
The lifetime floor.
subject.exp - nowcan be negative when the subject token is still acceptable within clock skew but has passed its nominal expiry.minpropagates the negative, the<= 0guard catches it, and the derived token is refused rather than being minted already-expired. The lab has a test for exactly this boundary, and it is the kind of case that only appears under clock drift in production.
may_delegate on the output is len(chain) < max_depth — so a token at the depth limit is
issued but cannot be exchanged again. That is more useful than refusing to issue it: the last hop
still works, it just cannot extend the chain.
8. Attestation as a set-containment problem
def matches(self, attested):
return all(s in attested for s in self.selectors)
Registered selectors must be a subset of attested selectors. The direction matters and is easy to invert.
- Correct: a registration for
{ns=agents, sa=investigator}matches a workload presenting{ns=agents, sa=investigator, pod=abc}. The workload has more attributes than required, which is normal — the attestor reports everything it can observe. - Wrong (
all(a in self.selectors for a in attested)): the workload would have to present exactly the registered set, and any extra observed attribute would break attestation. - Also wrong (
any(...)): a workload matching one selector gets the identity. A registration on{ns=agents, sa=investigator}would then be satisfied by any pod in the namespace — which is the war story in the HITCHHIKERS-GUIDE.
Two guards around it:
An entry with no selectors is refused at registration. all(... for ... in ()) is True, so
an empty selector set matches everything — the identity would be handed to any workload that
asks. Vacuous truth turning into a total authorization bypass is a genuinely elegant bug, and it is
why the check exists.
Multiple matches raise rather than resolving. The lab could take the most specific match, or the first registered. Both are defensible policies and both are policies — meaning the identity a workload receives depends on registration order or on a tie-break rule nobody remembers. Refusing makes ambiguity a configuration error, surfaced at attestation time.
9. A traced three-hop flow
Setup. Entra (entra-1) is the enterprise issuer; the platform STS (platform-1) performs
exchanges. Clock starts at 1000 and ticks 1 per read.
Hop 0 — the user authenticates.
| Step | Value |
|---|---|
code_challenge("aaa…") | base64url(sha256(verifier)) |
/authorize | client teams-channel, exact redirect match, scopes narrowed against registration |
/token | code marked used, PKCE verified |
| result | iss=login.bank.ae, sub=u-42, aud=agent-platform, scope=[payments.read, payments.release], tenant=wholesale, may_delegate=True |
Hop 1 — the orchestrator exchanges. The platform verifier accepts aud=agent-platform.
Audience narrows to payments-investigator; scope unchanged (both still needed); chain becomes
(orchestrator,); lifetime min(120, 120, ~590) = 120.
Hop 2 — the investigator exchanges. A different verifier, whose policy audience is
payments-investigator — this is the important part: each service has its own verifier with its
own expected audience, which is what makes §3's check 4 meaningful. Audience narrows to
core-banking; scope narrows to payments.read alone; chain becomes
(orchestrator, payments-investigator); lifetime is capped by the parent's remaining life at 118 s.
What core banking sees:
sub = u-42
act = orchestrator → payments-investigator
aud = core-banking
scope= [payments.read]
exp = now + 118
Then the refusals, each a test:
| Attempt | Refused because |
|---|---|
widen to treasury.trade | not covered by the subject's scope |
append orchestrator again | already in the chain |
| impersonate | disabled at construction |
| exchange a non-delegable token | may_delegate is false |
| exchange to the same audience | no narrowing |
And the JIT path, which is the same machinery with a different entry point: broker.issue(...)
with a subject_token calls exchange (so the user stays in the chain); without one it mints a
workload credential whose sole actor is the agent and whose may_delegate is False.
10. Invariants, complexity, determinism
Invariants (each tested):
- Base64url round-trips for every padding case and emits no
=. - Signing is deterministic — identical claims produce identical tokens.
- A token whose header claims a different algorithm is refused.
- A token for another audience is refused despite a valid signature.
- Clock skew is tolerated in both directions and bounded in both.
actnests latest-outermost and round-trips earliest-first.narrow_scopesnever returns a scope outsideheld, for any input.- An authorization code is single-use, client-bound, redirect-bound and PKCE-bound.
- Every code failure returns the same error code.
- An exchange narrows the audience, subsets the scope, appends exactly one actor, and never extends the lifetime past the parent's.
- Neither an actor already in the chain nor the subject itself can be appended.
- Attestation requires all registered selectors and refuses ambiguity.
- A registration with no selectors is impossible.
- A suspended identity cannot obtain a credential.
- A key-bound token is useless without the key.
- Two identical flows produce identical tokens.
Complexity:
| Operation | Cost |
|---|---|
sign / verify_signature | \( O(n) \) in token size — one HMAC |
Verifier.verify | \( O(S + C) \) — scopes checked, chain length |
narrow_scopes | \( O(H \cdot R) \) — held × requested; both tiny |
ReplayCache.check_and_record | \( O(N) \) — a full sweep for expiry on every call |
exchange | one verify plus \( O(C) \) chain work |
SpireServer.attest | \( O(E \cdot S) \) — entries × selectors |
ReplayCache sweeping the whole cache on every check is the one that does not scale: at high
request rates it is \( O(N) \) per verification. Production uses a TTL-native store — Redis with
EXPIRE, or a bounded LRU — and the lab's version is deliberately simple because the semantics
(seen-once, expires with the token) are the lesson.
Determinism. No wall clock (injected), no RNG, no uuid4 — jti values come from a counter,
SVID serials from a counter, and thumbprints from a hash. Canonical JSON makes signatures stable.
The result is that test_two_identical_flows_produce_identical_tokens is an exact string
comparison across two independently constructed servers, which would be impossible with any real
source of entropy in the path.