Skip to content

LLM Gateway Verification Specification

Status: Active implementation contract Last updated: 2026-07-10 Applies to: llm-gateway/service (github.com/eqtylab/gateway/llm-gateway/service)

This spec defines Guardian LLM Gateway behavior for:

  • RFC 9421 request signature validation
  • sidecar-first agent auto-registration integration point
  • provider-routed forwarding for verified Anthropic/OpenAI/ChatGPT backend traffic
  • signed runtime project-membership presentation and project-context resolution
  • append-only request/response audit persistence

Out of scope:

  • control plane service implementation
  • sidecar implementation
  • OPA authorization policy runtime

Sidecar registration integration details:

  • llm-gateway/docs/sidecar-agent-registration.md

For normal proxied requests, gateway executes:

  1. Assign/read correlation ID, classify the route, and run environment request.start hooks.
  2. Verify the RFC 9421 signature, viper.v1 profile, content digest, freshness/replay, attestation, and registered did:key identity.
  3. Bind any signed project presentation to a non-authoritative project candidate.
  4. Resolve and pin exactly that candidate project’s active snapshot, with environment fallback disabled.
  5. Run only its typed project.bootstrap membership entry.
  6. Establish authoritative project context only from a validated typed success; projectless requests instead pin the environment-default snapshot and skip bootstrap.
  7. Run pinned proxy.pre, forward to the provider or relay destination, then run pinned proxy.post.
  8. Persist request/response audit attribution and enqueue pinned request.end work.

Gateway provider routing is prefix-based:

  • /v1/anthropic_api/{*path} forwards to anthropic-upstream-url/{*path}
  • /v1/openai_api/{*path} forwards to openai-upstream-url/{*path}
  • /v1/chatgpt_backend/{*path} forwards to chatgpt-backend-upstream-url/{*path}

Rules:

  • provider prefix alone (no trailing path) is unsupported
  • query string is preserved
  • request method is preserved

Required request headers (proxy path):

  • Signature-Input
  • Signature
  • X-Viper-State-CID — CID of the state attestation blob (sidecar uploads it to PUT /v1/blobs/{cid} ahead of the signed request)
  • X-Viper-Nonce — per-request UUIDv4
  • X-Viper-Timestamp
  • Content-Digest when request has a body
  • X-Viper-Identity-CID — only when identity attestation is produced
  • X-Viper-Credential-CID — one signed canonical list containing the registered agent VC CID and any additional presented credential CIDs

The credential renewal path (POST /v1/agents/credentials/renew) requires only X-Viper-Nonce alongside the signature headers; the blob upload path (PUT /v1/blobs/{cid}) requires no X-Viper-* attestation headers.

Content-Digest requirements:

  • must include a blake3 digest entry
  • gateway recomputes BLAKE3 over the request body and rejects mismatches

Required signature profile (viper.v1) covered components (proxy path):

  1. @method
  2. @target-uri
  3. x-viper-state-cid
  4. x-viper-nonce
  5. x-viper-timestamp
  6. content-digest — required when Content-Digest header is present (and for requests with a body)
  7. x-viper-identity-cid — required when the header is present
  8. x-viper-credential-cid — required when the header is present

For the credential renewal path the covered components reduce to @method, @target-uri, x-viper-nonce. For blob upload, they reduce to @method, @target-uri, content-digest.

Additional header components are allowed (except authorization).

3.1 Runtime Project-Membership Presentation

Section titled “3.1 Runtime Project-Membership Presentation”

The project-presentation.v1 headers are:

  • X-Viper-Credential-CID: one canonical, lexicographically sorted and deduplicated comma-separated list of 1–8 JCS credential CIDs. Despite the singular header name, the value is a bounded list. Viper automatically includes its registered agent VC CID; configured project-membership credentials are merged into the same list.
  • X-Viper-Project-URN: an optional canonical urn:uuid:<project_uuid> selector used to disambiguate multi-project membership.

When either header is present, it must be covered by the verified RFC 9421 signature. The signing profile rejects a present-but-uncovered credential list because that list also carries the agent identity credential; the project binder rejects an uncovered project selector as a non-configurable core failure in either enabled rollout mode.

X-Viper-Verifiable-Credential-CID is deprecated. The Gateway accepts a signed legacy header when the unified header is absent, but rejects requests that send both. Viper emits only the unified header.

Forms A–D remain unchanged. Gateway core verifies canonical header grammar/coverage, CID/body binding, signer/credential-subject binding, project-reference existence, and deterministic non-ambiguous selection. The result is a non-authoritative ProjectCandidate: it may select policy but grants no project access.

For a candidate, Gateway resolves only the active (deployment environment, project URN) snapshot and pins it. It does not fall back to the environment snapshot. The project snapshot must contain one enabled premade builtin.project_membership entry at project.bootstrap. That plugin uses snapshot configuration for trusted issuers, required membership, accepted statuses, expiration/revocation, roles, and observe/enforce behavior. Gateway accepts only its typed result—never generic plugin context_writes—before constructing authoritative projectctx.Context. Ordinary project hooks run only after typed success, against the same pin through proxy.pre, proxy.post, audit, and request.end.

When no candidate exists, the request is explicitly projectless: it pins the environment-default snapshot and skips project.bootstrap. No external membership lookup occurs on the request path; stored evidence/status is refreshed out of band.

Gateway rollout configuration:

  • project_context.mode / LLM_GATEWAY_PROJECT_CONTEXT_MODE: disabled (default), observe, or enforce. observe and enforce both enable the core binder and both fail closed on core protocol/binding errors; project membership observe/enforce behavior is configured on builtin.project_membership.
  • project_context.require_membership and project_context.trusted_issuer_dids remain accepted only as deprecated rollout configuration. They no longer decide project-specific policy; use membership_required and trusted_issuers on the bootstrap plugin.
  • Enabling project context requires the plugin runtime.

Required signature params:

  • created
  • keyid
  • alg=ed25519 or alg=ecdsa-p256-sha256

keyid format:

did:key:z... (Ed25519 or P-256 multikey).

The state attestation blob referenced by X-Viper-State-CID is canonical JSON containing the agent/host snapshot (host info, agent app fields, captured env vars, derived request metadata) and is content-addressed with a BLAKE3 multihash CID. After signature validation the gateway loads the blob from its store, parses version from the blob JSON, and validates nonce/timestamp freshness from the signed X-Viper-Nonce and X-Viper-Timestamp headers against the configured clock-skew window.

Gateway uses postgres as the trusted registry source of truth for sidecar registration.

Request verification depends on trusted registry lookups after signature validation:

  • key lookup by DID (did:key)
  • key status check (active)
  • agent status check (active vs revoked)

Revoked agents are denied locally with:

  • status 403
  • code agent_revoked
  • message Agent access has been revoked

Gateway exposes:

POST /v1/agents/registrations

Behavior:

  • available when -agent-registration-enabled=true
  • validates provisioning_token using configured registration token secret
  • upserts agent/users bindings in the trusted registry
  • key lifecycle is idempotent by did:key
  • same (agent_id, did) -> noop
  • new did for existing agent_id -> new active key record inserted (updated)
  • activation policy is auto-active for newly registered agents/keys
  • revoked agents cannot be reactivated by registration; they must be restored via control-plane kill-switch endpoint
  • emits append-only registry_registration_events

Request fields:

  • agent_id
  • did (did:key:z..., Ed25519 or P-256 multikey)
  • did_source (required — "Software (ed25519)", "Software (p256)", or "YubiKey")
  • display_name (optional)
  • provisioning_token

The initial user-to-agent binding is derived gateway-side from the provisioning token subject; the request body carries no users field.

Success response fields:

  • agent_id
  • agent_status
  • key_status
  • registration_result (created|updated|noop)
  • registered_at
  • vc — W3C Verifiable Credential issued for the registered DID (Ed25519Signature2020); always present
  • correlation_id

Current registration verifier uses HMAC-signed token format:

ggr1.<payload_b64url>.<sig_b64url>

Payload claims:

  • jti
  • agent_id
  • sub
  • iat
  • exp

Signature: HMAC-SHA256(secret, "ggr1.<payload_b64url>")

Secret comes from standardized runtime config (registry-registration-token-secret, LLM_GATEWAY_REGISTRATION_TOKEN_SECRET, or TOML registry.registration_token_secret).

Replay key:

(keyid, nonce, created)

Behavior:

  • atomic reserve on first-seen nonce tuple
  • duplicate tuple within TTL is denied (sig_replay)
  • TTL extends to created + default_sig_max_age + clock_skew

Current backend: in-memory replay store.

On successful verification and authorization, gateway forwards the request upstream and injects:

  • X-Guardian-Verify-Result: verified
  • X-Guardian-Agent-Id
  • X-Guardian-Key-Id
  • X-Guardian-Profile
  • X-Guardian-Correlation-Id
  • X-Forwarded-Host
  • X-Forwarded-Proto
  • X-Forwarded-For (append behavior)

Before forwarding, Gateway strips RFC 9421 signature headers and all internal X-Viper-* headers, including project credential CIDs and project selectors. Audit header snapshots retain header names for diagnostics but redact X-Viper-* values; resolved project identifiers and statuses are stored only in structured audit fields.

Host behavior in phase-1:

  • preserve original inbound Host header when forwarding upstream

On verification or authorization failure, gateway returns local deny response and does not forward upstream.

Response headers:

  • content-type: application/json
  • x-guardian-correlation-id: <correlation_id>

Response body:

{
"code": "sig_invalid",
"message": "signature verification failed",
"correlation_id": "...."
}

Verifier reason codes (implemented):

  • sig_missing_headers
  • sig_invalid
  • sig_expired
  • sig_replay
  • key_not_registered
  • agent_revoked
  • attestation_missing
  • attestation_invalid
  • nonce_missing
  • profile_invalid
  • verify_internal_error
  • request_read_error

Gateway-specific policy/upstream codes:

  • policy_denied
  • policy_error
  • upstream_unavailable

Registration error codes:

  • registration_invalid_request
  • registration_invalid_token
  • registration_conflict
  • registration_internal_error

Runtime project reason codes are defined normatively in rfc-project-membership-presentation-contract.md. Core codes (unsigned/malformed presentation, signer/subject or project mismatch, ambiguity, unknown project, CID mismatch, evidence conflict, resolver/snapshot/bootstrap infrastructure errors) fail closed whenever the feature is enabled. Configurable membership-policy codes (missing/unresolvable/invalid evidence, issuer, validity, revocation, and roles) follow the selected snapshot’s builtin.project_membership.mode; observe records deny_ignored without establishing context or running ordinary project plugins, while enforce returns a local 403.

Status mapping:

  • 401: malformed/invalid signature, freshness, replay, missing required inputs
  • 403: agent_revoked or policy deny
  • 500: policy evaluation internal error or request/audit internal processing errors
  • 502: upstream forwarding failure

Gateway binary flags:

  • -listen (default :10000)
  • -registry-backend (default postgres)
  • -registry-db-dsn (required when registry-backend=postgres; defaults to audit-db-dsn when set)
  • -agent-registration-enabled (default true)
  • -registry-registration-token-secret (required when agent-registration-enabled=true)
  • -registry-registration-token-issuer (default control-plane)
  • -signature-label (default sig)
  • -default-sig-max-age (default 60s)
  • -default-clock-skew (default 120s)
  • -anthropic-upstream-url (default https://api.anthropic.com)
  • -openai-upstream-url (default https://api.openai.com)
  • -chatgpt-backend-upstream-url (default https://chatgpt.com/backend-api)
  • -audit-enabled (default true)
  • -audit-db-dsn (required when audit enabled)
  • -audit-queue-dir (default data/audit-queue)
  • -audit-queue-max-bytes (default 10GiB)
  • -audit-batch-size (default 100)
  • -audit-flush-interval (default 1s)
  • -audit-retention-days (default 90)

Registry trust settings are currently global at verification time (configured via gateway flags).

Unit tests:

  • verifier logic: internal/verify/verifier_test.go
  • gateway HTTP behavior: internal/gatewayhttp/server_test.go
  • audit queue and encoding: internal/audit/queue_test.go, internal/audit/encoding_test.go
  • registration and trusted registry behavior: internal/registry/*_test.go

Developer signer CLI:

  • cmd/guardian-sigtool

Phase-1 audit writes are asynchronous and fail-open for gateway traffic:

  • request path always attempts to enqueue audit exchange record
  • queue write failures do not block traffic and are logged as critical drops
  • sink write failures retry on subsequent flush cycles
  • audit persistence is append-only (no upsert/overwrite by correlation_id)

Each exchange row captures:

  • request metadata + headers + full body bytes
  • response metadata + headers + full body bytes
  • route outcome (proxied, local_deny, upstream_error)
  • decision code and correlation ID
  • verified identity fields when available (agent_id, key_id, profile)
  • safely attributable project candidate URN separately from authoritative established project_urn
  • membership establishment/status/credential/issuer/reason and bootstrap outcome
  • pinned snapshot ID/version/scope/fallback metadata (candidate project traffic always records scope=project, fallback=false)

Body storage:

  • full request/response bytes are stored
  • request/response bodies are stored as captured; gateway-internal X-Viper-* header values are redacted before persistence, and raw membership credential JSON is never copied into structured project audit fields

Retention:

  • gateway periodically deletes rows older than -audit-retention-days using received_at (exchange time), not DB insertion time

Migration standard:

  • SQL files live in llm-gateway/service/migrations
  • file naming: NNNN_description.up.sql
  • applied versions tracked in schema_migrations
  • first migration for this feature: 0001_audit_exchanges.up.sql

14. Registry Schema and Control-Plane Read Model

Section titled “14. Registry Schema and Control-Plane Read Model”

Migration 0002_trusted_agent_registry.up.sql creates the initial registry schema, and 0004_single_tenant_schema.up.sql migrates it to the tenantless model used by current runtime:

  • registry_agents
  • registry_agent_keys
  • registry_users
  • registry_agent_user_bindings
  • registry_registration_events

Control-plane read views:

  • v_registry_agents
  • v_registry_agent_keys
  • v_registry_agent_users
  • v_registry_agent_audit_link

Control plane can query PostgreSQL directly for discovery and audit linkage.

Verifier and authorization flow remain interface-based so backends can be swapped without changing the request contract.