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 thepkg/description. This document is the single normative home forpkg/. 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/uaierrandpkg/agentfacts(review feedback — both had concrete consumers in the Provider kit and Seeker SDK with no home in the first draft), and movesenvelope/receiptconstruction 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 saiddid:webwhile the Audit Ledger and Seeker LLDs saiddid: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:
| Package | One line |
|---|---|
pkg/jcs | Turn any JSON into one exact byte form (RFC 8785), so hashes and signatures match everywhere. |
pkg/ed25519x | Sign and verify with Ed25519, without ever holding raw private key bytes. |
pkg/did | Resolve and verify did:webvh identities, including their full key history. |
pkg/schema | The frozen JSON Schemas, embedded and compiled, plus one validate call. |
pkg/issuers | The governed list of credential issuers UAI trusts, and one Allowed() check. |
pkg/jwtmint | Build and sign delegation tokens (EdDSA). Only the Trust Server calls this. |
pkg/jwtverify | Check delegation tokens offline. Providers and the Audit Ledger call this. |
pkg/envelope | The {context, message} wire envelope, its canonical bytes, and its proof. |
pkg/receipt | Hash and verify UAI receipts, against the key that was valid at signing time. |
pkg/transport | A TLS 1.3 HTTP client (and server config helper) with safe defaults. |
pkg/clients | The only sanctioned cross-service REST clients, generated from each service’s OpenAPI file. |
pkg/uaierr | The canonical protocol error codes Providers return and Seekers match on, as typed errors. |
pkg/agentfacts | Build, 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:
- Drift. The same primitive was specified differently in different LLDs.
pkg/didwas adid:webresolver in two documents and adid:webvhresolver in two others. A byte-identical shared package cannot have four partial, inconsistent specs. - 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.
- 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.
- 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:webvhverifier) or the code is trivially small (the issuer list). - Wrap, never expose. Every third-party library sits behind a
pkg/API that we own. Services importpkg/jcs, nevergowebpki/jcsdirectly. If a dependency dies or a better one appears, we swap it inside one package instead of touching five components. Adepguardrule enforces this: onlypkg/may import the underlying libraries. - 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. - 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 smallCacheinterface that the service’smaininjects.pkg/returns errors; services decide what to log. - Everything takes context. Every network call and every potentially slow operation accepts a
context.Contextand honors cancellation and deadlines. - 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.
- Private keys never enter
pkg/. Anywhere a signature is produced, the API takes acrypto.Signer, not key bytes. KMS and HSM handles satisfycrypto.Signer, so key material stays inside the key store. This also settles the Seeker LLD’s open wording item oncrypto.Signerat the root. - 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.
- 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.
- Algorithm agility through
kid. Nothing inpkg/hardcodes an algorithm beyond the Phase 1 Ed25519 profile. Signature algorithms resolve through thekidand the verification methodtypein 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:
| Package | Registry | Trust Server | Audit Ledger | Seeker SDK | Provider kit |
|---|---|---|---|---|---|
jcs | yes | yes | yes | yes | yes |
ed25519x | yes | yes | yes | yes | yes |
did | yes | yes | yes | yes | yes |
schema | yes | yes | yes | yes | yes |
issuers | yes | yes (Tier 3+ seam) | no | no | no |
jwtmint | no | yes | no | no | no |
jwtverify | no | test only | yes (query auth) | no | yes (offline token check) |
envelope | no | no | no | yes | yes |
receipt | no | no | yes (verify at ingest) | yes (read-back check) | yes (build and sign) |
transport | outbound fetches | outbound fetches | outbound fetches | yes | yes |
clients | no (serves) | yes (one hop to Registry) | no in Phase 1 | yes | yes |
uaierr | no | no | no | yes (match on codes) | yes (return from pipeline) |
agentfacts | no (stores facts_ref only) | no | no | yes (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 intojcsanded25519x;transportis added becauseclientscannot exist without it;uaierr,agentfacts,envelope, andreceiptcarry over from the epic’s original child issues #63, #69, #64, and #68). - Trust Server sub-epic B (#103) builds
jwtmintandjwtverify, inpkg/, to this LLD. Its cross-language token vector becomes part of the golden vector set here. - The Seeker SDK and Provider kit consume
envelopeandreceipt(both list them as uai-foundation dependencies); construction is under issue #7. This also keeps the Audit Ledger — apkg/receiptconsumer 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-epic | Contents | Dev-days |
|---|---|---|
| 7A | jcs wrapper, ed25519x, multiformats helpers, first golden vectors | 2.5 |
| 7B | schema (embedded, offline compile) and issuers | 2 |
| 7C | did resolver and verifier, did:webvh v1.0, interop fixtures | 8 to 10 |
| 7D | did log authoring (service identity publication) | 3 |
| 7E | transport, clients generation, thin retry, drift check, uaierr | 4 |
| 7F | Vector suite, fuzz targets, depguard CI gates | 3 |
| 7G | agentfacts — build/parse + compliance checks | 2 |
| 7H | envelope + receipt — models, canonical bytes, proof sign/verify, historical-key receipt verify | 7 |
| 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.
| Need | Decision | What we use | License | Why this one |
|---|---|---|---|---|
| RFC 8785 canonical JSON | Reuse, wrap | github.com/gowebpki/jcs | Apache-2.0 | The 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. |
| Ed25519 | Stdlib | crypto/ed25519 | Go BSD | Never take third-party code for core signatures when the standard library ships a constant-time, audited implementation. |
| Multibase, multihash | Reuse, wrap | github.com/multiformats/go-multibase, go-multihash | MIT/Apache | did: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, wrap | github.com/lestrrat-go/jwx/v3 | MIT | The 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-12 | Reuse, wrap | github.com/santhosh-tekuri/jsonschema/v6 | Apache-2.0 | Fully 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 fetching | Reuse, wrap | github.com/doyensec/safeurl | Apache-2.0 | A 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 generation | Reuse (tool) | github.com/oapi-codegen/oapi-codegen/v2 | Apache-2.0 | The 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 coalescing | Quasi-stdlib | golang.org/x/sync/singleflight | Go BSD | Collapses concurrent cache misses for the same DID into one fetch. Maintained by the Go team. |
| Retry/backoff in clients | Build (thin) | ~50 lines in pkg/clients | n/a | Jittered 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 verification | Build | in-house, in pkg/did | n/a | This 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 list | Build (trivial) | in-house, in pkg/issuers | n/a | An embedded, governance-maintained allowlist and one lookup function. Too small to need a library. |
| Protocol error codes | Build (trivial) | in-house, in pkg/uaierr | n/a | A declaration file: typed codes and HTTP statuses. Too small to need anything. |
| AgentFacts model | Build (trivial) | in-house, in pkg/agentfacts | n/a | A 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).
- Parse.
did:webvh:{SCID}:{domain}[:{path}]. Strict charset, lowercase method, SCID present and base58btc-shaped. Reject anything else. - Transform. Same DID-to-HTTPS mapping as
did:web, targetingdid.jsonl. HTTPS only. No user-controlled ports in Phase 1. - 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.
- 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.
- 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-2022cryptosuite (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. - 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: truefails 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). /whoisand DID URL path dereferencing: not implemented; UAI has no consumer for them.did:webfallback: none. Nothing is in production, so the resolver isdid:webvhonly and fails closed ondid:webinput (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-Controlfrom 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. kidis required and must resolve to a verification method in the issuer’s DID document; the verifier uses exactly that key.exp,iat,aud,issare 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/v2generates types and client per service fromservices/<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/transportclient, adds the ~50-line retry (idempotent GETs only, jittered backoff, max 2 retries, honorRetry-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.sumplus the Go checksum database (sum.golang.org) verify every download. CI builds with-mod=readonlyso no build can quietly edit the dependency set. The Go toolchain version is pinned (GOTOOLCHAIN) so builds are reproducible. - Vulnerability scanning.
govulncheckalready gates CI (Registry quality gates); it coverspkg/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
DisallowUnknownFieldsor 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/didsection. Nothing else inpkg/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:
| Operation | Budget |
|---|---|
jcs.Canonicalize, 4 KiB document | under 1 ms |
Token verify (jwtverify) | under 0.5 ms |
| Receipt hash | under 1 ms |
| DID resolve, warm (cache hit) | under 1 ms |
| DID resolve, cold | network-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 (theerrgrouppatterns 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 underinternal/) because the Seeker SDK is an importable library and external adopters must be able to build it, and becausepkg/receipt.Verifyis 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 validdid.jsonlwith 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:
- Unit tests, test-as-you-go, per package, with the negative-path matrices this repo already favors (every hard rule in
jwtverifyand every pipeline step indidis one test each). - Golden vectors (above), including third-party suites: the RFC 8785 appendix vectors through
pkg/jcs, and the DIF interop registry fixtures throughpkg/did. Passing someone else’s fixtures is the honest definition of compliant. - Fuzzing. Go native fuzz targets on the parsers that eat attacker bytes:
jcs.Canonicalize, the DID log parser,jwtverifytoken parsing, envelope decoding,agentfacts.Parse, anduaierr.Decode. A short fuzz pass runs in CI; longer runs are a nightly job. - Race and bench.
go test -raceacrosspkg/; 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.
- Only
pkg/**may importgowebpki/jcs,lestrrat-go/jwx,santhosh-tekuri/jsonschema,doyensec/safeurl,multiformats/*(wrap-never-expose). pkg/**may not importservices/**,github.com/redis/*,github.com/jackc/pgx*, or any logging framework (stateless, infra-free, log-free).- The existing rules stand: services may not import each other’s internals; cross-service HTTP only via
pkg/clients; onlycmd/andinfra/import backends. services/registry/**,services/trustserver/**, andservices/auditledger/**may not importpkg/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.
- Only
- Client drift check. Regenerate
pkg/clientsfrom everyapi/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
kidplus verification methodtype(principle 10) and thecrypto.Signerboundary. When a suite is chosen,ed25519xgrows a sibling,jwtmint/jwtverifyextend 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 inpkg/did/log. - Signed issuer list replacing the embedded file behind the same
Trustinterface. - In-flight token revocation touches
jwtverifyonly 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
- did:webvh v1.0 specification: https://identity.foundation/didwebvh
- did:webvh implementations and interop registry: https://didwebvh.info/latest/implementations/ and https://identity.foundation/didwebvh-implementations/
- Reference implementation used as design reference (Rust): https://github.com/decentralized-identity/didwebvh-rs
- Go proof-of-concept (pre-v1.0, not adopted): https://github.com/nuts-foundation/trustdidweb-go
- swiyu (Swiss e-ID) technology stack and did:webvh profile: https://swiyu-admin-ch.github.io/technology-stack/
- RFC 8785 (JCS): https://www.rfc-editor.org/rfc/rfc8785 ; Go library: https://github.com/gowebpki/jcs
- jwx (JOSE for Go): https://github.com/lestrrat-go/jwx
- JSON Schema validator: https://github.com/santhosh-tekuri/jsonschema
- SSRF protection: https://github.com/doyensec/safeurl
- OpenAPI codegen: https://github.com/oapi-codegen/oapi-codegen
- Multiformats: https://github.com/multiformats/go-multibase , https://github.com/multiformats/go-multihash