aic-agent — Architecture (EN)EN
aic-agent architecture
aic-agent is the consumer-side SDK for the AIC authorization model. It
mirrors the server side (aic-verifier): where the service validates and admits,
the client holds, mints and presents the credential.
Repo map
| File | Role |
|---|---|
aicagent.go |
Agent client aggregating identity + transports, refresh-on-401, evidence retry |
aicrequest.go |
user-signer submission → approval → core AIC issuance |
daassertion.go / dasigner.go |
DA assertions and the DASigner abstraction (LocalDASigner, HTTPDASigner) |
clc.go |
CLC-v1 client-side capability/operation decision glue |
mcpclient/ |
mTLS MCP client: obtains a fresh AIC certificate (user-signer → core) and presents it on every JSON-RPC request to an AIC-gated MCP server |
llm.go |
OpenAI-compatible chat client used by Agent.Chat |
config.go |
Config, Validate, JSON load (LoadClientConfigFile / ParseClientConfig) |
helpers.go |
capability/operation helpers exposed on top of types |
identity/ |
agent key material and fingerprints |
bearer/ |
local minting (Sign) and DA exchange (SignDA, IssuerClient) |
transport/ |
Bearer and mTLS http.RoundTrippers |
smoke/ |
CLC corpus conformance + issuance-flow smoke (build tag smoke) |
examples/ |
call-bearer, call-mtls, smoke-call, mcp-client (pair with the aic-verifier service examples) |
Roles and flow
principal (owner) ──(da.go: SignDA)──▶ DA JWT (principal-signed)
│
agent ──(identity)──▶ key ──(bearer or issuer)──▶ Bearer AIC-JWT / mTLS cert
│
aic-verifier host ◀──(transport)── HTTP request + credential
Credential carriers
Bearer AIC-JWT (draft-wei-aic-jwt)
- Header:
alg(ES256/EdDSA),typ="aic+jwt",kid= SPKI hash of the signing key. - Payload:
sub/aud/iat/exp/jti,cnf.jkt= RFC 7638 thumbprint of the presenter key,aic.principal,aic.delegation_mode,aic.capabilities.
Locally minted tokens (bearer.Sign) are trusted by a service whose
JWTCAFile includes the agent key's certificate/SPKI. Alternatively the agent
presents a DA assertion to an issuer's token endpoint (bearer.IssuerClient)
and receives a short-lived outer token (§ RFC 7523).
mTLS client certificate
The client certificate carries the AIC X.509 extension (1.3.6.1.4.1.66257...).
The SDK loads the cert/key pair and builds a tls.Config for the connect; the
service's CACertFile verifies the chain and extracts the AIC extension. Server
CA pinning is optional; without it system roots are used.
Key hashing semantics (important)
AIC distinguishes two hashes of the same key (draft § principalUid):
- SPKI hash (
KeyHashOf(pub, "sha-256")) — the DAprincipal.key_hashbounds the delegation to the principal signing key.bearer.SignDAautomatically sets it to the principal key's SPKI hash. - JWK thumbprint (
JWKThumbprint, RFC 7638) — thecnf.jktconfirmation bound to the agent's presenter key in the outer token.
identity.AgentKey exposes both (SPKI and Thumb); Principal() and Cnf()
choose the right one for the outer token, while SignDA overrides the DA
principal key hash with the SPKI form.
Transports
transport.BearerTransport— injectsAuthorization: Bearer <AIC-JWT>, provider-style for auto-refresh; theAgentwraps it with a single 401-triggered refresh (remote issuance only).transport.NewMTLSTransport— clone of the default transport with the mTLStls.Config.transport.TransportWithCA— trusts a service CA PEM for either mode.
AIC issuance and client-side CLC
aicrequest.go implements the human-in-the-loop issuance client
(SubmitAICRequest / WaitForAIC / IssueAICCertificate / ObtainAIC). It
carries the effective constraint set the DA was signed with into core's
authorization_constraints, so the minted AIC extension matches the DA; see
the README for the flow.
clc.go exposes the CLC-v1 decision core to the client: Authorize (against
Config.Capabilities) and AuthorizeGrants (against an explicit grant set),
so an agent can pre-check an operation with the same language revision the
service side uses. allow_unresolved is a distinct verdict and must not be
read as allow.
Non-2xx responses from the doJSON transport layer are parsed by
refusalErrorFromResponse into a *RefusalError: the plain
{"code","message"} shape and the RFC 9457 application/problem+json shape
that aic-verifier returns for a remediable denial (with a CLC-CHALLENGE-v1
challenge member and an optional Retry-After header). The error keeps the
historical status <code>: <body> substring so the DA signer's
statusFromError keeps working, exposes the parsed Challenge for
errors.As consumers, and sets ChallengeUnavailable when a challenge was
announced but unusable — the denial information always survives, and a bad
challenge is never silently downgraded to "no challenge".
Evidence-required denials can be closed out through the EvidenceProvider
hook on Config: doJSONEvidence (the retry-aware wrapper around the shared
JSON client used by coreDo) marshals the request body once, attempts the call,
and if the result is a *RefusalError whose Challenge is present, parseable
and usable at the instant of the call, waits out Retry-After or
retry_timing.not_before (never past expires_at), asks the provider whether
the deployment has made the required facts available to the verifier's
EvidenceFacts hook, and retries the byte-identical request once. A
second denial, a stale challenge, an unavailable provider or a declined signal
all reach the caller unchanged. The SDK never transmits evidence on the wire:
the protocol has no evidence channel — facts live at the deployment's verifier,
and the provider is the glue between them and the client.
MCP client (mcpclient/)
mcpclient.New runs the full issuance pipeline and hands the result to a
mark3labs/mcp-go Streamable HTTP client:
submit AIC request ─▶ user-signer ─▶ human approves (console)
◀── DA evidence {da, user_cert_pem}
build CSR + evidence ─▶ core ─▶ AIC cert (bound to the agent's own key)
│
mTLS client certificate ─▶ AIC-gated MCP server (/mcp)
- The certificate is short-lived and memory-only: it is re-obtained once
NotAfterapproachesRefreshBefore(default 5m), and there is no on-disk cache, so a process that stops cannot mint credentials later. - The AIC is presented via
tls.Config.GetClientCertificate, resolved at each handshake, so a session outliving one certificate transparently picks up the next on re-connect. - Connect serializes on the issuance mutex, so concurrent
Certificate/MCP/Connectcalls collapse to a single refresh (single-flight). examples/mcp-clientdrives the chain from the CLI against a user-signer, core, and an AIC-gated MCP server.
Testing
Unit tests in bearer prove round-trips without a real service:
- Sign → aicjwt.Validate must permit and recover capabilities.
- SignDA → aicjwt.ValidateDA must accept ver=2, authorized-mode semantics
and the SPKI-bound principal.
- IssuerClient.Exchange → a local httptest token endpoint validates the DA
and issues an outer token over RFC 7523 form-encoded exchange.
smoke/ (build tag smoke, go test -tags smoke ./smoke/) drives the
normative CLC-v1 corpus through the SDK glue and exercises the issuance flow
against stub user-signer/core servers.
End-to-end runs pair with aic-verifier/examples/bearer-jwt-backend and
.../mtls-backend; both examples' identity headers (X-AIC-*) are asserted
by the commands documented in each example header.
Security hardening (post-v0.1)
| Ref | Fix | File |
|---|---|---|
| C1 | refreshRoundTripper resets the request body via GetBody before retry and skips retry for non-idempotent methods with a consumed body. |
aicagent.go |
| C2 | IssuerClient.Exchange rejects non-HTTPS token endpoints by default; loopback (127.0.0.1, ::1, localhost) and AllowPlaintextHTTP=true are exceptions. |
bearer/issuer.go |
| C3 | BearerTransport.RoundTrip reads fixed under RLock to prevent data races with concurrent SetToken. |
transport/transport.go |
| C4 | NewInsecureTLS removed (unused; prefer MTLSConfig.ServerCAFile). |
transport/transport.go |
Refresh-body safety
HTTP retry-after-401 is only attempted when the request is idempotent (GET/HEAD/OPTIONS/TRACE), the body is nil, or the request provides a GetBody function so the body can be re-created for the retry. Non-idempotent POST/PUT without GetBody returns the 401 directly without retry.
User signer, logging and LLM configuration
User signer (Config.UserSigner, signer.go)
The user signer holds the principal's identity key and signs the DA assertion
the agent presents to a remote issuer. Config.UserSigner selects its address
and defaults to DefaultUserSignerAddr (https://127.0.0.1:8444, loopback).
When RemoteIssuer is set and Config.DA is empty, Agent.Token() fetches a
DA from the signer (POST /sign-da, FetchDA → SignerRequest/SignerResponse)
before the RFC 7523 exchange. Config.DA may also be set directly to bypass
the signer, or a bearer token may be supplied fully formed (Config.Token).
Logging (Config.Logger / Config.LogFile)
Config.LogFile, when non-empty, appends SDK log output to the named file
(0644) instead of stdout; Agent.Close() releases it. Config.Logger accepts
any *slog.Logger for programmatic routing.
LLM API (Config.LLMConfig, llm.go)
LLMConfig{ServerURL, APIKey, Model, Timeout} carries the upstream LLM API
address and API key. LLMConfig.Chat speaks the OpenAI-compatible
/chat/completions shape; Agent.Chat is the one-line convenience.
DefaultLLMServerURL is https://api.openai.com/v1.
Root CA (Config.RootCA)
Config.RootCA (one or more PEM files, comma/space separated) is the outbound
TLS trust bundle for the endpoints the agent calls itself: the user signer,
the remote issuer token endpoint, and the LLM API. It is appended to the
system roots; when empty only system roots are used. ServerCA remains the
service-specific pin for the protected backend, so an intranet deployment can
trust an internal signing/issuing/LLM PKI without loosening service trust.
JSON configuration (LoadClientConfigFile / ParseClientConfig)
All of the above — mode, agent key, MTLS files, root CA, remote issuer, user
signer, log file and LLM settings — can be read from a single JSON file
(config.example.json). Unknown fields are rejected so typos surface as
errors instead of silently ignored options.
Credential lifecycle, end to end
1. bootstrap 2. acquire 3. present 4. maintain
───────────────── ────────────────── ──────────────────── ─────────────────────
agent key (local) local mint (Sign) Agent.Do / Get / DoAIC on 401: refresh once
| per-request token → Bearer header / mTLS (idempotent-only)
└─ no AIC yet? remote exchange → aic-verifier admits on challenge:
ObtainAIC flow (RFC 7523 via → backend answers wait → provider →
(user-signer → IssuerClient, byte-identical retry
approval → CSR cnf.jkt binds
→ core mints) the presenter key)
Agent is a one-stop client: it owns the key, chooses the mint path, attaches
the credential through the transport, and surfaces refusals
(*RefusalError). Calling Client() hands you the underlying http.Client
for code that wants the standard surface.
Decision pre-check vs the server authority
clc.go lets the agent decide an operation against its declared capabilities
before spending a request (Config.Authorize, AuthorizeGrants). It uses the
same CLC core as aic-verifier, so client and server verdicts agree by
construction — but the server's decision is authoritative. A client pre-check
that says allow is a courtesy, not a guarantee; one that says deny saves a
round trip.