UAI Shared Primitives (pkg/): Low-Level Design (LLD)

The shared pkg/ layer imported by every UAI service — the single normative home for the common libraries.

Scope. This is the how of the shared primitives layer: the pkg/ tree that the Registry, Trust Server, Audit Ledger, Seeker SDK, and Provider kit all import. Until now, each service LLD carried its own partial copy of the pkg/ description. This document is the single normative home for pkg/. The service LLDs keep a one-line pointer here.

Status. First draft for iteration. Library picks marked (decision) carry a recommendation and a decision-log entry; picks marked (open) need a ruling. Revision: adds pkg/uaierr and pkg/agentfacts (review feedback — both had concrete consumers in the Provider kit and Seeker SDK with no home in the first draft), and moves envelope/receipt construction to issue #7 (review ruling — both kit epics list them as dependencies of uai-foundation and had no build issues for them).

Applies D15. All DID references in this document are did:webvh. This fixes the drift where the Registry and Trust Server LLDs still said did:web while the Audit Ledger and Seeker LLDs said did:webvh.


1. Purpose and scope

The shared primitives are the small pieces of code that every UAI component must agree on, byte for byte. If two services canonicalize JSON differently, signatures break. If two services resolve DIDs differently, trust breaks. If the token minter and the token verifier drift apart, the whole permission model breaks. So these pieces are written once, in one place, and imported everywhere.

pkg/ is a library layer, not a service. It has no listeners, no databases, no configuration files, and no state of its own. It gives the services thirteen packages:

PackageOne line
pkg/jcsTurn any JSON into one exact byte form (RFC 8785), so hashes and signatures match everywhere.
pkg/ed25519xSign and verify with Ed25519, without ever holding raw private key bytes.
pkg/didResolve and verify did:webvh identities, including their full key history.
pkg/schemaThe frozen JSON Schemas, embedded and compiled, plus one validate call.
pkg/issuersThe governed list of credential issuers UAI trusts, and one Allowed() check.
pkg/jwtmintBuild and sign delegation tokens (EdDSA). Only the Trust Server calls this.
pkg/jwtverifyCheck delegation tokens offline. Providers and the Audit Ledger call this.
pkg/envelopeThe {context, message} wire envelope, its canonical bytes, and its proof.
pkg/receiptHash and verify UAI receipts, against the key that was valid at signing time.
pkg/transportA TLS 1.3 HTTP client (and server config helper) with safe defaults.
pkg/clientsThe only sanctioned cross-service REST clients, generated from each service’s OpenAPI file.
pkg/uaierrThe canonical protocol error codes Providers return and Seekers match on, as typed errors.
pkg/agentfactsBuild, parse, and compliance-check AgentFacts documents (/.well-known/agentfacts.json).

This document covers what each package does, its public surface, which third-party library sits underneath it (and why), how the set stays secure, and how it scales. It does not cover any service’s business logic; that stays in the service LLDs.

2. Why this LLD exists

Three problems forced this document:

  1. Drift. The same primitive was specified differently in different LLDs. pkg/did was a did:web resolver in two documents and a did:webvh resolver in two others. A byte-identical shared package cannot have four partial, inconsistent specs.
  2. Ownership. GitHub issue #7 (“shared protocol primitives”) gates the Registry’s proof-of-control and VC verification work and sits at the front of the critical path, but it had no design document behind it. The Registry estimation handoff calls #7 the single biggest schedule risk.
  3. Reuse versus build. Nobody had ruled, with research, on which primitives we build and which we take from the ecosystem. Rebuilding solved problems (JSON canonicalization, JOSE, JSON Schema) is wasted effort and a security risk; the industry libraries have had years of adversarial review that fresh code has not.

3. Design principles

These ten rules govern everything in pkg/. They are short on purpose.

  1. Reuse before build. If a maintained, widely used library implements the spec we need, we use it. We build only when no compliant library exists (the did:webvh verifier) or the code is trivially small (the issuer list).
  2. Wrap, never expose. Every third-party library sits behind a pkg/ API that we own. Services import pkg/jcs, never gowebpki/jcs directly. If a dependency dies or a better one appears, we swap it inside one package instead of touching five components. A depguard rule enforces this: only pkg/ may import the underlying libraries.
  3. One implementation, byte identical. The repo is one Go module. Every service builds pkg/ from the same commit, so “byte-identical across services” is guaranteed by the build, not by discipline.
  4. Stateless and infrastructure free. No globals, no init() side effects, no environment reads, no logging, no Redis or Postgres imports. Where a package benefits from a cache, it accepts a small Cache interface that the service’s main injects. pkg/ returns errors; services decide what to log.
  5. Everything takes context. Every network call and every potentially slow operation accepts a context.Context and honors cancellation and deadlines.
  6. Fail closed, with typed errors. Verification functions return a sentinel error or a fully verified result, never a partial one. There is no “verify but ignore this one check” mode.
  7. Private keys never enter pkg/. Anywhere a signature is produced, the API takes a crypto.Signer, not key bytes. KMS and HSM handles satisfy crypto.Signer, so key material stays inside the key store. This also settles the Seeker LLD’s open wording item on crypto.Signer at the root.
  8. Frozen by vectors. A set of golden test vectors (canonical bytes, hashes, tokens, receipts, DID logs) is the compatibility contract. Changing a vector is a breaking change and needs a decision record, exactly like changing a frozen schema.
  9. Minimal dependency budget. Every new third-party dependency needs a decision-log entry, a named owner, and a quick license glance (free and permissive only). Dependencies are pinned; nothing floats.
  10. Algorithm agility through kid. Nothing in pkg/ hardcodes an algorithm beyond the Phase 1 Ed25519 profile. Signature algorithms resolve through the kid and the verification method type in the DID document, so a post-quantum or hybrid suite later is additive, not a rewrite.

4. Who consumes what

The consumer matrix, from the four service LLDs plus the Provider kit:

PackageRegistryTrust ServerAudit LedgerSeeker SDKProvider kit
jcsyesyesyesyesyes
ed25519xyesyesyesyesyes
didyesyesyesyesyes
schemayesyesyesyesyes
issuersyesyes (Tier 3+ seam)nonono
jwtmintnoyesnonono
jwtverifynotest onlyyes (query auth)noyes (offline token check)
envelopenononoyesyes
receiptnonoyes (verify at ingest)yes (read-back check)yes (build and sign)
transportoutbound fetchesoutbound fetchesoutbound fetchesyesyes
clientsno (serves)yes (one hop to Registry)no in Phase 1yesyes
uaierrnononoyes (match on codes)yes (return from pipeline)
agentfactsno (stores facts_ref only)nonoyes (parse + compliance check)yes (build and serve)

Three notes. First, jwtmint has exactly one production consumer, the Trust Server; it still lives in pkg/ next to jwtverify so the signer and verifier are developed and tested as one pair and cannot drift. Second, envelope and receipt appeared only in the Seeker LLD before; the Provider kit needs both (a Provider verifies inbound envelopes and builds signed receipts), so they are shared primitives, not Seeker internals. Third, the three control-plane services never import pkg/envelope: UAI stays blind to payload content by design, and a CI rule makes that structural (see the CI gates section).

5. Build ownership and epic mapping

This LLD specifies all thirteen packages. Construction stays split across the existing epics, unchanged, with #7 as the anchor:

  • Issue #7 (shared protocol primitives) covers: jcs, ed25519x, did, schema, issuers, transport, clients, uaierr, agentfacts, envelope, receipt. This matches its existing description (“keys, canonical, hash, did, schema, issuers, clients”; hash folds into jcs and ed25519x; transport is added because clients cannot exist without it; uaierr, agentfacts, envelope, and receipt carry over from the epic’s original child issues #63, #69, #64, and #68).
  • Trust Server sub-epic B (#103) builds jwtmint and jwtverify, in pkg/, to this LLD. Its cross-language token vector becomes part of the golden vector set here.
  • The Seeker SDK and Provider kit consume envelope and receipt (both list them as uai-foundation dependencies); construction is under issue #7. This also keeps the Audit Ledger — a pkg/receipt consumer at ingest — depending only on the foundation epic, never on a kit epic.

A planning seed for breaking #7 into sub-epics, in this repo’s estimation convention (dev-days are one hands-on writer; points come later at issue creation):

Sub-epicContentsDev-days
7Ajcs wrapper, ed25519x, multiformats helpers, first golden vectors2.5
7Bschema (embedded, offline compile) and issuers2
7Cdid resolver and verifier, did:webvh v1.0, interop fixtures8 to 10
7Ddid log authoring (service identity publication)3
7Etransport, clients generation, thin retry, drift check, uaierr4
7FVector suite, fuzz targets, depguard CI gates3
7Gagentfacts — build/parse + compliance checks2
7Henvelope + receipt — models, canonical bytes, proof sign/verify, historical-key receipt verify7
Total~31.5 to 33.5

7C is the long pole and it is genuinely new code (see the reuse-versus-build table). Everything else is thin wrapping and wiring. 7A, 7B, 7E, and 7G can run in parallel with 7C once interfaces are agreed, which fits the two-engineer pattern used on the Registry. 7H follows 7A (it signs over jcs canonical bytes) and consumes the did.Resolver interface from 7C; receipt.Verify’s historical-key check lands once 7C’s VerificationMethodAt exists.

6. Reuse versus build: the researched decisions

The rule from principle 1, applied. One thing said plainly first: “reuse” never means paying for anything. Every library below is free, open-source software under a permissive license; it costs nothing to use in development or production, and asks nothing beyond keeping the author’s copyright notice in the tree, which Go modules do automatically. Every row was checked against the current ecosystem (July 2026). “Wrap” means the library is used inside pkg/ behind our own API, per principle 2.

NeedDecisionWhat we useLicenseWhy this one
RFC 8785 canonical JSONReuse, wrapgithub.com/gowebpki/jcsApache-2.0The maintained cleanup fork of the reference implementation by the RFC’s author (cyberphone/json-canonicalization), made with his permission. Packaged in Debian; used across the signing ecosystem (sigstore/cosign dependency chains, buildkite). Exactly one function we need: Transform.
Ed25519Stdlibcrypto/ed25519Go BSDNever take third-party code for core signatures when the standard library ships a constant-time, audited implementation.
Multibase, multihashReuse, wrapgithub.com/multiformats/go-multibase, go-multihashMIT/Apachedid:webvh requires base58btc multibase and multihash for the SCID and entry hashes. These are the canonical implementations from the IPFS/libp2p ecosystem, in production at very large scale for years.
JOSE (JWT, JWS, JWK)Reuse, wrapgithub.com/lestrrat-go/jwx/v3MITThe complete JOSE toolset: JWT plus JWK plus JWS in one actively maintained library. We need JWK handling and RFC 7638 thumbprints for the DPoP seam, which the popular golang-jwt/jwt does not provide. v3 is the current stable line; v4 (2026) trims dependencies and adds post-quantum composite signatures (ML-DSA, draft-ietf-jose-pq-composite-sigs), which lines up exactly with our Phase 2 PQC seam. We pin v3 now and revisit v4 with the PQC work (open question P-Q4).
JSON Schema draft 2020-12Reuse, wrapgithub.com/santhosh-tekuri/jsonschema/v6Apache-2.0Fully compliant with the official JSON-Schema-Test-Suite, supports draft 2020-12, thread-safe validation, rich error locations. This confirms the pick already proposed in Registry decision issue #18.
SSRF-hardened fetchingReuse, wrapgithub.com/doyensec/safeurlApache-2.0A security-firm-authored drop-in for net/http that blocks private and reserved ranges at the dialer and mitigates DNS rebinding by pinning the resolved IP. Actively maintained (2026). We keep it behind our own fetcher interface so an in-house dialer control function remains a clean fallback (P-Q8).
OpenAPI client generationReuse (tool)github.com/oapi-codegen/oapi-codegen/v2Apache-2.0The standard Go generator for clients and types from OpenAPI 3.0/3.1. Generated clients carry no extra runtime dependencies. The experimental v3 line is not stable; we stay on v2.
Request coalescingQuasi-stdlibgolang.org/x/sync/singleflightGo BSDCollapses concurrent cache misses for the same DID into one fetch. Maintained by the Go team.
Retry/backoff in clientsBuild (thin)~50 lines in pkg/clientsn/aJittered exponential backoff on idempotent GETs only, honoring Retry-After. Too small to justify a dependency that brings its own logger (P-Q3).
did:webvh resolution and verificationBuildin-house, in pkg/didn/aThis is the one real build. As of this research there is no complete, v1.0-compliant Go implementation. The complete implementations listed by DIF are TypeScript, Python, and Rust (the Rust one contributed by Affinidi, security-reviewed, with external-signer support). The only Go code (nuts-foundation/trustdidweb-go) is a proof of concept against the old pre-1.0 spec. We implement the verifier ourselves against the v1.0 spec, using didwebvh-rs as the reference design and the DIF interop registry (identity.foundation/didwebvh-implementations) as our conformance fixture source. This confirms the “confirm before build starts” item in D15.
Trusted issuer listBuild (trivial)in-house, in pkg/issuersn/aAn embedded, governance-maintained allowlist and one lookup function. Too small to need a library.
Protocol error codesBuild (trivial)in-house, in pkg/uaierrn/aA declaration file: typed codes and HTTP statuses. Too small to need anything.
AgentFacts modelBuild (trivial)in-house, in pkg/agentfactsn/aA typed model plus build/parse/compliance checks over the frozen AgentFacts schema. No library exists or is needed.

Two libraries were considered and rejected. golang-jwt/jwt/v5 is the most popular Go JWT library but covers JWT only; we would still need a JWK library for DID-document key handling and DPoP thumbprints, so one complete library beats two partial ones. A very new strict JCS implementation (json-canon, first released 2026) is interesting for its conformance traceability but is months old with no ecosystem track record; we prefer the reference-lineage library and note this one for a future review.

Licenses, kept simple. Everything above is free and open source under a permissive license (Apache-2.0, MIT, or BSD). There is nothing to purchase and no ongoing obligation. The one rule worth keeping: no GPL or AGPL code anywhere in the tree, because those licenses could force UAI’s own code open. With a dependency list this small, whoever adds a library checks its license by eye in the same PR. No tooling, no formal process.

7. Per-package design

Each section: what it does in plain words, then the public surface, then the security and scale notes that matter. Interfaces are sketches; exact signatures firm up in code review.

7.1 pkg/jcs

Plain words. JSON allows the same data to be written many ways (key order, number formats, spacing). Before hashing or signing JSON, everyone must reduce it to one exact byte form, or signatures made by one party will not verify at another. This package is that reducer.

Surface.

func Canonicalize(raw []byte) ([]byte, error)      // RFC 8785 canonical form
func Hash(raw []byte) ([32]byte, error)            // sha256(Canonicalize(raw))
func HashB64URL(raw []byte) (string, error)        // base64url, unpadded, of Hash

Underneath. gowebpki/jcs.Transform, wrapped. The wrapper adds an input size cap (ErrTooLarge above a caller-set limit, default 1 MiB) so untrusted input cannot burn unbounded CPU in canonicalization, and it pins the sha256 profile in one place.

Rules that ride along. Bound objects that get signed (proof-of-control, DPoP-style bindings) stay all-strings, per the Registry LLD: strings keep JCS down to sorted keys and escaping, while numbers pull in full ECMAScript number formatting. The library handles numbers correctly either way; the all-strings rule just removes a class of cross-language surprises.

Vectors. The RFC 8785 appendix test files, plus UAI-specific vectors (a bound object, an envelope, a receipt core), checked byte for byte in CI and cross-checked from Python.

7.2 pkg/ed25519x

Plain words. One place for producing and checking Ed25519 signatures, and for moving public keys between the formats UAI uses (raw, multibase, JWK). Private keys never appear as bytes in this package.

Surface.

func Sign(ctx context.Context, s crypto.Signer, msg []byte) ([]byte, error)
func Verify(pub ed25519.PublicKey, msg, sig []byte) error   // ErrBadSignature

func PublicKeyToMultibase(pub ed25519.PublicKey) string     // z + base58btc(0xed01 || key)
func MultibaseToPublicKey(s string) (ed25519.PublicKey, error)
func PublicKeyToJWK(pub ed25519.PublicKey) (json.RawMessage, error)  // OKP/Ed25519
func JWKThumbprint(jwk json.RawMessage) (string, error)              // RFC 7638, for cnf.jkt

Underneath. crypto/ed25519 for the math; go-multibase for the z base58btc encoding with the Ed25519 multicodec prefix (0xed01), matching the publicKeyMultibase form already used in the demo code and required by did:webvh.

Why crypto.Signer. The Trust Server key ring and the did:webvh log authoring both sign with keys that should live in a KMS or HSM. crypto.Signer is the standard Go seam for exactly that: the handle signs, the bytes never leave the key store. Every signing path in pkg/ (token minting, envelope proofs, receipt signing, DID log entries, checkpoint signing) goes through this one function.

Security notes. Ed25519 verification is constant-time by construction. Any residual secret comparison anywhere in pkg/ (none expected in Phase 1) must use crypto/subtle; this is the same lesson as the Python demo’s hmac.compare_digest fix, stated once here so it is a rule, not tribal memory.

7.3 pkg/did

Plain words. This is how UAI answers “who is this agent, and what keys do they control, and what keys did they control at some past moment.” A did:webvh identity is a domain plus a self-certifying identifier (SCID), and it publishes not one document but a signed, hash-chained log of every version of its document (did.jsonl). This package fetches that log safely, verifies the whole chain, and hands back the document plus its history. It is the one genuinely new build in pkg/ (see the reuse-versus-build table) and the long pole of issue #7.

Layout.

pkg/did/
  did.go          Parse, validate, DID <-> HTTPS URL transform
  resolve.go      Resolver: fetch + verify + cache
  verify.go       log verification: SCID, entry hashes, proofs, parameters
  history.go      VerificationMethodAt(kid, at time.Time)
  log/            authoring: create genesis entry, append entry (crypto.Signer)
  fetch.go        SSRF-hardened HTTP fetch (safeurl underneath)
  cache.go        Cache interface + verified-state snapshot type

Surface (resolution side).

type Resolver interface {
    Resolve(ctx context.Context, did string) (*Document, error)
    ResolveHistory(ctx context.Context, did string) (*History, error)
}

type History struct { /* every verified version, with validity windows */ }
func (h *History) VerificationMethodAt(kid string, at time.Time) (*VerificationMethod, error)

func New(fetch Fetcher, cache Cache, opts ...Option) Resolver

This satisfies the model.Resolver port already defined in each service, unchanged. VerificationMethodAt is the new capability D15 bought: the Audit Ledger and pkg/receipt use it to check a receipt against the key that was valid when the receipt was signed, not the key the domain shows today.

Verification pipeline (fail closed, in order).

  1. Parse. did:webvh:{SCID}:{domain}[:{path}]. Strict charset, lowercase method, SCID present and base58btc-shaped. Reject anything else.
  2. Transform. Same DID-to-HTTPS mapping as did:web, targeting did.jsonl. HTTPS only. No user-controlled ports in Phase 1.
  3. Fetch. Through the hardened fetcher: public IPs only (private, loopback, link-local, reserved ranges blocked at the dialer), DNS-rebinding pinning, redirects off, response size cap applied to decoded bytes (default 1 MiB), so a compressed response cannot expand past the cap, content sanity check, 3 second budget. These are the same SSRF rules the Registry LLD already states; they are implemented once, here.
  4. Parse the log. JSONL, per-line size cap, max entry count cap (default 500, P-Q6). Unknown top-level parameters are recorded, not silently dropped.
  5. Verify the chain. SCID binds the DID to its genesis entry; every entry’s hash chains to the previous version; every entry carries a Data Integrity proof in the eddsa-jcs-2022 cryptosuite (which is exactly Ed25519 plus JCS, both already in this tree) signed by the keys authorized at that point; pre-rotation commitments (nextKeyHashes) are enforced when active; version numbers and timestamps must be monotonic; deactivation is terminal.
  6. Produce. The latest document, plus the verified history with validity windows per verification method.

Any failed step returns a typed error (ErrBadSCID, ErrChainBroken, ErrBadProof, ErrDeactivated, …) and nothing else.

Phase 1 profile (what we verify, what we defer). did:webvh v1.0 has optional machinery we do not need on day one:

  • Witnesses: parsed and recorded in resolution metadata, not verified in Phase 1. Verification lands in Phase 2 as a Tier 3/4 gate, matching the D15 note.
  • Watchers: out of scope for the resolver; a UAI watcher service is a Phase 2+ infrastructure question.
  • Portability (DID renaming): rejected in Phase 1 (portable: true fails resolution). Switzerland’s swiyu profile disallows portability for its national deployment for exactly our reason: one agent, one identity, no silent renames inside a sovereign registry (P-Q1).
  • /whois and DID URL path dereferencing: not implemented; UAI has no consumer for them.
  • did:web fallback: none. Nothing is in production, so the resolver is did:webvh only and fails closed on did:web input (P-Q7).

Caching and scale. DID logs grow over time and a naive resolver re-downloads and re-verifies the whole log every time. Two mechanisms keep this cheap:

  • Verified-state snapshots. The cache stores the last verified state (version number, entry hash, active keys, document) keyed by DID. On the next resolution the resolver fetches the log, fast-forwards past already-verified entries, and verifies only new ones. The v1.0 spec explicitly permits resuming from previously verified state. Cache TTL follows Cache-Control from the origin, capped by policy; the snapshot itself never expires as verified history, only the “is this still current” answer does.
  • Singleflight. Concurrent misses for the same DID collapse into one fetch and one verification.
  • Negative caching. A failed resolution (unreachable domain, missing log, broken chain) is cached briefly (default 30 seconds), so repeated requests for a bad DID cannot be turned into a flood of outbound fetches. A DID that verified before and fails now is surfaced as a distinct error, because that is a security event, not a routine miss.

Per-entry cost is one JCS pass, one hash, and one Ed25519 verification, roughly tens of microseconds of CPU each, so even a cold 100-entry log is single-digit milliseconds of compute plus one HTTP round trip. The warm path is a cache hit.

Authoring side (pkg/did/log). UAI’s own services publish did:webvh identities (Registry, Trust Server, Audit Ledger), and the Trust Server’s key ring projects its DID document from it. The log sub-package creates the genesis entry and appends entries, signing through crypto.Signer, and emits the did.jsonl bytes for the service to publish at /.well-known/. The same code later backs the onboarding CLI that reduces provider friction (the D15 con). Witness co-signing at authoring time is a Phase 2 seam.

Conformance. The DIF interop registry publishes real did.jsonl fixtures from every complete implementation. Our verifier must resolve those fixtures correctly in CI; that is the operational definition of “compliant,” not our own opinion of the spec. Negative vectors (tampered entry, wrong SCID, forked history, broken proof) are hand-built alongside.

7.4 pkg/schema

Plain words. The frozen JSON Schemas are the protocol’s contract. This package embeds them into the binary, compiles them once, and gives every service the same validate call, with no network access ever.

Surface.

func Load() (*Registry, error)                       // compile embedded schemas, once
func (r *Registry) Validate(name string, doc []byte) error   // ErrSchemaViolation with pointer detail
func (r *Registry) MustGet(name string) *jsonschema.Schema   // for advanced callers

Underneath. santhosh-tekuri/jsonschema/v6, draft 2020-12. The compiler is configured with no URL loaders registered: every $ref must resolve inside the embedded set, so validation can never trigger an HTTP fetch. This is both a security property (no schema-driven SSRF, no availability coupling) and a determinism property (the binary validates the same way everywhere, forever).

D15 change carried here. The frozen DID pattern moves from ^did:web:.+$ to ^did:webvh:.+$ in RegistryAgentRecord and UAIReceipt (and anywhere else a DID pattern appears). The exact stricter pattern (whether the SCID segment shape is pinned in the regex) is a frozen-schema decision, tracked with the schema freeze, not decided here.

7.5 pkg/issuers

Plain words. UAI only accepts credentials from issuers it has decided to trust. This is that list, and the one question everyone asks it.

Surface.

type Trust interface {
    Allowed(issuerDID string, vcType string) bool
}
func Load() (Trust, error)   // embedded, governance-maintained list

Governance, not code. The list is a data file in the repo, owned via CODEOWNERS, changed only by PR with review, exactly like the frozen schemas. Phase 2 upgrades it to a signed governance artifact fetched and verified at startup; the interface does not change.

7.6 pkg/jwtmint

Plain words. The Trust Server’s permission slips are JWTs signed with Ed25519. This package builds them. It exists in pkg/ (not inside the Trust Server) purely so that the minter and the verifier are one tested pair.

Surface.

type Claims struct {
    Issuer, Subject, Audience string
    Scope string
    Tier  int
    TTL   time.Duration
    CNFJWKThumbprint string   // optional, DPoP seam (cnf.jkt)
    Extra map[string]any      // schema-validated by the caller
}
func Mint(ctx context.Context, s crypto.Signer, kid string, c Claims) (string, error)

Underneath. jwx/v3 for JWS assembly, with an adapter that routes the actual signature through pkg/ed25519x.Sign (and therefore through crypto.Signer). The kid header names the exact verification method in the Trust Server’s published DID document, per the key ring design. jti is always set (crypto/rand), iat/exp always present, algorithm is pinned to EdDSA (Ed25519); there is no way to mint anything else through this API.

7.7 pkg/jwtverify

Plain words. Providers check tokens offline, with no callback to UAI. The Audit Ledger checks tokens on its query endpoint. Both use this package, which is deliberately strict and boring.

Surface.

type Params struct {
    ExpectedAudience string
    ExpectedIssuer   string
    Leeway           time.Duration  // pinned default 30s
    Keys             KeySource      // usually pkg/did resolution of the issuer
}
func Verify(ctx context.Context, token []byte, p Params) (*VerifiedClaims, error)

Hard rules (each one is a test).

  • Algorithm allowlist is exactly EdDSA (Ed25519). none, all HS*, RS*, ES* are rejected before any key lookup. This kills the classic algorithm-confusion attacks by construction.
  • kid is required and must resolve to a verification method in the issuer’s DID document; the verifier uses exactly that key.
  • exp, iat, aud, iss are required; leeway is the pinned constant, not caller-tunable upward past a ceiling.
  • Token size cap before parsing (default 16 KiB). Malformed input is rejected cheaply.
  • No claims are returned unless every check passed (principle 6).

Offline by design (ZTAA). KeySource is an interface precisely so a Provider can verify with zero network calls at request time: it is normally backed by a cached, periodically refreshed copy of the Trust Server’s published DID document, not a live resolution per request. Token checks then cost microseconds and keep working even if the control plane is briefly unreachable, which is the offline-validation, no-callback property the HLD and the conformance plan require of every Provider.

The old demo’s HS256 path (and the Seeker LLD’s lingering AuditAuth/HS256 template) is exactly what this package makes impossible; the Seeker contract-break fix consumes this package.

7.8 pkg/envelope

Plain words. When a Seeker talks to a Provider, the payload travels inside a {context, message} envelope with a signature (the proof) over its canonical bytes. This package owns that envelope: its type, its canonical form, and its proof.

Surface.

type Envelope struct {
    Context json.RawMessage   // includes the compliance block per the frozen envelope schema
    Message json.RawMessage
}
func (e *Envelope) CanonicalBytes() ([]byte, error)              // via pkg/jcs
func Sign(ctx context.Context, s crypto.Signer, kid string, e *Envelope) (*Proof, error)
func VerifyProof(ctx context.Context, r did.Resolver, e *Envelope, p *Proof) error

Notes. The envelope’s field shape is owned by the frozen envelope schema; this package validates against pkg/schema before signing and after receiving. The missing context.compliance block flagged in the Seeker LLD review is a schema item; once the schema carries it, this package enforces it automatically. Proof verification resolves the signer through pkg/did, so a Provider checking a Seeker’s envelope (and vice versa) uses the same trust chain as everything else.

7.9 pkg/receipt

Plain words. A receipt is UAI’s evidence: a hash-only record that an interaction happened, signed by the Provider. Years later, someone must be able to check that signature against the key that was valid on that day. This package computes receipt hashes and performs that check.

Surface.

func CanonicalCore(r *schema.UAIReceipt) ([]byte, error)   // JCS of the signed core fields
func Hash(r *schema.UAIReceipt) ([32]byte, error)
func Verify(ctx context.Context, res did.Resolver, r *schema.UAIReceipt) error

The D15 payoff, made concrete. Verify resolves the signer’s history and calls VerificationMethodAt(kid, r.SignedAt). A receipt therefore verifies against the historical key, and a later key rotation or domain change cannot orphan old evidence. This single call is why UAI moved to did:webvh.

Consumers. The Audit Ledger’s ingest pipeline (its verify/ package wraps this), the Seeker’s read-back confirmation, and the Provider kit’s self-check before submission. One implementation, three call sites, zero drift.

7.10 pkg/transport

Plain words. Every outbound HTTPS call in UAI should be TLS 1.3 with sane timeouts and pooling, and every UAI server should present TLS 1.3. Nobody should hand-build an http.Client again.

Surface.

func NewClient(opts ...Option) *http.Client   // TLS 1.3 min, timeouts, pooling, no cookie jar
func NewHardenedClient(opts ...Option) *http.Client   // the above + safeurl SSRF guard (public IPs only)
func ServerTLSConfig() *tls.Config            // TLS 1.3 min, modern defaults, for service listeners and the Provider template

Defaults (pinned, overridable downward only where safe). MinVersion: TLS1.3; dial 5 s; TLS handshake 5 s; response header 10 s; overall deadline from the caller’s context; HTTP/2 on; MaxIdleConnsPerHost tuned for service-to-service reuse; redirects off in the hardened client. The hardened client is what pkg/did fetching and the Trust Server’s consent fetching build on, so the SSRF posture is one implementation, not four.

7.11 pkg/clients

Plain words. When one UAI service must call another (the one internal hop, the Seeker calling the control plane), it uses a generated client, never a hand-rolled URL. The OpenAPI file in each service’s api/ directory is the contract; the client is generated from it, so the contract cannot silently drift from the code.

How it is built.

  • oapi-codegen/v2 generates types and client per service from services/<name>/api/openapi.yaml.
  • Generated code is committed. CI regenerates and fails on diff (drift check), so a contract change is always a visible diff in review.
  • A thin hand-written wrapper per client injects the pkg/transport client, adds the ~50-line retry (idempotent GETs only, jittered backoff, max 2 retries, honor Retry-After), decodes the standard error envelope into typed errors, and propagates the request id header.

Boundary. The existing depguard rule stands: cross-service HTTP goes only through pkg/clients. This package is the reason that rule is enforceable.

7.12 pkg/uaierr

Plain words. When a Provider refuses a request, the refusal is itself part of the protocol: the Seeker decides what to do next (retry, re-consent, pick another Provider) by matching on the error code. Providers and Seekers never share a service, so if each side declares its own strings, that decision logic breaks silently. This package is the single declaration of those codes.

Surface.

type Error struct {
    Code       string   // "token_invalid", "scope_not_supported", ...
    Message    string
    HTTPStatus int
}
func (e *Error) Error() string

var (
    TokenInvalid      = &Error{"token_invalid", "...", 401}
    ScopeNotSupported = &Error{"scope_not_supported", "...", 403}
    TierInsufficient  = &Error{"tier_insufficient", "...", 403}
    ConsentRequired   = &Error{"consent_required", "...", 403}
    // reserved, declared but not wired (Phase 2, spec §4.3):
    // DPoPRequired, DPoPProofMismatch, DPoPProofExpired, DPoPAudienceMismatch, DPoPReplay
)

func Encode(e *Error) []byte              // the P2P wire error shape
func Decode(body []byte) (*Error, error)  // map a Provider's error body to a typed error

Notes. The Provider kit returns these from its validate/enforce pipeline (the adopter’s handler rarely touches them directly); the Seeker SDK decodes a Provider’s error body back into a typed error, which is what makes its retry/re-consent logic testable. The Provider kit maps pkg/jwtverify failures to TokenInvalid at the edge, so token failures speak the same code everywhere. Scope note: this package owns the P2P protocol codes only; each control-plane service’s REST error envelope ({error: {code, message, request_id}}) is a service-owned shape defined in its OpenAPI contract and is not duplicated here. The Phase 2 DPoP codes are declared now as reserved names so the seam is visible, exactly like the cnf seam in jwtmint.

7.13 pkg/agentfacts

Plain words. An AgentFacts document is an agent’s public self-description: what it can do, which scopes it serves, its risk-tier ceiling, its compliance posture. Providers publish it at /.well-known/agentfacts.json; Seekers fetch and check it during metadata verification before trusting a discovery hit; the Registry stores only the pointer (facts_ref). AgentFacts is one of the five frozen JSON Schemas, so pkg/schema already embeds its contract — this package gives that schema its working type: build a document from config, parse one, and run the compliance checks.

Surface.

type AgentFacts struct { /* @context, id, name, capabilities[], uai UAIExtension */ }
type UAIExtension struct { /* uai_registration, delegation_scopes, risk_tier_max,
                              india_compliance, consent_requirements, vc_refs */ }

func Build(cfg Config) (*AgentFacts, error)
func Parse(b []byte) (*AgentFacts, error)      // size-capped, strict, schema-validated
func (a *AgentFacts) ComplianceCheck() error   // required uai fields present; risk_tier_max >= 3 requires an insurance VC ref

Notes. Build and Parse validate against the frozen AgentFacts schema via pkg/schema, so a served document and a parsed one are held to the same contract. The Provider kit derives Build’s config from the same source as its Registry self-registration, which is what keeps the served document and the registered record from drifting. This package never fetches: the Seeker retrieves the document through the pkg/transport hardened client and hands the bytes to Parse, keeping the package stateless per principle 4. Phase 2 NANDA federation (the Registry resolving facts_ref outward and verifying the AgentFacts VC signature at ingestion) lands behind these same types.

Consumers. The Provider kit builds and serves it (identity/registration serving); the Seeker SDK’s candidate verification parses and compliance-checks it. The Registry does not consume this package in Phase 1.

8. Cross-cutting security

8.1 Supply chain

The primitives are the highest-value target in the codebase: compromise pkg/jcs or pkg/jwtverify and you compromise every service at once. The supply chain controls are therefore not optional hygiene, they are part of the design.

  • Pinning and integrity. Every dependency is pinned in go.mod; go.sum plus the Go checksum database (sum.golang.org) verify every download. CI builds with -mod=readonly so no build can quietly edit the dependency set. The Go toolchain version is pinned (GOTOOLCHAIN) so builds are reproducible.
  • Vulnerability scanning. govulncheck already gates CI (Registry quality gates); it covers pkg/ and every dependency automatically.
  • Update discipline. Renovate (or Dependabot) proposes weekly bumps; a human merges. Security releases of the wrapped libraries are fast-tracked. No dependency ever floats to latest.
  • Licenses. Checked by eye at the PR that adds a dependency (see the reuse-versus-build section). No license tooling; the tree is deliberately too small to need it.
  • One-entry rule. A new dependency needs a decision-log line here, an owner, and a reason. This keeps the tree small enough that checking it by eye stays realistic.

8.2 Input handling

Everything pkg/ parses can arrive from an adversary (a DID log from a hostile domain, a token from anyone, a VC from an untrusted issuer). The shared rules:

  • Cap before parse. Size limits precede every parse: JCS input, token bytes, DID log and per-line, schema instances. Caps are constants with documented defaults, overridable per call site downward.
  • Strict shapes. Where a structure is frozen, decoding uses DisallowUnknownFields or schema validation; surprise fields are an error, not a shrug. The one exception is DID log parameters, where unknown parameters are recorded for forward compatibility (per spec) but never silently honored.
  • No secondary fetches. Nothing in pkg/ follows a URL found inside parsed data except through the hardened fetcher with the same SSRF posture, and only where the spec requires it.

8.3 Keys and signing

Restating the boundary because it is the single most important security property of this layer: no API in pkg/ accepts private key bytes. Signing takes crypto.Signer. The Trust Server’s KMS/HSM key ring, the DID log authoring path, receipt signing in the Provider kit, and checkpoint signing in the Audit Ledger all satisfy this with handles. Test code uses in-memory ed25519.PrivateKey, which also satisfies crypto.Signer, so tests need no special path.

8.4 Logging and secrets

pkg/ does not log. It returns typed errors with enough context to act on and never embeds raw tokens, full VCs, or DID log bodies in error strings (a DID and a version number are fine; the bytes are not). This keeps the existing log-hygiene gate trivially true for the whole layer, and gitleaks continues to gate the repo.

9. Cross-cutting scalability

  • Stateless by construction. No package holds state, so every service scales horizontally with zero coordination inside pkg/. All state (nonces, rate limits, caches) stays in the services’ Redis behind injected interfaces, per the existing D5/D6 posture.
  • Caching where it pays. Exactly one primitive is expensive and repeated: DID resolution. It gets the verified-state snapshot cache plus singleflight described in the pkg/did section. Nothing else in pkg/ caches; compiled schemas are loaded once at service start and are immutable thereafter (thread-safe validation is a documented property of the library).
  • Budgets. Planning numbers for one core on commodity hardware, enforced as CI benchmarks with generous headroom rather than flaky exact numbers:
OperationBudget
jcs.Canonicalize, 4 KiB documentunder 1 ms
Token verify (jwtverify)under 0.5 ms
Receipt hashunder 1 ms
DID resolve, warm (cache hit)under 1 ms
DID resolve, coldnetwork-bound, 3 s budget, CPU under 10 ms for a 100-entry log

These budgets keep pkg/ invisible inside the Trust Server’s existing P95 < 500 ms token budget and the ledger’s ingest budget.

  • Concurrency. pkg/ functions are safe for concurrent use unless documented otherwise (none are, in Phase 1). Bounded parallelism (the errgroup patterns for parallel VC resolution and parallel upstream fetches) stays in the services, where the bounds are policy; pkg/ just has to be safe underneath them.

10. Versioning, compatibility, and the golden vectors

  • One module. The repository is a single Go module; pkg/ is exported (not under internal/) because the Seeker SDK is an importable library and external adopters must be able to build it, and because pkg/receipt.Verify is deliberately public so anyone can independently verify a receipt. Byte-identical across services falls out of “same module, same commit.”
  • Publication. When the Seeker SDK opens to external adopters, the module gets semantic version tags and the usual Go major-version import discipline. Until then, tags are internal milestones (P-Q5).
  • The vectors are the freeze. pkg/testdata/vectors/ holds golden files: canonical bytes for known inputs, hashes, a minted token with its signing key’s public half, a signed receipt, a valid did.jsonl with its expected resolution result, and the negative cases. CI asserts them byte for byte. The Trust Server’s cross-language requirement (Go mints, Go and Python verify) lives here and extends to JCS and receipts, because canonicalization is where cross-language drift actually happens. Changing any vector is a protocol-visible change: it needs a decision record, same as a frozen schema edit.

11. Testing and conformance

Four layers, matching the repo’s quality-gate buckets:

  1. Unit tests, test-as-you-go, per package, with the negative-path matrices this repo already favors (every hard rule in jwtverify and every pipeline step in did is one test each).
  2. Golden vectors (above), including third-party suites: the RFC 8785 appendix vectors through pkg/jcs, and the DIF interop registry fixtures through pkg/did. Passing someone else’s fixtures is the honest definition of compliant.
  3. Fuzzing. Go native fuzz targets on the parsers that eat attacker bytes: jcs.Canonicalize, the DID log parser, jwtverify token parsing, envelope decoding, agentfacts.Parse, and uaierr.Decode. A short fuzz pass runs in CI; longer runs are a nightly job.
  4. Race and bench. go test -race across pkg/; the benchmark suite backs the budget table.

pkg/ needs no testcontainers: nothing in it touches Postgres or Redis (principle 4). Integration against real backends happens in the services that inject them.

12. CI gates (concrete rules)

Additions to the existing pipeline, stated as enforceable rules:

  • depguard, three new rules.
    1. Only pkg/** may import gowebpki/jcs, lestrrat-go/jwx, santhosh-tekuri/jsonschema, doyensec/safeurl, multiformats/* (wrap-never-expose).
    2. pkg/** may not import services/**, github.com/redis/*, github.com/jackc/pgx*, or any logging framework (stateless, infra-free, log-free).
    3. The existing rules stand: services may not import each other’s internals; cross-service HTTP only via pkg/clients; only cmd/ and infra/ import backends.
    4. services/registry/**, services/trustserver/**, and services/auditledger/** may not import pkg/envelope. The control plane never touches payload envelopes; this rule writes the notary model’s privacy guarantee into CI instead of leaving it to convention.
  • Client drift check. Regenerate pkg/clients from every api/openapi.yaml; fail on diff.
  • Vector job. Golden vectors byte-compared; the Python cross-check runs the same vectors.
  • Existing gates unchanged: golangci-lint (gosec, depguard), govulncheck, gitleaks, coverage.

13. Phase 2 hooks (seams in place, nothing built)

  • Post-quantum and hybrid signatures. The seam is kid plus verification method type (principle 10) and the crypto.Signer boundary. When a suite is chosen, ed25519x grows a sibling, jwtmint/jwtverify extend the algorithm allowlist by exactly one entry, and jwx v4’s composite-signature support (ML-DSA plus classical) is the natural vehicle; nothing else moves.
  • Witness verification in pkg/did (Tier 3/4 gate) and witness co-signing in pkg/did/log.
  • Signed issuer list replacing the embedded file behind the same Trust interface.
  • In-flight token revocation touches jwtverify only as an optional injected check, keeping offline verification the default.
  • jwx v4 migration, evaluated together with the PQC work (P-Q4).
  • SBOM generation (one CI step) only if a MeitY or procurement checklist ever asks exactly what code is in the binary; not before.
  • Watcher service for UAI-operated monitoring of registered DIDs (infrastructure, not pkg/).

14. References