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 DA principal.key_hash bounds the delegation to the principal signing key. bearer.SignDA automatically sets it to the principal key's SPKI hash.
  • JWK thumbprint (JWKThumbprint, RFC 7638) — the cnf.jkt confirmation 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 — injects Authorization: Bearer <AIC-JWT>, provider-style for auto-refresh; the Agent wraps it with a single 401-triggered refresh (remote issuance only).
  • transport.NewMTLSTransport — clone of the default transport with the mTLS tls.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 NotAfter approaches RefreshBefore (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/Connect calls collapse to a single refresh (single-flight).
  • examples/mcp-client drives 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: - Signaicjwt.Validate must permit and recover capabilities. - SignDAaicjwt.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, FetchDASignerRequest/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.