aat

package
v0.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 29, 2026 License: Apache-2.0 Imports: 14 Imported by: 0

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

View Source
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.

View Source
const (
	StatusVerified = "verified"
	StatusAsserted = "asserted"
	StatusBroken   = "broken"
)

The three per-hop states (schema §7, §8).

View Source
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).

View Source
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).

View Source
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

View Source
var ErrNotAATChain = errors.New("aat: not AAT chain material")

ErrNotAATChain marks material that is not AAT chain carriage.

Functions

func MarshalChain

func MarshalChain(chain []Hop) ([]byte, error)

MarshalChain renders chain as carriage material.

func ParHash

func ParHash(jws string) string

ParHash returns the par_hash value a child must carry to name jws as its parent: lowercase-hex SHA-256 over the compact JWS's ASCII bytes, all three segments and the two dots included. An empty jws has no par_hash and returns "".

func Weakest

func Weakest(results []HopResult) string

Weakest returns the receipt-level rollup: the weakest hop, ordered broken < asserted < verified (schema §8, Q12). An empty chain has no authority to roll up and is `asserted`.

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

type Hop struct {
	Claims Claims
	Raw    []byte
	JWS    string
}

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

func ParseChain(raw []byte) ([]Hop, error)

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

func (h Hop) JKT() string

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

func (h Hop) ReceiptHop(res HopResult) receipt.Hop

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) Signed

func (h Hop) Signed() bool

Signed reports whether the hop arrived with a token behind it.

func (Hop) Unsigned

func (h Hop) Unsigned() Hop

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

func (r HopResult) StoredFlag() string

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL