Documentation
¶
Overview ¶
Package aat mints and verifies the delegation chain: AAT-shaped hop tokens per `draft-niyikiza-oauth-attenuating-agent-tokens-01`, adopted as specified rather than reinvented (D8.1, Q95).
The token ¶
One hop is one compact JWS (RFC 7515), EdDSA over Ed25519:
BASE64URL(UTF8(protected)) "." BASE64URL(payload) "." BASE64URL(sig)
with protected header `{"alg":"EdDSA","typ":"aat+jwt","kid":<parent jkt>}` and a payload that is exactly the frozen per-hop field set of receipt-schema-v1.md §7 — `del_depth`, `del_max_depth`, `par_hash`, `cnf.jwk`, raw RFC 9396 `authorization_details`, `exp`, `jti`, `credential`, plus at depth 0 the `root_principal_binding` behalf extension (Q11) and, for autonomous roots, `trigger` (Q14).
The three §7 members that are NOT claims are the ones behalf writes after checking rather than the ones a caller asserts: `verification`, `carriage_route` and `attenuation_flag`. A token that carried its own verification status would be a self-graded exam.
The signing rule, and par_hash ¶
Each hop is signed by its PARENT's key — the key the parent hop confirmed in its own `cnf.jwk`. The depth-0 hop has no parent, so it is signed by the device key `behalf login` bound, which is also the key it confirms: the root is self-signed, and what makes it evidence is not that signature but the OIDC nonce binding underneath it (D5).
`par_hash` is the DAG edge (Q10), defined here as:
par_hash = lowercase-hex SHA-256 over the parent hop's compact JWS,
taken as the ASCII bytes of all three dot-joined segments
(protected "." payload "." signature) — the token exactly as
it travels.
At depth 0 there is no parent and the value is the all-zero sentinel (oidclogin.RootParHash), which the frozen schema requires the field to carry anyway.
Hashing the parent's *signature* as well as its claims is the load-bearing choice: re-parenting a hop under a different parent — even one asserting byte-identical claims under a different key — changes the parent's signature, so the child's par_hash no longer names it and the chain reads `broken`. TestReparentedHopIsBroken pins that.
What this package does not do ¶
It runs no network. Depth 0 reuses oidclogin's three offline checks verbatim (Q17, D5); it does not reimplement them. Attenuation reuses internal/why's comparator (Q13); there is no second comparator here, and there is no bespoke normalization layer.
Index ¶
Constants ¶
const ( Alg = "EdDSA" Typ = "aat+jwt" )
Alg and Typ are the JWS header values every hop token carries. EdDSA over Ed25519 is the only algorithm v1 mints or accepts: the four v1 keys are all Ed25519 (Q69), and an algorithm agility surface nobody needs is an attack surface nobody wanted.
const ( StatusVerified = "verified" StatusAsserted = "asserted" StatusBroken = "broken" )
The three per-hop states (schema §7, §8).
const ( // MethodRootOIDC is D5's three offline checks at depth 0 (Q17). MethodRootOIDC = oidclogin.VerificationMethodRoot // "oidc-nonce-binding" // MethodHopJWS is the AAT signature chain plus its invariants (Q17). MethodHopJWS = "aat-jws-ed25519" // MethodNoSignature is THE caller-asserted case: a hop with no token. MethodNoSignature = "caller-asserted: no signature" // MethodNoRootMaterial: `behalf login` never ran here, so the root // binding cannot be checked and nothing above it can be either (Q21). MethodNoRootMaterial = "caller-asserted: no root material" // MethodRootDegraded: the customer deleted login evidence, so the root // is behalf-attested rather than third-party re-verifiable (Q22). MethodRootDegraded = "caller-asserted: root material incomplete" // MethodParentUnverified: this hop's own token checks out, but the hop // beneath it is not verified, so its authority chains to nothing. MethodParentUnverified = "caller-asserted: parent unverified" // MethodUncomparableGrant: this hop's own token checks out and its // parent's does too, but the two grants are written in a vocabulary the // comparator has no rules for, so the delegation was never shown to // narrow. `verified` on a hop means every invariant was checked, and I4 // was not (Q13, D8.7). MethodUncomparableGrant = "caller-asserted: grant not comparable" // MethodUnsupportedKey: a cnf.jwk v1 cannot verify. Out of scope is // asserted, not broken (Q17). MethodUnsupportedKey = "caller-asserted: unsupported key type" // MethodNotVerifiedAtCapture is the belt-and-braces value for a hop the // capture surface embedded without a verification result to go with it. // Nothing reaches it today; it exists so that an empty status can never // be what a receipt records. MethodNotVerifiedAtCapture = "caller-asserted: not verified at capture" MethodBrokenSignature = "broken: signature invalid" MethodBrokenParHash = "broken: par_hash mismatch" MethodBrokenDepth = "broken: depth invariant" MethodBrokenExpiry = "broken: expiry invariant" MethodBrokenAttenuation = "broken: attenuation broadened" MethodBrokenRoot = "broken: root predicate failed" MethodBrokenMalformed = "broken: malformed hop" )
The method vocabulary. `verification.method` is the frozen schema's only free string on a hop besides `evidence_ref`, so it carries the machine-readable reason: for a verified hop, which predicate established it; for anything else, why it was not established. The values are stable and greppable; the human sentence lives in HopResult.Reason, and the variable detail behind an attenuation finding is recomputed at read time from the raw grants the record already holds (Q11, Q13).
const ChainSchemaVersion = "behalf.sh/aat-chain/v1"
ChainSchemaVersion is the projection key on carried chain material — the bytes that travel in `params._meta["sh.behalf/chain"]` (Q15, D4).
const RootParHash = oidclogin.RootParHash
RootParHash is par_hash at depth 0: the explicit no-parent sentinel, the same constant `behalf login` mints into its root receipt.
Variables ¶
var ErrNotAATChain = errors.New("aat: not AAT chain material")
ErrNotAATChain marks material that is not AAT chain carriage.
Functions ¶
func MarshalChain ¶
MarshalChain renders chain as carriage material.
Types ¶
type BroadeningError ¶
type BroadeningError struct {
// Tool is the draft §3.3 tool identifier, empty when the finding did not
// come from the draft's rules.
Tool string
// Argument is the constraint map key, empty for a tool-level finding —
// a tool the parent never granted, or a closed-world key set that changed
// shape.
Argument string
// ParentConstraint and DerivedConstraint are the two `constraint_type`
// values, empty where one side had no constraint at all.
ParentConstraint string
DerivedConstraint string
// Reason is the comparator's explanation, citing the draft section.
Reason string
}
BroadeningError is Mint's refusal to sign a hop whose grant widens the authority its parent delegated.
It is a typed error rather than a sentence because the caller that hit it usually needs to fix one specific thing. For a grant in the AAT draft's own shape (§3.3), the fields name exactly which capability failed §4.5: the tool, the argument key, and the two `constraint_type` values that could not be shown to narrow. For behalf's own grant shape the comparator has no tool to name, the fields are empty, and Reason carries the whole finding.
The message is unchanged from when this refusal was untyped, deliberately: the sentence a human reads is the comparator's own reason either way.
func (*BroadeningError) Capability ¶
func (e *BroadeningError) Capability() bool
Capability reports whether this refusal names a specific draft §3.3 tool.
func (*BroadeningError) Error ¶
func (e *BroadeningError) Error() string
type Claims ¶
type Claims struct {
DelDepth int `json:"del_depth"`
DelMaxDepth int `json:"del_max_depth"`
ParHash string `json:"par_hash"`
Cnf receipt.Cnf `json:"cnf"`
AuthorizationDetails []map[string]any `json:"authorization_details"`
Exp int64 `json:"exp"`
JTI string `json:"jti"`
Credential receipt.Credential `json:"credential"`
RootPrincipalBinding *receipt.RootBinding `json:"root_principal_binding,omitempty"`
Trigger *receipt.Trigger `json:"trigger,omitempty"`
}
Claims is one hop's token claim set: the frozen §7 per-hop field set, in §7's order. Field order here is serialization order, and the serialized bytes are the signed bytes.
type Hop ¶
Hop is one delegation hop as it travels and as it is verified.
Raw is the exact payload bytes the signature covers, kept verbatim: a hop parsed off the wire is verified against the bytes that arrived, never against a re-marshaling of Claims, because a JSON round-trip that reorders or renumbers anything would silently invalidate a valid token (the span rule, export-format-v1.md §1.2).
JWS is the compact serialization. An EMPTY JWS is not an error and not a forgery: it is the caller-asserted hop — an agent presenting a claim with no token behind it. Verify records it as `asserted`, never `verified` and never `broken`.
func Mint ¶
func Mint(parent ed25519.PrivateKey, parentHop *Hop, params MintParams) (Hop, error)
Mint signs one hop with parent's key.
parentHop nil mints the depth-0 root: par_hash is the no-parent sentinel and the signing key must be the key the root confirms (the device key `behalf login` bound). Otherwise the hop sits one below parentHop, and the signing key must be the key parentHop confirmed in its cnf.jwk — signing a child with any other key produces a token that verifies under nothing, which Mint refuses to produce rather than leaving for Verify to find.
Minting is deterministic: no clock, no randomness. Ed25519 signing is deterministic, the claim set marshals in declaration order, and `authorization_details` maps marshal with sorted keys — so the same parameters under the same keys produce the same token bytes forever.
func ParseChain ¶
ParseChain reads carriage material. It does not verify anything: parsing and verifying are separate so that material which fails to verify is still recorded rather than dropped (Q45 — append and flag, never silently discard).
It returns ErrNotAATChain for material that is not this format at all, so a caller can fall back to older carriage without treating it as corruption.
func (Hop) JKT ¶
JKT returns the RFC 7638 thumbprint of the hop's confirmed key, or "" if cnf.jwk is not an Ed25519 OKP key (Q16, Q17).
func (Hop) ReceiptHop ¶
ReceiptHop projects one verified hop into the frozen receipt shape (schema §7): the claim set, plus the three members behalf writes after checking rather than a caller asserts.
The claim bytes are re-serialized here, which is safe precisely because they are not the evidence: the evidence is the token, addressed from verification.evidence_ref and held in the customer's store. What the receipt embeds is the chain in the schema's own shape, so a reader with nothing but the receipt still sees the whole delegation (Q10).
func (Hop) Unsigned ¶
Unsigned returns a copy of h with its signature stripped: the same claim set, arriving caller-asserted. This is the realistic failure the demo's run B records — an agent that simply claims to act as the human — and it is deliberately constructible, because a product whose failure mode cannot be reproduced cannot be shown.
type HopResult ¶
type HopResult struct {
Status string
Method string
EvidenceRef string
Reason string
// Attenuation is this hop's grant compared against its parent's, by
// internal/why's comparator (Q13). Empty at depth 0, which has no parent
// to be compared against.
Attenuation why.Attenuation
// AttenuationReason is the comparator's explanation for the outcomes
// that need one — `unknown` above all, which is recorded and flagged,
// never swallowed, and which keeps the hop out of `verified` (D8.7).
AttenuationReason string
}
HopResult is one hop's verification outcome. Status, Method and EvidenceRef are what the receipt stores (schema §7); Reason and the attenuation fields are the read-side detail, surfaced to the operator and recomputable from the record.
func Verify ¶
func Verify(chain []Hop, root RootMaterial) []HopResult
Verify checks a chain from the root up and returns one result per hop, in chain order. It runs entirely offline.
Verification is bottom-up because authority is: a hop can be `verified` only if the hop beneath it is. A hop whose own token checks out but whose parent does not verify is `asserted`, not `verified` and not `broken` — nothing about it failed, and nothing about it was established either. The same word covers a hop whose grant the comparator has no rules for: an invariant that could not be checked is not an invariant that held (D8.7).
func (HopResult) StoredFlag ¶
StoredFlag is the value that goes into the hop's `attenuation_flag`.
The frozen schema's enum is {attenuated, unchanged, unknown}: it has no `broadened`, because a broadened grant is not a flag, it is a break — the hop's status says `broken` and its method says so. Leaving the flag empty there is deliberate; the finding is not lost, and `behalf why` recomputes the comparison from the raw grants on every read anyway (Q11).
type MintParams ¶
type MintParams struct {
// Subject is the hop's own public key. It becomes cnf.jwk, and it is the
// key the next hop's signature must verify under.
Subject ed25519.PublicKey
// MaxDepth sets del_max_depth at depth 0. Above the root it is inherited
// from the parent and must be left zero or repeated exactly: a hop that
// could raise its own depth budget would have no budget.
MaxDepth int
// AuthorizationDetails is the raw RFC 9396 grant, captured verbatim
// (Q11). At least one object is required.
AuthorizationDetails []map[string]any
// Exp is the per-hop expiry, verbatim (Q11, Q23). Above the root it must
// not outlive the parent.
Exp int64
// JTI is the per-hop token id — the behalf extension submitted upstream
// per D8.6, and the other half of the revocation-window join (Q23).
JTI string
// Credential is the canonical credential reference — never the token
// itself (Q23).
Credential receipt.Credential
// RootPrincipalBinding is the depth-0 OIDC nonce-thumbprint binding
// (D5). Required at depth 0 unless Trigger is set; refused above it.
RootPrincipalBinding *receipt.RootBinding
// Trigger marks an autonomous depth-0 root (Q14). Refused above depth 0.
Trigger *receipt.Trigger
}
MintParams is everything a hop asserts about itself. Depth, max depth and par_hash are NOT here: they are derived from the parent, which is what makes a minted chain structurally sound by construction.
type RootMaterial ¶
type RootMaterial struct {
// Report is oidclogin.VerifyRoot's outcome over the customer's state
// directory — the D5 three checks, run offline against persisted
// material and reused verbatim here (Q17, Q18). Nil means no usable
// login: the root hop is asserted and everything above it stays
// asserted.
Report *oidclogin.Report
// Absent explains a nil Report in plain language.
Absent string
// At is the instant verification runs — capture time for the proxy.
// Zero disables the wall-clock freshness check, which is what offline
// re-verification wants: the record already states what was true at
// capture, and re-reading it years later must not turn a then-valid
// token into a finding (the same reasoning as D5's deliberate
// no-expiry-check on the root).
At time.Time
}
RootMaterial is the login-time evidence the depth-0 predicate needs, gathered once rather than per receipt.
func LoadRootMaterial ¶
func LoadRootMaterial(stateDir string) RootMaterial
LoadRootMaterial runs the D5 root predicate once against stateDir. It never touches the network and never fails: a state directory with no login is a first-class, expected condition (Q21), reported as absent material rather than as an error.