UAI Trust Server: Low-Level Design (LLD)
How the Trust Server is built — policy evaluation, consent validation, token minting, the key ring, and offline verification.
Scope. This document explains how the Trust Server is built. What it is and why it exists (the notary model, the risk tiers, offline verification) is in the HLD. The UAI Technical Specifications and the five frozen JSON Schemas remain the contract; this LLD implements them and must not contradict them.
Consistency. The Trust Server shares one repository, one set of
pkg/libraries, and one architectural style with the Registry. Where a mechanism already exists in the Registry LLD (proof-of-control,did:webvhresolution, the error shape, the audit trail, the two-listener split), this document points to it instead of repeating it, and describes only what differs.
Terms used throughout. A Seeker is an agent asking to do something; a Provider is an agent that serves the request. A DID is a decentralized identifier, an agent’s cryptographic identity; did:webvh is the DID method UAI uses (Decision D15). It anchors an identity to a domain name over HTTPS and adds a signed history log, so a key can be checked as valid at the moment it signed, not just today. A delegation token is a short-lived signed permission slip (a JWT) stating exactly what one Seeker may do with one Provider. TTL is time to live, how long something stays valid. A nonce is a random one-time value used to prevent replay. DPoP is proof-of-possession that binds a token to a specific key so a stolen token is useless. A VC is a W3C Verifiable Credential. KMS/HSM is the managed key store / hardware security module where private keys live. Fail-secure means that when any check cannot be completed, the answer is no.
1. Purpose and scope
The Trust Server is the authority desk of the UAI notary model. It answers one question: “may this Seeker do this action with this Provider, right now?” When the answer is yes, it issues a delegation token that states exactly what is allowed and for how long. Three properties define it. It never sees the data being exchanged. It keeps no record of the tokens it issues. And it is never in the live request path, because each Provider verifies tokens on its own, offline, using the Trust Server’s published public keys.
This document covers the Trust Server’s module layout, what it stores, its domain model, interfaces, API, core flows, concurrency, validation, security, observability, errors, and testing. The Registry and the Audit Ledger appear only where they touch the Trust Server: the one internal call to the Registry, and the token fields a Provider later records in its receipt.
2. Responsibilities
The Trust Server does:
- Check that the caller really controls the Seeker DID it claims, using proof-of-control (§7.1), the same mechanism the Registry uses.
- Evaluate every token request against a machine-readable risk policy, which maps the requested scope to a tier, a token TTL, and the consent and credential requirements for that tier (§8.2).
- Validate the consent artifact for Tier 2 and above against the DPDP consent properties (§8.3).
- Verify a DPoP proof for Tier 3 and above, so the issued token is bound to the caller’s key (§8.4; framework only in Phase 1).
- Make the one internal call to the Registry, to confirm the Provider is active, supports the requested scope, and is allowed to operate at the requested tier (§8.5).
- Build and sign the delegation token (Ed25519 signature,
EdDSAalgorithm) with the mandatory UAI claims (§7.2). - Keep its signing keys in a rotating ring, and publish the public halves in its
did:webvhlog at/.well-known/did.jsonlso Providers can verify tokens offline (§8.6). - Rate-limit the token and challenge endpoints (§11).
The Trust Server does not:
- See, store, or forward any agent payload. The data call is directly between Seeker and Provider.
- Store issued tokens, sessions, or any record it is the system of record for (§4). It has no Postgres.
- Sit in the live request path. Providers verify tokens offline; there is no per-call callback (spec §12.3).
- Register agents or serve discovery. That is the Registry.
- Store or validate receipts. That is the Audit Ledger. The Trust Server only contributes three values the Provider records in its receipt:
token_hash,scope, andtier. - Recall or revoke tokens that are already issued and still valid (in-flight tokens) in Phase 1. Consent withdrawal is enforced on the Provider side through the consent revocation check; coordinated in-flight revocation is a Phase 2 item (§8.7, §16).
- Check live credential revocation in Phase 1. The status-list check at token time is a Phase 2 backstop (§16).
3. Module layout and boundaries
The layout follows the Registry exactly: one binary, internal packages that other services cannot import (the compiler enforces the wall), and shared libraries in pkg/. Domain types and the small interfaces (the “ports”) live in model/. Each backend (Redis, the DID resolver, the Registry client) is a separate implementation behind one of those interfaces.
cmd/trustserver/main.go wiring + config + graceful shutdown; loads and validates the risk policy, builds the key ring and one Redis pool, injects all backends
services/trustserver/
api/
openapi.yaml REST contract for the edge API; source of truth for docs and clients
internal/
model/ domain types (TokenRequest, Grant, Claims, PolicyDecision, ConsentArtifact, ProviderView, KeyRef) + sentinel errors
+ the port interfaces: PolicyEngine, ConsentVerifier, ProofVerifier, DPoPVerifier, RegistryClient, KeyRing, TokenMinter, VCVerifier, NonceStore, RateLimiter, Resolver
httpapi/ edge (public) listener: routing, middleware, handlers, request/response types
adminapi/ internal/admin listener: separate mux, mTLS + service-token auth, not gateway-exposed
token/ token issuance orchestration: the ordered pipeline of §8.1
policy/ risk-policy loader + evaluator (scope -> tier -> TTL -> requirements); load-time contradiction check
consent/ consent-artifact fetch + DPDP validation (Tier 2+)
proof/ seeker proof-of-control verification (signature over the JCS bound object); reuses pkg/jcs, pkg/ed25519x, pkg/did
dpop/ DPoP proof verifier (Tier 3+); framework only in Phase 1
vc/ VCVerifier for provider credentials at token time (Tier 3+ seam); same interface as the Registry
keyring/ Ed25519 signing key ring, rotation, DID-document projection
audit/ append-only audit trail for token issuance, consent-validation events, and admin actions
infra/ outbound infrastructure, one adapter per backend
redisx/ shared go-redis pool + NonceStore, RateLimiter, Cache implementations
registryc/ RegistryClient implementation wrapping pkg/clients (the one internal hop)
config/ typed config from environment
pkg/ shared, byte-identical across services (reused, not re-implemented)
did/ did:webvh resolver (reads and verifies the DID log, its SCID, and its key history), SSRF-hardened (satisfies model.Resolver; uses a Cache injected by main)
ed25519x/ Ed25519 sign/verify
jcs/ RFC 8785 canonicalization (for proof-body hashing and JWK thumbprints)
jwtmint/ delegation-token minting (EdDSA); the Trust Server calls this with the active key
jwtverify/ delegation-token verification; Providers call this offline (shared so signer and verifier cannot drift)
schema/ frozen JSON Schemas + loader (UAIRiskPolicy and ConsentArtifact used here)
issuers/ trusted-issuer list, governance-maintained (satisfies model.IssuerTrust; used by vc/)
clients/ the only sanctioned cross-service REST client (generated from each service's api/openapi.yaml)
Boundary rule. Same as the Registry. Nothing inside services/trustserver/internal/... may import another service’s internals; all cross-service calls go through pkg/clients. A depguard lint rule fails the build if this is violated. A second depguard rule keeps infra/ out of the feature packages, so no handler ever names Redis or the Registry client directly.
Dependency direction. model/ defines the domain types, the error values, and the port interfaces, and imports nothing else internal. Every other internal package depends on model/; nothing depends back on it. The concrete implementations live at the edge (infra/redisx/, infra/registryc/, internal/keyring/, internal/vc/, plus the shared pkg/did/ and pkg/issuers/). Only main knows about infra/: it builds the real backends and injects them.
Shared Redis. Redis is treated exactly as in the Registry’s infra/redisx: one connection pool, built once in main, with each use exposed as its own small adapter behind a port in model/. In Phase 1 Redis backs three things: single-use challenge nonces for proof-of-control, per-IP and per-DID rate limiting, and the did:webvh resolver cache. A fourth use, the DPoP replay guard for Tier 3, exists as a seam and switches on with the live Tier 3 path (§8.4, §16). Because pkg/ must stay free of service imports, pkg/did declares its own tiny cache interface; the redisx cache satisfies it and main wires the two together, exactly as in the Registry.
Reused libraries, not forks. The Trust Server does not re-implement canonicalization, signing, DID resolution, or the token code. It imports pkg/jcs, pkg/ed25519x, pkg/did, pkg/jwtmint, pkg/jwtverify, pkg/schema, and pkg/issuers. This is what guarantees that a Provider verifying with pkg/jwtverify checks exactly what the Trust Server minted with pkg/jwtmint, and that a Seeker’s proof-of-control produces exactly the bytes the Registry already expects.
Two listeners. As in the Registry, the public API (httpapi/) and the internal admin API (adminapi/) are separate packages on separate network listeners. The admin listener is never exposed through the gateway and sits behind mutual TLS plus a shared service token. The admin actions are key rotation, risk-policy reload, and resolver-cache purge (§7, §8.6).
4. State posture and infrastructure
the Trust Server is the permanent home of no data. It stores no table of issued tokens, no sessions, and no copies of consent artifacts. Any replica can serve any request, and scaling out needs no coordination, because every token is self-contained and every Provider verifies it offline. This is exactly the property that keeps the Trust Server out of the live request path.
No Postgres. Unlike the Registry and the Audit Ledger, the Trust Server owns no database. There is nothing to migrate and nothing to back up.
Redis holds only short-lived, replaceable data. It backs the single-use nonces, the rate-limit counters, and the resolver cache (§3). If Redis goes down, new challenges cannot be issued, and rate limiting and caching degrade; /readyz reports this. But nothing is lost, because nothing in Redis is a record. Running Redis in a highly available setup (§16) removes it as a single point of failure for issuing tokens.
Loaded at startup, held in memory. Two things live in process memory for the life of the process, and neither is a datastore: the risk policy (loaded from disk or a config source, checked for contradictions at load time, §8.2) and the signing key ring (loaded from the KMS/HSM, §8.6). Reloading the policy and rotating a key are explicit admin actions, never background changes.
5. Domain model
The Trust Server has no stored record. Its domain types describe three things: the shape of a request, the shape of a decision, and the shape of the token it mints.
| Type | Origin | Notes |
|---|---|---|
TokenRequest | client | {seeker_did, provider_did, scope, consent_id?}. consent_id is required at Tier 2 and above. There is no txn field: the transaction id is minted by the Trust Server, not sent by the caller (§7.2). This is the request body; the proof (and any DPoP proof) travels in headers (§7.1). |
Grant | server | The minted result returned to the Seeker: the compact JWT, its token_hash, scope, tier, exp, cnf.jkt when present, and the Trust-Server-minted txn (transaction id) that the Seeker then binds across its request envelope and the receipt. |
Claims | server | The JWT claim set of §7.2. |
PolicyDecision | server | What the policy engine returns: {tier, ttl, consent_required, allowed_consent_methods, required_vc_types, insurance_vc_required, dpop_required, runtime_controls}. |
ConsentArtifact | external | The frozen ConsentArtifact schema (spec §4.4). Fetched and validated, never stored. |
ProviderView | external | The part of the Registry record the internal call returns: {status, supported_scopes, risk_tier_max}. |
KeyRef | server | One entry in the key ring: {kid, private handle, public JWK, not_after}. Private key bytes stay in the KMS/HSM; only the handle and the public key are in memory. |
6. Core interfaces
These interfaces are the seams of the service. Handlers depend on them, never on Redis, the resolver, or the Registry client directly, which keeps every handler testable with fakes. The Resolver, NonceStore, RateLimiter, and VCVerifier interfaces are the same as the Registry’s, so their implementations are shared.
// policy: scope -> tier, TTL, and requirements, from the machine-readable risk policy
type PolicyEngine interface {
Evaluate(scope string) (PolicyDecision, error) // ErrScopeUnknown
}
// consent: fetch and validate a consent artifact for Tier 2+ (spec §4.4)
type ConsentVerifier interface {
Verify(ctx context.Context, consentID, seekerDID, providerDID, scope string) error // ErrConsentInvalid
}
// proof: seeker proof-of-control over the JCS bound object (§7.1), reused from the Registry
type ProofVerifier interface {
Verify(ctx context.Context, in ProofInput) error // ErrProofInvalid
}
// dpop: DPoP proof on the token request for Tier 3+ (§8.4); returns the JWK thumbprint to bind as cnf.jkt
type DPoPVerifier interface {
Verify(ctx context.Context, proof string, req DPoPRequest) (jkt string, err error) // ErrDPoPInvalid
}
// registry: the one internal hop; confirms the provider is usable for this request
type RegistryClient interface {
GetProvider(ctx context.Context, did string) (ProviderView, error) // ErrProviderUnavailable
}
// keyring: the active signing key, plus the public set for /.well-known/did.jsonl
type KeyRing interface {
Active() KeyRef
Public() []PublicKey
Rotate(ctx context.Context) error
}
// minter: build and sign the delegation token (wraps pkg/jwtmint)
type TokenMinter interface {
Mint(claims Claims, signWith KeyRef) (Grant, error)
}
// vc: provider credential checks at token time (Tier 3+ seam; same interface as the Registry)
type VCVerifier interface {
Verify(ctx context.Context, vcRefs []string, subjectDID string, tier int) error // ErrVCInvalid
}
// nonce, resolver: identical to the Registry's ports (RateLimiter likewise, omitted for brevity)
type NonceStore interface {
Issue(ctx context.Context, did string) (nonce string, err error)
Consume(ctx context.Context, nonce string) (did string, err error) // ErrNonceInvalid
}
type Resolver interface {
Resolve(ctx context.Context, did string) (*DIDDocument, error) // ErrUnresolvable
PurgeCache(did string)
}
7. API surface
Edge listener (public, behind the gateway):
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /trust/v1/challenge?did={seeker_did} | none | Issue a one-time nonce (5-minute TTL) for proof-of-control. Safe against probing and rate-limited, as in the Registry. |
| POST | /trust/v1/token | proof-of-control (+ DPoP at Tier 3+) | Run all checks and, on success, issue a delegation token. |
| GET | /.well-known/did.jsonl | none | The Trust Server’s did:webvh log: the signed, hash-chained history of its public signing keys, current and recent, for offline verification. Cacheable, version-stamped. |
| GET | /healthz, /readyz | none | Liveness and readiness. |
| GET | /metrics | internal only | Prometheus. |
Internal listener (separate port, not exposed by the gateway, mutual TLS plus a shared service token):
| Method | Path | Purpose |
|---|---|---|
| POST | /admin/v1/keys/rotate | Advance the key ring by one (§8.6). |
| POST | /admin/v1/policy/reload | Reload and re-validate the risk policy; the swap is atomic on success, and a no-op if the new policy has a contradiction (§8.2). |
| POST | /admin/v1/clear-did-cache | Purge the resolver cache. |
7.1 Seeker authentication (proof-of-control)
Before issuing anything, the Trust Server must know the caller really controls the Seeker identity it claims. The check is the same proof-of-control mechanism the Registry already uses (Registry LLD §8.1), so a client that can register can also request tokens with no new code. The flow: the Seeker fetches a one-time nonce from /trust/v1/challenge, signs a small object with its private key, and sends the request body untouched with the proof in two headers:
X-UAI-Nonce: <nonce from /trust/v1/challenge>X-UAI-Signature: <base64url Ed25519 signature, no padding>
What gets signed is the canonical form (RFC 8785, JCS) of this small bound object:
bound = {
"did": "<seeker DID>", // sub of the token, subject of the proof
"htm": "POST", // the HTTP method
"htu": "<scheme>://<host>/trust/v1/token", // the externally visible URL, query dropped
"kid": "<verification-method fragment>", // which of the seeker's keys signed
"nonce": "<nonce from the challenge>",
"body_sha256": "<base64url(sha256(JCS(TokenRequest))), no padding>"
}
signature = Ed25519_sign( JCS(bound) )
Each field closes a specific attack. htm and htu tie the proof to this exact method and URL, so a captured proof cannot be redirected elsewhere. body_sha256 ties it to this exact request body, so the body cannot be swapped. The nonce is single-use, so the proof cannot be replayed. kid names the exact key used, which keeps key rotation clean. The handler additionally requires that the did field, the DID bound to the nonce, and the token’s sub all match, so a proof minted by one Seeker can never mint a token for another. The byte-level rules (uppercase htm, query-stripped htu built from the externally visible URL, the DID written raw and unencoded, all fields as strings) are exactly those of Registry §8.1, and the same reference test vector is asserted as a cross-language unit test so client and server can never drift apart.
How this differs from DPoP. Proof-of-control answers who is asking (does the caller control the Seeker DID) and is required at every tier. DPoP (§8.4, Tier 3 and above) answers a different question: it binds the issued token to a key the caller holds, so that even a stolen token cannot be used downstream. They are separate proofs, in separate headers, doing separate jobs.
7.2 Delegation token claims (UAI profile)
The minted JWT carries the mandatory claims from spec §4.1. The Trust Server sets every one of them; the client sets none.
| Claim | Value |
|---|---|
iss | The Trust Server’s own DID (the subject of its did:webvh log at /.well-known/did.jsonl). |
sub | The Seeker’s DID (the authenticated caller). |
aud | The Provider’s DID. The token works only at this Provider. |
iat, nbf, exp | Issued-at, not-before, expiry. The gap exp - iat is the tier’s TTL from the policy decision. |
jti | A unique token id (32 random bytes), so downstream systems can detect replays. |
scope | The requested scope, after the policy and Provider checks pass. |
txn | The transaction id, minted here by the Trust Server (not supplied by the caller), tying the token to one UAI interaction. It is also returned in the response so the Seeker can bind it across its envelope and the receipt. |
cnf (Tier 3+) | { "jkt": "<thumbprint of the DPoP proof key>" }. Absent below Tier 3. |
The JWT header carries alg: EdDSA and a kid of the form <trust_server_did>#<key>, which points at one verification method in its did:webvh log at /.well-known/did.jsonl. The signing algorithm is carried by that verification method, not hard-coded into the token format, so moving to a new algorithm later (including a post-quantum one) means publishing a new key, not changing the token shape (§16). The token_hash stored in Grant and in the audit trail is base64url(sha256(compact JWT)). This is the same digest the Provider later puts in its receipt as authorization.token_hash, and the same one DPoP uses as ath, so all three always agree.
8. Key flows
8.1 Token issuance
The checks run in a fixed order: cheap local checks first, the nonce burn next, and the expensive network calls (the Registry call, the consent fetch, the VC fetch) last.
- Rate-limit the caller (§11).
- Parse the body; reject malformed JSON (400).
- Validate the
TokenRequestshape (422 on failure). - Consume the nonce from Redis in one atomic step (401 if missing, expired, or already used). A failed attempt burns the nonce.
- Check that the DID bound to the nonce equals the body’s
seeker_did(401). - Resolve the Seeker’s
did:webvhto its DID Document. This reads the published DID log and checks its signed history before trusting the current key. The fetch is SSRF-hardened and cached (422 if unresolvable). - Verify
X-UAI-Signatureover the bound object of §7.1, using the key named bykid(401). - Evaluate the risk policy for
scope, producing the tier, the TTL, and the requirements (422 if the scope is unknown). - Make the one Registry call (§8.5) and fetch the Provider’s record. Deny if the Provider is not
active, does not support the scope, or is capped below the requested tier (403). If the Registry is unreachable, fail closed with 503. - At Tier 2 and above, validate consent (§8.3): resolve
consent_id; check it is not expired and not revoked, the scope is withinallowed_scopes, the DIDs match, and all DPDP properties are true (422 on any failure). - At Tier 3 and above, verify the DPoP proof (§8.4), keep its key thumbprint for
cnf.jkt, and re-verify the Provider’s credentials (§10). Framework only in Phase 1. - Mint the JWT with the ring’s active key (§8.6), the TTL from step 8, and
cnf.jktfrom step 11 when present. Write an audit record (§12). Return the token.
sequenceDiagram
autonumber
participant S as Seeker
participant T as Trust Server
participant DR as did:webvh Resolver
participant RG as Registry
participant CS as Consent Source
S->>T: GET /trust/v1/challenge?did=...
T-->>S: nonce (5-min TTL, stored in Redis)
S->>S: sign JCS bound object (did, htm, htu, kid, nonce, body_sha256)
S->>T: POST /trust/v1/token (TokenRequest + X-UAI-Nonce + X-UAI-Signature [+ DPoP at T3+])
T->>T: validate shape and consume nonce (atomic, burns on use)
T->>DR: resolve seeker DID document (SSRF-guarded, cached)
DR-->>T: verification key
T->>T: verify proof-of-control signature
T->>T: evaluate risk policy (scope to tier, TTL, requirements)
T->>RG: GET /registry/v1/agents/{provider_did} (the one internal hop)
RG-->>T: provider status, supported_scopes, risk_tier_max
T->>T: deny if suspended / scope unsupported / tier over ceiling
opt Tier 2+
T->>CS: resolve + validate consent artifact (DPDP)
CS-->>T: consent valid
end
T->>T: mint + sign JWT (iss, sub, aud, scope, tier, txn, exp, and cnf.jkt at Tier 3+)
T-->>S: 200 token + transaction_id (P95 under 500ms)8.2 Risk policy evaluation
The policy engine is a simple lookup with no side effects: given a scope, it returns the tier, the TTL, and the requirements. The policy file follows the frozen UAIRiskPolicy schema (spec §4.5). It is loaded once at startup and validated with pkg/schema.
Scope resolution. Scopes can be exact (read:certificate_status) or wildcards (read:public_data:*). When both could match, the most specific entry wins (longest prefix). An unknown scope is a hard 422. There is no default tier, on purpose: a typo must never quietly classify a sensitive action as low-risk.
TTL. The decision carries the token lifetime for the tier. Order of precedence (proposal): if the matched policy entry sets its own TTL, use that; otherwise use the per-tier default (Tier 1 five minutes, Tier 2 two minutes, Tier 3 thirty seconds, Tier 4 the session bound). A token is never valid longer than the minimum time needed to complete the action (spec §4.1).
Contradiction check at load time. Before a policy (new or reloaded) is accepted, it is checked for internal contradictions: the same scope mapped to two tiers, a wildcard that swallows a more specific entry with an incompatible tier, a tier that requires DPoP but names no consent method where the tier table demands one, or a required VC type that no trusted issuer can issue. Any contradiction fails the load. On a failed reload, the running policy stays in place and the reload returns an error; there is never a half-applied policy. This implements the HLD’s “flags contradictory policy rules at load time”.
8.3 Consent validation pipeline (Tier 2+)
Every request at Tier 2 or above must name a consent_id. The consent verifier fetches the artifact, validates it against the frozen ConsentArtifact schema (spec §4.4), and runs the five issuance checks:
- Not expired (
expires_atis in the future). - Not revoked (
revocation_statusisactive; at Tier 3 and above, this is confirmed against therevocation_check_url). - The requested scope is inside the consent’s
allowed_scopes. - The consent’s
seeker_didandprovider_didmatch the request. - All six
dpdp_propertiesare true.
Any failure fails issuance (422). The artifact fetch uses the same SSRF protections as the DID resolver (§11). The audit trail records only the outcome (consent_id, pass or fail, timestamp), never the subject_ref or any personal data (spec §4.4).
Phase 1 posture. The live path in Phase 1 is Tier 1 and Tier 2, and consent comes from a mock consent provider rather than live Aadhaar or OTP (HLD §8). The verifier and all five checks are real; only the source is mocked. Phase 2 swaps in the live source behind the same ConsentVerifier interface (§16).
8.4 DPoP on the token request (Tier 3+)
At Tier 3 and above, the Seeker sends a DPoP proof header alongside proof-of-control. The proof is a small JWT signed by a fresh, single-use key pair (the “proof key”), whose public half rides in the proof’s header. It carries htm, htu (the Trust Server’s token URL), iat, and jti (spec §4.3). The verifier checks the method and URL, that iat is within the allowed clock skew (at most thirty seconds for Tier 3, ten for Tier 4), the signature against the key in the header, and that jti has not been seen before. It then returns the key’s thumbprint, which the Trust Server places in the token’s cnf.jkt claim. The Provider later requires a fresh proof from that same key, which is what makes a stolen token useless.
The jti replay guard is a short-TTL Redis set (window equal to the token TTL plus the skew), the fourth Redis use noted in §3. Thumbprints follow the JWK rules of RFC 7638 (for Ed25519 keys: crv, kty, x), the same computation the Provider-side verifier uses, so both sides always produce the same jkt.
Phase 1 posture. Tier 3 is framework only in Phase 1 (HLD §8). The verifier, the cnf seam, and the Redis replay guard exist and are unit-tested against the reference vectors, but the live Tier 3 path switches on in Phase 2. Tier 4 is out of scope for Phase 1.
8.5 The one internal hop
To authorize a request, the Trust Server needs one fact it does not hold: is this Provider currently allowed to serve this scope at this tier? That fact lives in the Registry. The Trust Server calls GET /registry/v1/agents/{provider_did} through pkg/clients (the only sanctioned cross-service path) and checks three fields:
statusmust beactive. A suspended or deregistered Provider is denied immediately (403). This is how the Registry’s emergency kill-switch (Registry LLD §9.5) reaches the token path without waiting for Phase 2 revocation: once a Provider is suspended, it stops receiving new tokens from the very next request.supported_scopesmust contain the requested scope (403 otherwise).risk_tier_maxmust be at least the tier the policy assigned (403 otherwise). The Trust Server never issues a token above the ceiling the Provider declared (spec §6).
Failure mode (Phase 1). If the Registry is unreachable, issuance fails closed with 503. The Trust Server does not cache Provider records and does not issue on stale data in Phase 1; the zero-trust rule is fail-secure (spec §8.1). A short-lived Provider-record cache to ride out a brief Registry outage is a Phase 2 availability item (§16), not Phase 1 behaviour.
8.6 Key ring and rotation
The Trust Server signs tokens with an Ed25519 key ring: one key is active (used for signing), and the most recent previous keys stay retained (published for verification only). Retention is what makes rotation safe: a token signed moments before a rotation still verifies, because the key that signed it is still published.
The retirement rule. A retired key must stay published for at least: the DID-document client cache TTL, plus the maximum token TTL, plus a safety buffer. Removing it earlier makes tokens signed with it unverifiable, which shows up as mass 401 errors downstream. The size of the ring follows from this rule and the rotation interval; it is not a number chosen up front. With our short token lifetimes (five minutes at most) and a modest cache TTL, a ring of three keys satisfies the rule comfortably (default), but the rule governs and the count is its consequence. This mirrors standard identity-provider practice: publish, announce, sign, and retire a key only after every token it could have signed has expired.
- Where keys live. Private key material is generated inside the KMS/HSM and never leaves it (spec §8.1 requires HSM storage for Tier 3/4). The ring in memory holds only handles and public keys, never private bytes.
- Publication.
GET /.well-known/did.jsonlpresents the ring as the Trust Server’sdid:webvhlog: one verification method per retained public key, each with a stablekid, carried in a signed, hash-chained history. Providers fetch and cache this to verify tokens offline, and the history lets a Provider confirm that akidwas a valid signing key at the moment the token was signed, not just today. The endpoint is version-stamped and served with explicitCache-Controlheaders (public, max-age=<TTL>, must-revalidate), so the server, not each client, decides the safe caching window; the retirement rule above is computed against this same TTL. Guidance for Providers: cache according to the headers; if a token arrives with an unknownkid, refetch the document once before rejecting (the ring may have just rotated); and cap that refetch rate to a few minutes, so a flood of boguskidvalues cannot be used to hammer the endpoint. Serving the DID document from static, highly available hosting (a CDN or object store), so that verification never depends on the Trust Server process being up, is a Phase 2 hardening (§16). - Rotation.
POST /admin/v1/keys/rotate(internal listener) creates a new active key, moves the previous active key to retained, and drops the oldest key once the retirement rule allows. The minter always signs withActive(), and the JWTkidnames that key. - Publish before use. Because verification is offline and cached, a new key must be published, and given time to propagate through caches, before it starts signing (proposal: a two-step rotate, or a minimum publish-ahead interval). This guarantees no Provider ever sees a
kidit cannot resolve. - did:webvh and the ring. The ring’s rotation is recorded as
did:webvhlog entries: each rotation appends an entry that updates the verification methods, and a retired key stays in the history for as long as the retirement rule needs.did:webvhpre-rotation, where the next key’s hash is committed in advance, means a stolen active key cannot rotate the Trust Server’s identity away. This hardens the root of token trust beyond what the ring alone gives. - Genesis. The first key is created by the same governed offline ceremony that seeds the trusted-issuer list (Registry LLD §12); this ceremony also fixes the
did:webvhSCID, the self-certifying identifier bound to the log’s genesis, and the key fingerprint is published. The root of token trust is therefore auditable, not implicit, and the SCID stops a later domain takeover from silently rewriting the Trust Server’s key history.
8.7 Consent withdrawal and in-flight tokens
The spec requires that withdrawing consent invalidates the active tokens that reference it (§4.4). A stateless Trust Server cannot recall tokens it never stored, and tokens are verified offline by design. So invalidation happens at the two points that do see requests:
- At issuance: the Trust Server refuses any new token against a consent whose
revocation_statusisrevoked(§8.3). - In flight: the consent’s
revocation_check_urlreflectsrevokedwithin sixty seconds of withdrawal (spec §4.4), and Providers check it for Tier 3+ tokens that have lived past half their TTL. In every case, the short TTLs (thirty seconds at Tier 3) cap how long a withdrawn consent can still be exploited.
Active, coordinated revocation of already-issued tokens, tied together with Registry suspension, is a Phase 2 item (§16), matching the Registry’s own in-flight-revocation hook (Registry LLD §17).
9. Concurrency model
The Trust Server is request/response and spends its time waiting on I/O. The HTTP server already gives each request its own goroutine, and Redis and the upstream services handle concurrent access. Extra concurrency is added only where it removes a real wait:
- Parallel fetches during issuance. Once the proof is verified, the Registry call, the consent fetch (Tier 2+), and the Provider-VC fetch (Tier 3+) are independent of each other. They run concurrently under a bounded
errgroupwith a timeout per call, so a Tier 3 request is not three network round-trips in a row. The policy decision runs first, because it is local and determines which of the fetches are even needed. - Resolver
singleflight. Many simultaneousdid:webvhlookups for the same DID collapse into one upstream fetch (reused frompkg/did). - Graceful shutdown. On a stop signal: stop accepting connections, let in-flight requests finish under a deadline, then close the Redis pool.
What we avoid, exactly as in the Registry: goroutines that hold a resource while waiting, unbounded fan-out, and concurrency for its own sake. Key rotation and policy reload are serialized admin actions, applied with an atomic pointer swap, so a mint in progress never sees a half-changed ring or policy.
10. Validation
Three layers, checked in order, mirroring the Registry:
- Schema (is it well-formed?). The
TokenRequestbody is checked for shape (422 on failure). The loaded risk policy and every fetched consent artifact are validated against their frozen JSON Schemas (UAIRiskPolicy, ConsentArtifact) usingpkg/schema. The conformance suite uses the same schema files, so the service and the contract cannot drift apart. - Conditional (do the fields agree?).
consent_idis required at Tier 2 and above; a DPoP proof is required at Tier 3 and above; the requested scope must be inside the Provider’ssupported_scopesand the tier within itsrisk_tier_max; the scope must be inside the consent’sallowed_scopes. Failures return 422, except the Provider-capability checks which return 403 (§14). - Business (is it true in the outside world?). The Seeker’s
did:webvhmust resolve and the proof-of-control signature must verify against thekidkey. The consent artifact must resolve and pass all five checks. At Tier 3 and above, each Provider VC must resolve, verify, be unexpired, and come from a trusted issuer (revocation checking is deferred, §16). Failures return 401 (proof), 422 (consent, VC), or 403 (Provider capability), per §14.
11. Security summary
- Proof-of-control on every token request, bound to
kid, nonce, method, URL, and body hash (§7.1). Identical to the Registry’s mechanism; it has the same properties as RFC 9421 HTTP Message Signatures and shares DPoP’s (RFC 9449) replay defenses. - Nonces: 32 random bytes (
crypto/rand), 5-minute TTL, single-use via atomic RedisGETDEL, burned on any failed attempt. The challenge endpoint reveals nothing about who is registered and is rate-limited, as in the Registry. - DPoP (Tier 3+): the token is bound to the caller’s proof key through
cnf.jkt; each proof’sjtiis single-use and itsiatmust be within skew (thirty seconds Tier 3, ten Tier 4). Framework only in Phase 1. - Fail-secure everywhere. A missing or bad proof, an unknown scope, an unreachable Registry, an invalid consent, or a failed VC check all deny (spec §8.1). Nothing defaults to allow.
- Provider ceiling enforced. No token is ever issued for a scope the Provider does not support, or a tier above its declared
risk_tier_max(§8.5). - Key custody. Signing keys live in the KMS/HSM; the process holds only handles and public keys. Rotation is a governed admin action, and the first key comes from an audited offline ceremony (§8.6).
- SSRF. Both
did:webvhresolution and the consent-artifact fetch use the hardened resolver rules of Registry LLD §9.2: reject private, loopback, link-local, and unique-local address ranges; no redirects; connect and read timeouts; a maximum response size; TLS 1.3 only. Certificate Transparency monitoring and CAA records apply on government domains. - Rate limiting: per-IP on all public routes, and a tighter per-DID limit on
challengeandtoken. Redis-backed, so the limits hold across replicas. - Admin identity: the internal listener runs over mutual TLS, and every admin action carries an operator identity recorded in the audit trail. Four-eyes approval on rotation and destructive actions is a Phase 2 item (§16).
- Transport: TLS 1.3 only, no 1.2 fallback. Certificate pinning is recommended for connections to the Trust Server (spec §8.2).
- Logging hygiene: never log the minted token, signatures, DPoP proofs, nonces, or
subject_ref. Log DIDs,kid,txn,jti, tier, and the request id. - Input caps: maximum body size, maximum scope length, and a bounded
vc_refscount on the Provider record.
12. Observability
- Health:
/healthz(process is up) and/readyz(Redis reachable, risk policy loaded, key ring has an active key, Registry reachable). The gateway routes on/readyz. - Metrics (Prometheus): tokens minted by tier and result; token-issuance latency histogram (against the sub-500ms P95 budget); policy-evaluation outcomes; consent-validation pass/fail by tier; Registry-call latency and error rate; resolver cache hit/miss and upstream latency; nonce issue/consume; DPoP outcomes (Tier 3+); active-key age and time to next rotation;
did.jsonlfetch rate; and per-endpoint duration and status. One alert deserves a note: a spike in unknown-kidverification failures downstream, visible as a surge ofdid.jsonlrefetches just after a rotation, is the classic sign of a broken rotation and should page someone. - Logging: structured
log/slog, one line per request with method, path, status, duration, request id, seeker and provider DID, tier, andtxn. - Audit trail: append-only and separate from request logs. A record is written for every token issuance, every consent-validation event, and every admin action (rotate, reload, clear-cache). Each record carries the actor (seeker DID or operator identity),
kid,txn,jti, scope, tier, outcome, request id, HTTP method, URL, and timestamp. It never contains the token itself, signatures, proofs, nonces, or personal data. This trail is what disputes and forensics reconstruct from, alongside the receipt the Provider files with the Audit Ledger. - Retention and residency: audit and access logs are retained for at least one year, within Indian jurisdiction. This satisfies both applicable floors: the CERT-In 2022 Directions require 180 days, and the DPDP Rules 2025 (Rule 6) require one year for security logs. The stricter figure governs.
- Incident and breach reporting: two clocks apply, and both are documented in the ops runbook. Reportable cyber incidents go to CERT-In within 6 hours of detection (CERT-In 2022). A personal data breach additionally triggers the DPDP Rules 2025 track: intimation to the Data Protection Board, and notification to affected Data Principals within 72 hours, in plain language. The Trust Server holds no personal data (hash-only audit trail, no consent copies), which narrows this exposure but does not remove it; a signing-key compromise is a reportable incident under both tracks.
- Time sync: NTP to
time.nic.in(NIC) ortime.nplindia.org(NPL), so tokeniat/expvalues and the DPoP skew checks are consistent and admissible as evidence. - Tracing: OpenTelemetry spans on handler, Redis, resolver, and Registry calls (optional in Phase 1).
13. Configuration
Typed config loaded from environment variables in cmd/trustserver/main.go:
| Var | Meaning |
|---|---|
TRUST_EDGE_ADDR | Public listener address (dev :8002). |
TRUST_INTERNAL_ADDR | Internal/admin listener address. |
TRUST_INTERNAL_MTLS_CA / _CERT / _KEY | Mutual-TLS material for the internal/admin listener. |
TRUST_PUBLIC_BASE_URL | The externally visible base URL, used to rebuild htu for proof verification (§7.1). |
TRUST_DID | The Trust Server’s own DID (the token iss and the subject of its did:webvh log at /.well-known/did.jsonl). |
REDIS_ADDR | Redis address (nonces, rate limiting, resolver cache). |
NONCE_TTL | Challenge lifetime (default 5m). |
RISK_POLICY_PATH | Location of the UAIRiskPolicy file(s), loaded and validated at startup. |
TOKEN_TTL_TIER_1..4 | Per-tier TTL defaults, used when a scope entry does not set its own. |
KEYRING_KMS_URI / KEYRING_SIZE | KMS/HSM key source and ring size (default 3, per the §8.6 retirement rule). |
KEY_ROTATION_PUBLISH_AHEAD | Minimum time a new key must be published before it starts signing (§8.6). |
RESOLVER_CACHE_TTL / RESOLVER_ALLOWLIST | DID document cache lifetime, and an optional domain allowlist. |
REGISTRY_BASE_URL | Base URL for the one internal Registry call (pkg/clients). |
INTERNAL_SERVICE_TOKEN | Shared token for the internal listener (from the KMS/secret manager). |
CONSENT_SOURCE | Consent provider base URL; a mock provider in Phase 1 (§8.3). |
RATE_LIMIT_* | Per-IP and per-DID limits. |
MAX_BODY_BYTES | Request body size cap. |
AUDIT_SINK | Destination for the append-only audit trail. |
LOG_RETENTION_DAYS | Audit/access log retention (default 365; DPDP Rules 2025 Rule 6 supersedes the CERT-In 180-day floor). |
NTP_SERVERS | Time sources (default time.nic.in, time.nplindia.org). |
14. Error model
One JSON shape everywhere, identical to the Registry: { "error": { "code": "...", "message": "...", "request_id": "..." } }. Messages never echo secrets, tokens, or personal data.
| Status | When |
|---|---|
| 400 | Malformed JSON; invalid DID format; missing nonce header. |
| 401 | Invalid or replayed nonce; bad proof-of-control signature; the Seeker’s DID would not resolve; kid missing from the DID Document or not a signing key; htm/htu mismatch. DPoP failures at Tier 3+ (dpop_required, dpop_proof_mismatch, dpop_proof_expired, dpop_audience_mismatch, dpop_replay) per spec §4.3. |
| 403 | The Provider is not active; the scope is not in the Provider’s supported_scopes; the requested tier is above the Provider’s risk_tier_max. |
| 422 | TokenRequest shape invalid; unknown scope; consent_id missing at Tier 2+; consent invalid (expired, revoked, scope or DID mismatch, or a DPDP property false); a Provider VC invalid at Tier 3+. |
| 429 | Rate limit exceeded. |
| 503 | Redis unavailable, or the Registry unreachable (fail closed); also reflected by /readyz. |
The token endpoint deliberately never returns 404. A Provider the Registry cannot find, or that cannot serve this request, maps to 403, so the token endpoint never leaks whether a given DID exists in the Registry.
15. Testing and conformance
- Unit: handlers and logic are tested against fakes of the §6 interfaces, with no network. The policy engine, the consent checks, proof verification, and minting are each tested in isolation.
- Integration: real Redis via
testcontainers-go(proposal), covering nonce single-use, rate-limit behaviour, and resolver caching. A stub Registry and a stub consent source exercise the Registry call and the consent pipeline. - Conformance (spec §12.2): the Trust Server conformance suite runs in CI as a gate. It checks that all mandatory claims are present (
iss,sub,aud,exp,jti,scope,txn); Bearer header compliance (RFC 6750); that a Tier 2+ request without a consent reference fails; that a Tier 3+ request without the required VC fails; that the TTL matches the declared tier; and the DPoP profile tests for Tier 3+. - Consent and DPoP conformance (spec §12.7): a consent that is missing
purpose, expired, scope-mismatched, DID-mismatched, revoked, or has any DPDP property false must each fail. A DPoP proof with a missing header, a thumbprint mismatch, a replayedjti, or aniatoutside the skew window must each fail. - Cross-service token vector: a token minted with
pkg/jwtmintis verified bypkg/jwtverifyand by the Python reference validator, asserting byte-for-byte agreement, so the signer and every verifier stay in lockstep. The §7.1 proof-of-control reference vector is the same one the Registry’s cross-language test asserts. - Key rotation: two tests. A token signed under the previous active key must still verify after a rotation, while that key remains published. And a new
kidmust be resolvable in thedid:webvhlog at/.well-known/did.jsonlbefore it is ever used to sign (publish before use). - Contributor flow:
make testruns unit tests;make test-integrationspins up containers; CI runs both plusdepguardandgolangci-lint.
16. Phase 2 hooks (deferred, seams in place)
- Live Tier 3 (DPoP) and Tier 4. The DPoP verifier, the
cnfseam, and the Redis replay guard exist now (§8.4). Phase 2 turns on the live Tier 3 path and adds Tier 4 (manual approval, dual control,runtime_controlsenforcement). - Live consent via a registered Consent Manager. Replace the mock consent provider with live Aadhaar/OTP and integration with a Consent Manager registered with the Data Protection Board, behind the same
ConsentVerifierinterface (§8.3). The DPDP Rules 2025 make the Consent Manager framework operational from November 2026, so the Phase 2 target is a DPB-registered CM, not a bespoke consent source. The heavy obligations (seven-year consent-record retention, data-principal dashboards, machine-readable consent records) sit with the registered CM, which is one more reason the Trust Server validates consent and never stores it. - Status-list revocation at token time. Add a Bitstring Status List check on Provider VCs at issuance, the backstop the Registry defers (Registry LLD §17). The Provider record already carries the issuer and the credential index.
- In-flight token revocation. Coordinate with Registry suspension to actively kill already-issued tokens, closing the kill-switch completeness gap (§8.7; Registry LLD §17).
- Provider-record cache for availability. A short-TTL cache of the Registry response, with a hard staleness bound, so a brief Registry outage does not stop all token issuance (§8.5).
- Federated issuer trust. Replace the static trusted-issuer list with OpenID Federation entity statements, shared with the Registry.
- Post-quantum / hybrid signatures. Migrate the token signature suite through the
kid/verification-method seam (§7.2), shared with the Registry. - PASETO tokens. Optionally support PASETO alongside JWT (spec §4.1), behind the same
TokenMinterinterface. - Per-key HSM certificates and four-eyes approval on the internal listener for rotation and destructive actions.
- High availability. Clustered Redis with tested recovery objectives, so token issuance has no single point of failure. In addition, serve
/.well-known/did.jsonlfrom static, highly available hosting (CDN or object store), so offline token verification never depends on the Trust Server process being up (§8.6). - Multi-hop delegation (watch, do not build). Phase 1 delegation is deliberately single-hop: one Seeker, one Provider, one token, with
txnproviding traceability across the interaction. The industry is actively working on delegation across agent chains (an agent handing narrower authority to sub-agents), with several competing pre-consensus proposals and no settled standard yet. When UAI corridors need agent chains, we adopt whichever standard stabilises rather than inventing our own. The token’stxnbinding and thekid-based algorithm agility are the seams it would attach to. This is a named deferral, not an omission.
17. Open items to confirm
- Challenge round-trip on the hot path (decided). Reusing the Registry’s proof-of-control means every
POST /trust/v1/tokenis preceded by aGET /trust/v1/challenge. The sub-500ms P95 budget absorbs this comfortably. Recorded here for visibility; the alternative was a self-contained timestamp-plus-jtiproof with no challenge round-trip. - Provider-capability failures as 403. “Provider not active”, “scope unsupported”, and “tier over ceiling” return 403 (an authorization answer about the target Provider) rather than 422, and the endpoint never returns 404. Confirm this reading.
- TTL precedence. Per-scope policy TTL first, per-tier default as the fallback (§8.2). Confirm, versus tier defaults only.
- Rotation parameters. The retirement rule (§8.6: a retired key stays published for at least the DID-document cache TTL plus the maximum token TTL plus a buffer) fixes the shape. What needs confirming are the numbers: the
did.jsonlcache TTL, the publish-ahead interval, and the rotation cadence, from which the ring size (default three) follows. - Registry fail-closed. When the Registry is unreachable, issuance fails with 503 and no cached Provider record is used in Phase 1 (§8.5). Confirm, versus a short bounded cache from day one.
- Risk-policy governance. The policy file(s) are governed like the capability taxonomy: changes by PR with review, a single named owner, and one file per corridor (agriculture, health, governance). Confirm this governance from day one.
did:webvhverifier in Go. Decision D15 moves UAI todid:webvhfor every agent and for the UAI services’ own identities. No mature Go library exists yet (implementations are in Rust, TypeScript, Java, and Dart), so the sharedpkg/didwill implement the resolver and the log verifier against the v1.0 spec and its test suite. Confirm this build-it-ourselves approach and the target spec version before resolver work starts (§3).