<!--
  Full-page Markdown export (rendered HTML → GFM).
  Source: https://neotoma.io/zh/aauth/spec
  Generated: 2026-04-27T12:48:49.019Z
-->
# AAuth wire format and verification

This page is the canonical reference for Neotoma's AAuth wire format, signature verification rules, and trust-tier derivation. It is the upstream spec for the verifier in `src/middleware/aauth_verify.ts`, the agent-identity resolution in `src/crypto/agent_identity.ts`, and the attribution-policy seam in `src/services/attribution_policy.ts`.

For an integrator-oriented walkthrough see the [integration guide](/aauth/integration). Cryptographic attestation envelopes (Apple SE, WebAuthn-packed, TPM 2.0) are specified separately on the [attestation](/aauth/attestation) page; CLI keygen and hardware backends are on [CLI keys](/aauth/cli-keys).

## What AAuth is (and is not)

-   A writing agent owns a stable keypair (software or hardware-backed) and an `aa-agent+jwt` token carrying `iss`, `sub`, and a `cnf.jwk` confirmation key.
-   The agent signs each HTTP request per RFC 9421. Neotoma verifies the signature, derives a `trust_tier`, and stamps `(agent_thumbprint, agent_sub, agent_iss, trust_tier, …)` onto every durable row.
-   Bearer tokens, OAuth, and MCP `connection_id` continue to resolve the human `user_id`. AAuth never bypasses user-scope resolution.
-   When AAuth is absent, Neotoma falls back to MCP `clientInfo` or `X-Client-Name` / `X-Client-Version` HTTP headers as a self-reported attribution channel. Self-reported channels never reach the `hardware`, `operator_attested`, or `software` tiers, the highest they earn is `unverified_client`.

◆

## Wire format

Neotoma identifies a writing agent over **two channels**, in precedence order.

### 1\. AAuth (signed request)

The request carries:

-   `Signature`, the signature bytes (RFC 9421).
-   `Signature-Input`, signature parameters. MUST cover, at minimum: `@authority`, `@method`, `@target-uri`, `content-digest` (when the request has a body), and the `signature-key` header itself.
-   `Signature-Key`, the agent's JWK plus an `aa-agent+jwt` agent token. The JWT carries `typ: "aa-agent+jwt"`, `iss`, `sub`, `iat`, `cnf.jwk`, and optionally `cnf.attestation`.

Verification runs against the canonical `authority` configured via `NEOTOMA_AUTH_AUTHORITY` (defaults to the local dev host). The `authority` value MUST match the server's canonical host, using the request `Host` header is explicitly unsafe and is rejected.

### 2\. MCP `clientInfo` fallback

On `initialize` the MCP transport self-reports `{ name, version }`. Generic names (`mcp`, `client`, `mcp-client`, `unknown`, `anonymous`, …) are dropped through `normaliseClientNameWithReason` and treated as if `clientInfo` were absent. Non-MCP HTTP callers can pass the same information via the `X-Client-Name` and `X-Client-Version` headers.

A successful AAuth verification populates `agent_thumbprint` (RFC 7638), `agent_sub`, `agent_iss`, `agent_algorithm`, and `agent_public_key`. A populated `clientInfo` populates `client_name` and `client_version`. Both halves are persisted; the cascade below chooses the trust tier.

◆

## Verification rules

Performed by `aauth_verify` middleware:

1.  Parse `Signature-Input` and reject if any required component is missing.
2.  Resolve `@authority` against `NEOTOMA_AUTH_AUTHORITY`. Mismatch fails with `authority_mismatch`.
3.  Recompute `content-digest` (when the request has a body) and compare to the header. Mismatch fails with `digest_mismatch`.
4.  Parse the `Signature-Key` header into JWK + JWT. Reject if `typ` is not `aa-agent+jwt` or the JWT is malformed.
5.  Verify the JWT signature against the embedded JWK. The JWT MUST bind `cnf.jwk` to the same key used to sign the request.
6.  Check `iat` against the configured AAuth clock-skew window (`NEOTOMA_AUTH_AGENT_TOKEN_MAX_AGE_S`, default 300 s).
7.  Verify the request signature against the JWK. Failure produces `signature_invalid`.
8.  If `cnf.attestation` is present, dispatch to the attestation verifier and capture the per-format `attestation_outcome`.

Verifier failures **never** reject the request when the underlying signature is valid; they only prevent tier promotion. The `attribution.decision` block on `GET /session` records the failure reason so operators can debug from the Inspector.

◆

## Trust tiers

A single enum is stamped onto every durable row. Tier resolution is performed once per request, services and clients MUST read the already-resolved `AgentIdentity.trust_tier` from the per-request context and never re-derive it.

-   **`hardware`** , AAuth verified AND the JWT carries a `cnf.attestation` the verifier accepts AND (v0.12.0+) the bound key is not revoked.
-   **`operator_attested`** , AAuth verified AND `iss` (or `iss:sub`) is in the operator allowlist.
-   **`software`** , AAuth verified, but no attestation envelope (or attestation failed and operator allowlist did not match), regardless of signing algorithm.
-   **`unverified_client`** , No AAuth, but `clientInfo.name` (or `X-Client-Name`) survived normalisation.
-   **`anonymous`** , Nothing distinctive: generic or absent `clientInfo`, no AAuth, no fallback header.

◆

## Verification cascade

Request
 ├── AAuth Signature header?
 │     no  → non-generic clientInfo or X-Client-Name?
 │            yes → unverified\_client
 │            no  → anonymous
 │     yes → signature verifies?
 │            no  → fall through to clientInfo channel
 │            yes → cnf.attestation present?
 │                   yes → verifier accepts?
 │                          yes → key not revoked?
 │                                 yes → hardware
 │                                 no  → software
 │                          no  → iss / iss:sub in operator allowlist?
 │                                 yes → operator\_attested
 │                                 no  → software
 │                   no  → iss / iss:sub in operator allowlist?
 │                          yes → operator\_attested
 │                          no  → software

◆

## Per-request precedence

For each request Neotoma walks these inputs in order; the first populated field at each layer wins:

AAuth (verified signature + JWT) → agent\_thumbprint, agent\_sub, agent\_iss,
                                   agent\_algorithm, agent\_public\_key
        │
        ▼
clientInfo.name + version        → client\_name, client\_version
        │
        ▼
X-Client-Name + X-Client-Version → client\_name, client\_version
        │
        ▼
OAuth connection id              → connection\_id
        │
        ▼
(nothing)                        → anonymous

Bearer tokens resolve only `user_id`; they do not mint an attribution tier above `anonymous` on their own.

◆

## Operator policy

The server publishes the active policy on `GET /session` under `policy`:

-   **`NEOTOMA_ATTRIBUTION_POLICY`** (`allow` | `warn` | `reject`; default `allow`), global behaviour for `anonymous` writes.
-   **`NEOTOMA_MIN_ATTRIBUTION_TIER`** (`hardware` | `software` | `unverified_client`), minimum tier required.
-   **`NEOTOMA_ATTRIBUTION_POLICY_JSON`** , per-path overrides, e.g. `{"observations":"reject"}`.

Behaviour: `reject` returns `HTTP 403 ATTRIBUTION_REQUIRED` with `min_tier` and `current_tier` in the error envelope. `warn` stamps an `X-Neotoma-Attribution-Warning` response header and emits an `attribution_decision` log line, but completes the write. `allow` is silent. `min_tier` and `per_path` compose: a per-path `reject` always wins over the global `allow`.

Per-agent fine-grained capability scoping (`(op, entity_type)` allow-lists) is layered on top, see [agent capabilities](/aauth/capabilities).

◆

## Diagnostic surface

Every AAuth verification emits a structured `attribution_decision` log event with at least:

-   `signature_present`, was a `Signature` header on the request?
-   `signature_verified`, did the cryptographic check pass?
-   `signature_error_code`, when applicable: why verification failed (`authority_mismatch`, `digest_mismatch`, `signature_invalid`, `agent_token_expired`, `unsupported_algorithm`, …).
-   `attestation_outcome`, when present: result of the per-format verifier (`verified`, `format_unsupported`, `key_binding_failed`, `challenge_mismatch`, `chain_invalid`, …).
-   `revocation_outcome` (v0.12.0+), `not_checked`, `live`, `revoked`, or `error_skipped`.
-   `resolved_tier`, final tier as stamped onto the request context.

The same fields are exposed on `GET /session` under `attribution.decision` so client preflight tooling can surface them without scraping logs.

◆

## Transport parity

The same identity contract is threaded through every transport:

-   HTTP `/mcp` (MCP-over-HTTP).
-   Direct REST routes (`/store`, `/observations/create`, `/create_relationship`, `/correct`, `/session`, …).
-   MCP stdio.
-   CLI-over-MCP and CLI-over-HTTP.

Per-transport notes live on the [integration](/aauth/integration) page.

Back to [AAuth overview](/aauth).