diff --git a/internal/adapter/anchor/did/verification_method.go b/internal/adapter/anchor/did/verification_method.go new file mode 100644 index 0000000..58c8d19 --- /dev/null +++ b/internal/adapter/anchor/did/verification_method.go @@ -0,0 +1,172 @@ +package did + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/godaddy/ans/internal/domain" +) + +// selectVerificationMethodJWK picks the active verification method +// from the DID document and returns its public key in JWK form. +// +// Selection order per anchor-0b-did.md §2: +// 1. Methods referenced from the assertionMethod array (used to +// sign registration events). +// 2. If none, methods referenced from authentication. +// 3. Among candidates, choose the one with the most recent +// updated/created timestamp; ties broken lexicographically by +// id so the choice is deterministic. +// +// Verification method references can be either embedded objects or +// fragment IDs pointing into the document's verificationMethod +// array. Both shapes are handled. +func selectVerificationMethodJWK(doc *didDocument) ([]byte, error) { + candidates := collectByReferenceList(doc, doc.AssertionMethod) + if len(candidates) == 0 { + candidates = collectByReferenceList(doc, doc.Authentication) + } + if len(candidates) == 0 { + // Fall back to the first verificationMethod entry the + // document declares, if any. A document with no + // verificationMethod at all is unusable. + if len(doc.VerificationMethod) == 0 { + return nil, domain.NewValidationError( + "DID_NO_VERIFICATION_METHOD", + "DID document has no usable verification method", + ) + } + candidates = doc.VerificationMethod + } + + chosen := pickMostRecent(candidates) + jwk, err := verificationMethodToJWK(chosen) + if err != nil { + return nil, err + } + return jwk, nil +} + +// collectByReferenceList resolves a list of verification-method +// references into concrete verificationMethod objects. References +// are either string fragment IDs (e.g. "#key-1") or embedded +// objects. +func collectByReferenceList(doc *didDocument, refs []json.RawMessage) []verificationMethod { + out := make([]verificationMethod, 0, len(refs)) + for _, raw := range refs { + // Try string first (fragment reference). + var asString string + if err := json.Unmarshal(raw, &asString); err == nil { + if vm := findVerificationMethod(doc, asString); vm != nil { + out = append(out, *vm) + } + continue + } + // Otherwise, embedded object. + var vm verificationMethod + if err := json.Unmarshal(raw, &vm); err == nil && vm.ID != "" { + out = append(out, vm) + } + } + return out +} + +// findVerificationMethod resolves a verification-method reference +// (full URI or fragment ID) against doc.VerificationMethod. +func findVerificationMethod(doc *didDocument, reference string) *verificationMethod { + wantSuffix := reference + if strings.HasPrefix(reference, "#") { + wantSuffix = reference[1:] + } + for i := range doc.VerificationMethod { + vm := &doc.VerificationMethod[i] + if vm.ID == reference { + return vm + } + if idx := strings.Index(vm.ID, "#"); idx >= 0 && vm.ID[idx+1:] == wantSuffix { + return vm + } + } + return nil +} + +// pickMostRecent selects the candidate with the newest +// updated/created timestamp; ties broken lexicographically by id. +func pickMostRecent(candidates []verificationMethod) verificationMethod { + if len(candidates) == 1 { + return candidates[0] + } + sorted := make([]verificationMethod, len(candidates)) + copy(sorted, candidates) + sort.SliceStable(sorted, func(i, j int) bool { + ti := timestampOf(sorted[i]) + tj := timestampOf(sorted[j]) + if ti != tj { + return ti > tj // descending: newest first + } + return sorted[i].ID < sorted[j].ID + }) + return sorted[0] +} + +// timestampOf returns the verification method's effective +// timestamp string for comparison; updated wins over created. +func timestampOf(vm verificationMethod) string { + if vm.Updated != "" { + return vm.Updated + } + return vm.Created +} + +// verificationMethodToJWK converts a verification method's public +// key to the JWK byte form ANS-0 IdentityClaim expects. Three +// encodings are admitted per anchor-0b-did.md §3.2 step 8: +// - publicKeyJwk: pass through after re-canonicalizing the JSON +// so the bytes are stable for downstream hashing. +// - publicKeyMultibase: not yet supported; returns +// DID_KEY_MULTIBASE_NOT_IMPLEMENTED. Slice 2.1 will add +// multicodec key-type prefix decoding for the four common +// types (Ed25519, X25519, secp256k1, P-256). +// - publicKeyPem: not yet supported; returns +// DID_KEY_PEM_NOT_IMPLEMENTED. +// +// A verification method that supplies none of the three forms is +// rejected with DID_KEY_MISSING. +func verificationMethodToJWK(vm verificationMethod) ([]byte, error) { + switch { + case len(vm.PublicKeyJwk) > 0: + // Re-canonicalize through json.Marshal so the byte form is + // stable regardless of how the source serialized. + var jwkValue interface{} + if err := json.Unmarshal(vm.PublicKeyJwk, &jwkValue); err != nil { + return nil, domain.NewValidationError( + "DID_KEY_BAD_JWK", + "verification method's publicKeyJwk is not valid JSON", + ) + } + out, err := json.Marshal(jwkValue) + if err != nil { + return nil, domain.NewInternalError( + "DID_KEY_REMARSHAL", "remarshal publicKeyJwk", err, + ) + } + return out, nil + case vm.PublicKeyMultib != "": + return nil, domain.NewValidationError( + "DID_KEY_MULTIBASE_NOT_IMPLEMENTED", + fmt.Sprintf("publicKeyMultibase decoding not implemented in this slice (vm id=%s)", vm.ID), + ) + case vm.PublicKeyPem != "": + return nil, domain.NewValidationError( + "DID_KEY_PEM_NOT_IMPLEMENTED", + fmt.Sprintf("publicKeyPem decoding not implemented in this slice (vm id=%s)", vm.ID), + ) + default: + return nil, domain.NewValidationError( + "DID_KEY_MISSING", + fmt.Sprintf("verification method id=%s carries no publicKey* field", vm.ID), + ) + } +} diff --git a/internal/adapter/anchor/did/web.go b/internal/adapter/anchor/did/web.go new file mode 100644 index 0000000..6de2c48 --- /dev/null +++ b/internal/adapter/anchor/did/web.go @@ -0,0 +1,347 @@ +// Package did implements the ANS-0 §0.B DID anchor profile. +// +// The package's first concrete profile is did:web, which is also +// the priority sub-profile per docs/profiles/anchor-0b-did.md §1. +// Other DID methods (did:plc, did:key, did:ethr/did:pkh, did:ion) +// will land as additional Resolver implementations in this package +// once the did:web shape is proven and the SDK + CLI surface lands. +package did + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/godaddy/ans/internal/domain" +) + +// WebProfileID is the canonical identifier for the did:web profile. +const WebProfileID = "0.B-did:web" + +// freshnessBudget is the maximum age of a cached resolution for +// did:web before the resolver MUST re-resolve. Matches the budget +// recommended in docs/profiles/anchor-0b-did.md §3.5. +const freshnessBudget = 24 * time.Hour + +// Web resolves did:web identifiers by fetching the published DID +// document over HTTPS, validating it against the W3C DID Core +// requirements, selecting the active verification method, and +// shaping an IdentityClaim. Resolution semantics follow +// docs/profiles/anchor-0b-did.md §3. +// +// The resolver is deliberately stateless. A caller wiring rate +// limits, response caching, or HTTP-client tuning configures the +// http.Client passed in via WithHTTPClient. The resolver's verbatim +// fetch + validate + shape pipeline is short enough to keep in one +// place; future profiles share the JWK conversion and verification- +// method selection helpers but each owns its own resolution call. +type Web struct { + client *http.Client + clock func() time.Time +} + +// NewWeb constructs a Web resolver with sensible defaults: a 10s +// timeout HTTP client, follow up to 5 redirects (validated below +// for cross-domain rejection), reject TLS below 1.2 implicitly via +// the standard library defaults. +func NewWeb() *Web { + return &Web{ + client: &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: webRedirectPolicy, + }, + clock: time.Now, + } +} + +// WithHTTPClient returns a copy of the resolver using the provided +// http.Client. Tests inject a client whose Transport routes to an +// httptest.Server so the resolver hits a stable known target. +func (w *Web) WithHTTPClient(c *http.Client) *Web { + return &Web{client: c, clock: w.clock} +} + +// WithClock returns a copy of the resolver with a deterministic +// clock. Tests use this so IssuedAt is reproducible. +func (w *Web) WithClock(clock func() time.Time) *Web { + return &Web{client: w.client, clock: clock} +} + +// SupportedProfiles satisfies port.AnchorResolver. +func (w *Web) SupportedProfiles() []string { + return []string{WebProfileID} +} + +// Resolve implements port.AnchorResolver for did:web. The pipeline +// matches anchor-0b-did.md §3.2: +// +// 1. Lexical validation (DID URI shape, did:web method). +// 2. Construct the resolution URL per the path-component mapping. +// 3. HTTPS GET with Accept: application/did+json, application/json. +// 4. Validate response status, content type. +// 5. Parse as DID document; validate id field. +// 6. Select active verification method (assertionMethod first). +// 7. Convert verification method's public key to JWK form. +// 8. Construct IdentityClaim with IssuedAt = now. +func (w *Web) Resolve(ctx context.Context, input string) (*domain.IdentityClaim, error) { + domainPart, pathComponents, err := parseDIDWeb(input) + if err != nil { + return nil, err + } + + resolutionURL := buildResolutionURL(domainPart, pathComponents) + + doc, err := w.fetchDIDDocument(ctx, resolutionURL) + if err != nil { + return nil, err + } + + canonicalDID := canonicalizeDID(domainPart, pathComponents) + if !strings.EqualFold(doc.ID, canonicalDID) { + return nil, domain.NewValidationError( + "DID_DOCUMENT_ID_MISMATCH", + fmt.Sprintf("DID document id %q does not match resolved DID %q", doc.ID, canonicalDID), + ) + } + + jwk, err := selectVerificationMethodJWK(doc) + if err != nil { + return nil, err + } + + now := w.clock().UTC() + expires := now.Add(freshnessBudget) + + return &domain.IdentityClaim{ + AnchorType: domain.AnchorTypeDID, + ResolvedID: canonicalDID, + PublicKeyJWK: jwk, + MetadataURL: resolutionURL, + IssuedAt: now, + ExpiresAt: expires, + }, nil +} + +// webRedirectPolicy enforces same-effective-second-level-domain +// redirects per anchor-0b-did.md §3.2 step 3. Up to 5 redirects +// allowed; any cross-domain redirect fails closed with +// DID_REDIRECT_DOMAIN_MISMATCH. +func webRedirectPolicy(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return errors.New("DID_TOO_MANY_REDIRECTS: stopped after 5 redirects") + } + if len(via) == 0 { + return nil + } + original := via[0].URL.Host + current := req.URL.Host + if !sameEffectiveDomain(original, current) { + return fmt.Errorf("DID_REDIRECT_DOMAIN_MISMATCH: %s redirected to %s", original, current) + } + return nil +} + +// sameEffectiveDomain compares two hostnames by their last two +// labels. This is a deliberately conservative same-site check; it +// does not consult the public-suffix list because that introduces a +// runtime data dependency. A future refinement can wire in the +// PSL for ccTLD-aware matching, but the current rule errors on the +// side of refusing valid co-publisher redirects rather than +// admitting cross-domain ones. +func sameEffectiveDomain(a, b string) bool { + aParts := strings.Split(strings.ToLower(stripPort(a)), ".") + bParts := strings.Split(strings.ToLower(stripPort(b)), ".") + if len(aParts) < 2 || len(bParts) < 2 { + return strings.EqualFold(a, b) + } + aRoot := aParts[len(aParts)-2] + "." + aParts[len(aParts)-1] + bRoot := bParts[len(bParts)-2] + "." + bParts[len(bParts)-1] + return aRoot == bRoot +} + +func stripPort(host string) string { + if i := strings.LastIndex(host, ":"); i > 0 { + return host[:i] + } + return host +} + +// parseDIDWeb splits a did:web URI into (domain, path components). +// Returns the domain in canonical lowercase form and a slice of +// URL-decoded path components. +// +// did:web:(:)* +// - did:web:agent.example.com -> ("agent.example.com", []) +// - did:web:agent.example.com:agents:billing -> ("agent.example.com", ["agents", "billing"]) +// +// Per the W3C method spec, percent-encoded colons in the domain are +// preserved as port indicators; this resolver does not yet support +// non-default ports and rejects domains carrying explicit ports +// with DID_PORT_NOT_SUPPORTED. +func parseDIDWeb(input string) (string, []string, error) { + trimmed := strings.TrimSpace(input) + if !strings.HasPrefix(trimmed, "did:web:") { + return "", nil, domain.NewValidationError( + "DID_BAD_FORMAT", + "expected did:web prefix", + ) + } + rest := strings.TrimPrefix(trimmed, "did:web:") + if rest == "" { + return "", nil, domain.NewValidationError( + "DID_BAD_FORMAT", + "did:web URI missing identifier body", + ) + } + parts := strings.Split(rest, ":") + domainPart := strings.ToLower(parts[0]) + if domainPart == "" { + return "", nil, domain.NewValidationError( + "DID_BAD_FORMAT", + "did:web URI missing domain component", + ) + } + if strings.Contains(domainPart, "%3a") || strings.Contains(domainPart, "%3A") { + return "", nil, domain.NewValidationError( + "DID_PORT_NOT_SUPPORTED", + "did:web with explicit port (percent-encoded colon) is not supported", + ) + } + pathComponents := make([]string, 0, len(parts)-1) + for _, raw := range parts[1:] { + decoded, err := url.PathUnescape(raw) + if err != nil { + return "", nil, domain.NewValidationError( + "DID_BAD_FORMAT", + "did:web path component is not URL-encoded: "+raw, + ) + } + if decoded == "" { + return "", nil, domain.NewValidationError( + "DID_BAD_FORMAT", + "did:web path component is empty (consecutive colons)", + ) + } + pathComponents = append(pathComponents, decoded) + } + return domainPart, pathComponents, nil +} + +// buildResolutionURL constructs the HTTPS URL the resolver fetches +// to retrieve the DID document. Per anchor-0b-did.md §3.2 step 2: +// +// - empty path components: https:///.well-known/did.json +// - non-empty path components: https://///.../did.json +func buildResolutionURL(domainPart string, pathComponents []string) string { + if len(pathComponents) == 0 { + return "https://" + domainPart + "/.well-known/did.json" + } + escaped := make([]string, 0, len(pathComponents)) + for _, c := range pathComponents { + escaped = append(escaped, url.PathEscape(c)) + } + return "https://" + domainPart + "/" + strings.Join(escaped, "/") + "/did.json" +} + +// canonicalizeDID returns the lowercase did:web URI form for the +// IdentityClaim's ResolvedID. The fragment (verification-method +// selector) is dropped per anchor-0b-did.md §2. +func canonicalizeDID(domainPart string, pathComponents []string) string { + if len(pathComponents) == 0 { + return "did:web:" + domainPart + } + escaped := make([]string, 0, len(pathComponents)) + for _, c := range pathComponents { + escaped = append(escaped, url.PathEscape(c)) + } + return "did:web:" + domainPart + ":" + strings.Join(escaped, ":") +} + +// fetchDIDDocument issues the HTTPS GET, validates the response, and +// returns the parsed DID document. +func (w *Web) fetchDIDDocument(ctx context.Context, resolutionURL string) (*didDocument, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, resolutionURL, nil) + if err != nil { + return nil, domain.NewInternalError( + "DID_REQUEST_BUILD", + "build HTTP request for did:web resolution", + err, + ) + } + req.Header.Set("Accept", "application/did+json, application/json") + + resp, err := w.client.Do(req) + if err != nil { + return nil, domain.NewValidationError( + "DID_RESOLUTION_FAILED", + "HTTPS GET failed: "+err.Error(), + ) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, domain.NewValidationError( + "DID_RESOLUTION_FAILED", + fmt.Sprintf("HTTPS GET returned status %d", resp.StatusCode), + ) + } + + contentType := strings.ToLower(strings.SplitN(resp.Header.Get("Content-Type"), ";", 2)[0]) + contentType = strings.TrimSpace(contentType) + if contentType != "application/did+json" && contentType != "application/json" { + return nil, domain.NewValidationError( + "DID_BAD_CONTENT_TYPE", + "expected application/did+json or application/json, got "+contentType, + ) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1MiB cap + if err != nil { + return nil, domain.NewValidationError( + "DID_RESOLUTION_FAILED", + "read response body: "+err.Error(), + ) + } + + var doc didDocument + if err := json.Unmarshal(body, &doc); err != nil { + return nil, domain.NewValidationError( + "DID_DOCUMENT_PARSE", + "DID document is not valid JSON: "+err.Error(), + ) + } + if doc.ID == "" { + return nil, domain.NewValidationError( + "DID_DOCUMENT_PARSE", + "DID document missing required id field", + ) + } + return &doc, nil +} + +// didDocument is the minimal subset of W3C DID Core §5 fields the +// resolver needs. Adding more fields here is safe; JSON decode +// ignores anything not declared. +type didDocument struct { + ID string `json:"id"` + VerificationMethod []verificationMethod `json:"verificationMethod,omitempty"` + AssertionMethod []json.RawMessage `json:"assertionMethod,omitempty"` + Authentication []json.RawMessage `json:"authentication,omitempty"` +} + +type verificationMethod struct { + ID string `json:"id"` + Type string `json:"type"` + Controller string `json:"controller"` + PublicKeyJwk json.RawMessage `json:"publicKeyJwk,omitempty"` + PublicKeyMultib string `json:"publicKeyMultibase,omitempty"` + PublicKeyPem string `json:"publicKeyPem,omitempty"` + Created string `json:"created,omitempty"` + Updated string `json:"updated,omitempty"` +} diff --git a/internal/adapter/anchor/did/web_test.go b/internal/adapter/anchor/did/web_test.go new file mode 100644 index 0000000..df16213 --- /dev/null +++ b/internal/adapter/anchor/did/web_test.go @@ -0,0 +1,618 @@ +package did + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/godaddy/ans/internal/domain" +) + +// fixture and httptest helpers -------------------------------------- + +const sampleJWK = `{"kty":"OKP","crv":"Ed25519","x":"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"}` + +type docFixture struct { + id string + verificationMethod []map[string]interface{} + assertionMethod []interface{} + authentication []interface{} +} + +func (f docFixture) marshal(t *testing.T) []byte { + t.Helper() + out := map[string]interface{}{ + "@context": []string{"https://www.w3.org/ns/did/v1"}, + "id": f.id, + } + if len(f.verificationMethod) > 0 { + out["verificationMethod"] = f.verificationMethod + } + if len(f.assertionMethod) > 0 { + out["assertionMethod"] = f.assertionMethod + } + if len(f.authentication) > 0 { + out["authentication"] = f.authentication + } + b, err := json.Marshal(out) + if err != nil { + t.Fatalf("marshal fixture: %v", err) + } + return b +} + +// resolver tests ----------------------------------------------------- + +func mustClock(t time.Time) func() time.Time { + return func() time.Time { return t } +} + +func TestParseDIDWeb_Canonical(t *testing.T) { + cases := []struct { + name string + input string + wantDomain string + wantPath []string + wantErr bool + wantErrCode string + }{ + { + name: "domain only", + input: "did:web:agent.example.com", + wantDomain: "agent.example.com", + wantPath: []string{}, + }, + { + name: "lowercases domain", + input: "did:web:Agent.Example.COM", + wantDomain: "agent.example.com", + wantPath: []string{}, + }, + { + name: "with path components", + input: "did:web:agent.example.com:agents:billing", + wantDomain: "agent.example.com", + wantPath: []string{"agents", "billing"}, + }, + { + name: "missing prefix", + input: "agent.example.com", + wantErr: true, + wantErrCode: "DID_BAD_FORMAT", + }, + { + name: "wrong method", + input: "did:plc:abc", + wantErr: true, + wantErrCode: "DID_BAD_FORMAT", + }, + { + name: "empty body", + input: "did:web:", + wantErr: true, + wantErrCode: "DID_BAD_FORMAT", + }, + { + name: "consecutive colons", + input: "did:web:agent.example.com::billing", + wantErr: true, + wantErrCode: "DID_BAD_FORMAT", + }, + { + name: "explicit port (percent-encoded colon) not supported", + input: "did:web:agent.example.com%3A8443", + wantErr: true, + wantErrCode: "DID_PORT_NOT_SUPPORTED", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + d, p, err := parseDIDWeb(c.input) + if c.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) { + t.Fatalf("error is not *domain.Error: %T", err) + } + if dErr.Code != c.wantErrCode { + t.Errorf("code = %q, want %q", dErr.Code, c.wantErrCode) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if d != c.wantDomain { + t.Errorf("domain = %q, want %q", d, c.wantDomain) + } + if !equalSlices(p, c.wantPath) { + t.Errorf("path = %v, want %v", p, c.wantPath) + } + }) + } +} + +func TestBuildResolutionURL(t *testing.T) { + cases := []struct { + name string + dom string + path []string + want string + }{ + { + name: "well-known path when no path components", + dom: "agent.example.com", + want: "https://agent.example.com/.well-known/did.json", + }, + { + name: "path-based when components present", + dom: "agent.example.com", + path: []string{"agents", "billing"}, + want: "https://agent.example.com/agents/billing/did.json", + }, + { + name: "URL-escapes path components", + dom: "agent.example.com", + path: []string{"a b", "c"}, + want: "https://agent.example.com/a%20b/c/did.json", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := buildResolutionURL(c.dom, c.path) + if got != c.want { + t.Errorf("got %q, want %q", got, c.want) + } + }) + } +} + +func TestSelectVerificationMethodJWK_AssertionMethodWins(t *testing.T) { + doc := &didDocument{ + ID: "did:web:agent.example.com", + VerificationMethod: []verificationMethod{ + { + ID: "did:web:agent.example.com#auth-only", + Type: "Ed25519VerificationKey2020", + PublicKeyJwk: json.RawMessage(`{"kty":"OKP","crv":"Ed25519","x":"AUTH"}`), + }, + { + ID: "did:web:agent.example.com#assertion", + Type: "Ed25519VerificationKey2020", + PublicKeyJwk: json.RawMessage(`{"kty":"OKP","crv":"Ed25519","x":"ASSERT"}`), + }, + }, + AssertionMethod: []json.RawMessage{json.RawMessage(`"#assertion"`)}, + Authentication: []json.RawMessage{json.RawMessage(`"#auth-only"`)}, + } + jwk, err := selectVerificationMethodJWK(doc) + if err != nil { + t.Fatalf("err: %v", err) + } + if !strings.Contains(string(jwk), `"x":"ASSERT"`) { + t.Errorf("expected assertionMethod's key, got: %s", jwk) + } +} + +func TestSelectVerificationMethodJWK_FallsBackToAuthentication(t *testing.T) { + doc := &didDocument{ + ID: "did:web:agent.example.com", + VerificationMethod: []verificationMethod{ + { + ID: "did:web:agent.example.com#auth", + Type: "Ed25519VerificationKey2020", + PublicKeyJwk: json.RawMessage(`{"kty":"OKP","crv":"Ed25519","x":"AUTH"}`), + }, + }, + Authentication: []json.RawMessage{json.RawMessage(`"#auth"`)}, + } + jwk, err := selectVerificationMethodJWK(doc) + if err != nil { + t.Fatalf("err: %v", err) + } + if !strings.Contains(string(jwk), `"x":"AUTH"`) { + t.Errorf("expected authentication key, got: %s", jwk) + } +} + +func TestSelectVerificationMethodJWK_PicksMostRecent(t *testing.T) { + doc := &didDocument{ + ID: "did:web:agent.example.com", + VerificationMethod: []verificationMethod{ + { + ID: "did:web:agent.example.com#k-old", + PublicKeyJwk: json.RawMessage(`{"kty":"OKP","crv":"Ed25519","x":"OLD"}`), + Updated: "2026-01-01T00:00:00Z", + }, + { + ID: "did:web:agent.example.com#k-new", + PublicKeyJwk: json.RawMessage(`{"kty":"OKP","crv":"Ed25519","x":"NEW"}`), + Updated: "2026-05-01T00:00:00Z", + }, + }, + AssertionMethod: []json.RawMessage{ + json.RawMessage(`"#k-old"`), + json.RawMessage(`"#k-new"`), + }, + } + jwk, err := selectVerificationMethodJWK(doc) + if err != nil { + t.Fatalf("err: %v", err) + } + if !strings.Contains(string(jwk), `"x":"NEW"`) { + t.Errorf("expected newest key, got: %s", jwk) + } +} + +func TestSelectVerificationMethodJWK_EmbeddedObject(t *testing.T) { + embedded := `{"id":"did:web:agent.example.com#k-1","type":"Ed25519VerificationKey2020","controller":"did:web:agent.example.com","publicKeyJwk":{"kty":"OKP","crv":"Ed25519","x":"EMBED"}}` + doc := &didDocument{ + ID: "did:web:agent.example.com", + AssertionMethod: []json.RawMessage{json.RawMessage(embedded)}, + } + jwk, err := selectVerificationMethodJWK(doc) + if err != nil { + t.Fatalf("err: %v", err) + } + if !strings.Contains(string(jwk), `"x":"EMBED"`) { + t.Errorf("expected embedded key, got: %s", jwk) + } +} + +func TestVerificationMethodToJWK_UnsupportedShapes(t *testing.T) { + cases := []struct { + name string + vm verificationMethod + wantCode string + }{ + { + name: "multibase not implemented", + vm: verificationMethod{ID: "k-mb", PublicKeyMultib: "z6Mk..."}, + wantCode: "DID_KEY_MULTIBASE_NOT_IMPLEMENTED", + }, + { + name: "pem not implemented", + vm: verificationMethod{ID: "k-pem", PublicKeyPem: "-----BEGIN..."}, + wantCode: "DID_KEY_PEM_NOT_IMPLEMENTED", + }, + { + name: "no key at all", + vm: verificationMethod{ID: "k-empty"}, + wantCode: "DID_KEY_MISSING", + }, + { + name: "malformed JWK", + vm: verificationMethod{ID: "k-bad", PublicKeyJwk: json.RawMessage(`{not json}`)}, + wantCode: "DID_KEY_BAD_JWK", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := verificationMethodToJWK(c.vm) + if err == nil { + t.Fatal("expected error, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != c.wantCode { + t.Errorf("expected code %q, got %v", c.wantCode, err) + } + }) + } +} + +func TestSameEffectiveDomain(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"agent.example.com", "other.example.com", true}, + {"agent.example.com", "evil.example.org", false}, + {"agent.example.com:443", "other.example.com:8080", true}, + {"localhost", "localhost", true}, + } + for _, c := range cases { + got := sameEffectiveDomain(c.a, c.b) + if got != c.want { + t.Errorf("sameEffectiveDomain(%q, %q) = %v, want %v", c.a, c.b, got, c.want) + } + } +} + +func TestWeb_Resolve_HappyPath(t *testing.T) { + fixed := time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC) + doc := docFixture{ + id: "did:web:agent.example.com", + verificationMethod: []map[string]interface{}{ + { + "id": "did:web:agent.example.com#k-1", + "type": "Ed25519VerificationKey2020", + "controller": "did:web:agent.example.com", + "publicKeyJwk": json.RawMessage(sampleJWK), + }, + }, + assertionMethod: []interface{}{"#k-1"}, + }.marshal(t) + + // httptest server stand-in for the agent's HTTPS endpoint. + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/did.json" { + t.Errorf("path = %q, want /.well-known/did.json", r.URL.Path) + } + accept := r.Header.Get("Accept") + if !strings.Contains(accept, "application/did+json") { + t.Errorf("Accept header missing did+json, got %q", accept) + } + w.Header().Set("Content-Type", "application/did+json") + _, _ = w.Write(doc) + })) + defer server.Close() + + resolver := NewWeb().WithClock(mustClock(fixed)).WithHTTPClient(server.Client()) + + // Override the http.Client transport to route the resolver's + // https://agent.example.com URL to the httptest server. + resolver.client = newRoutingClient(server) + + claim, err := resolver.Resolve(context.Background(), "did:web:agent.example.com") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if claim.AnchorType != domain.AnchorTypeDID { + t.Errorf("AnchorType = %q, want did", claim.AnchorType) + } + if claim.ResolvedID != "did:web:agent.example.com" { + t.Errorf("ResolvedID = %q", claim.ResolvedID) + } + if !claim.IssuedAt.Equal(fixed) { + t.Errorf("IssuedAt = %v, want %v", claim.IssuedAt, fixed) + } + if claim.MetadataURL != "https://agent.example.com/.well-known/did.json" { + t.Errorf("MetadataURL = %q", claim.MetadataURL) + } + if !strings.Contains(string(claim.PublicKeyJWK), `"x":"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"`) { + t.Errorf("unexpected JWK: %s", claim.PublicKeyJWK) + } +} + +func TestWeb_Resolve_DIDDocumentIDMismatch(t *testing.T) { + doc := docFixture{ + id: "did:web:other.example.com", // wrong ID + verificationMethod: []map[string]interface{}{ + { + "id": "did:web:other.example.com#k-1", + "type": "Ed25519VerificationKey2020", + "publicKeyJwk": json.RawMessage(sampleJWK), + }, + }, + assertionMethod: []interface{}{"#k-1"}, + }.marshal(t) + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/did+json") + _, _ = w.Write(doc) + })) + defer server.Close() + + resolver := NewWeb() + resolver.client = newRoutingClient(server) + + _, err := resolver.Resolve(context.Background(), "did:web:agent.example.com") + if err == nil { + t.Fatal("expected DID_DOCUMENT_ID_MISMATCH, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "DID_DOCUMENT_ID_MISMATCH" { + t.Errorf("expected DID_DOCUMENT_ID_MISMATCH, got %v", err) + } +} + +func TestWeb_Resolve_NotFound(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + defer server.Close() + + resolver := NewWeb() + resolver.client = newRoutingClient(server) + + _, err := resolver.Resolve(context.Background(), "did:web:agent.example.com") + if err == nil { + t.Fatal("expected DID_RESOLUTION_FAILED, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "DID_RESOLUTION_FAILED" { + t.Errorf("expected DID_RESOLUTION_FAILED, got %v", err) + } +} + +func TestWeb_Resolve_BadContentType(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte("")) + })) + defer server.Close() + + resolver := NewWeb() + resolver.client = newRoutingClient(server) + + _, err := resolver.Resolve(context.Background(), "did:web:agent.example.com") + if err == nil { + t.Fatal("expected DID_BAD_CONTENT_TYPE, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "DID_BAD_CONTENT_TYPE" { + t.Errorf("expected DID_BAD_CONTENT_TYPE, got %v", err) + } +} + +func TestWeb_SupportedProfiles(t *testing.T) { + got := NewWeb().SupportedProfiles() + if len(got) != 1 || got[0] != WebProfileID { + t.Errorf("SupportedProfiles = %v", got) + } +} + +func TestWeb_Resolve_BadJSON(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/did+json") + _, _ = w.Write([]byte(`{not json}`)) + })) + defer server.Close() + + resolver := NewWeb() + resolver.client = newRoutingClient(server) + + _, err := resolver.Resolve(context.Background(), "did:web:agent.example.com") + if err == nil { + t.Fatal("expected DID_DOCUMENT_PARSE, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "DID_DOCUMENT_PARSE" { + t.Errorf("expected DID_DOCUMENT_PARSE, got %v", err) + } +} + +func TestWeb_Resolve_MissingID(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/did+json") + _, _ = w.Write([]byte(`{"@context":"https://www.w3.org/ns/did/v1"}`)) + })) + defer server.Close() + + resolver := NewWeb() + resolver.client = newRoutingClient(server) + + _, err := resolver.Resolve(context.Background(), "did:web:agent.example.com") + if err == nil { + t.Fatal("expected DID_DOCUMENT_PARSE, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "DID_DOCUMENT_PARSE" { + t.Errorf("expected DID_DOCUMENT_PARSE, got %v", err) + } +} + +func TestWeb_Resolve_PathComponents(t *testing.T) { + doc := docFixture{ + id: "did:web:agent.example.com:agents:billing", + verificationMethod: []map[string]interface{}{ + { + "id": "did:web:agent.example.com:agents:billing#k-1", + "type": "Ed25519VerificationKey2020", + "publicKeyJwk": json.RawMessage(sampleJWK), + }, + }, + assertionMethod: []interface{}{"#k-1"}, + }.marshal(t) + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/agents/billing/did.json" { + t.Errorf("path = %q, want /agents/billing/did.json", r.URL.Path) + } + w.Header().Set("Content-Type", "application/did+json") + _, _ = w.Write(doc) + })) + defer server.Close() + + resolver := NewWeb() + resolver.client = newRoutingClient(server) + + claim, err := resolver.Resolve(context.Background(), "did:web:agent.example.com:agents:billing") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if claim.ResolvedID != "did:web:agent.example.com:agents:billing" { + t.Errorf("ResolvedID = %q", claim.ResolvedID) + } + if claim.MetadataURL != "https://agent.example.com/agents/billing/did.json" { + t.Errorf("MetadataURL = %q", claim.MetadataURL) + } +} + +func TestWeb_Resolve_NoVerificationMethod(t *testing.T) { + doc := docFixture{ + id: "did:web:agent.example.com", + // No verificationMethod, no assertionMethod, no authentication. + }.marshal(t) + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/did+json") + _, _ = w.Write(doc) + })) + defer server.Close() + + resolver := NewWeb() + resolver.client = newRoutingClient(server) + + _, err := resolver.Resolve(context.Background(), "did:web:agent.example.com") + if err == nil { + t.Fatal("expected DID_NO_VERIFICATION_METHOD, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "DID_NO_VERIFICATION_METHOD" { + t.Errorf("expected DID_NO_VERIFICATION_METHOD, got %v", err) + } +} + +func TestWebRedirectPolicy_TooManyRedirects(t *testing.T) { + // Build 5 prior requests; the 6th hop trips the limit. + via := make([]*http.Request, 5) + for i := range via { + req, _ := http.NewRequest(http.MethodGet, "https://agent.example.com/x", nil) + via[i] = req + } + req, _ := http.NewRequest(http.MethodGet, "https://agent.example.com/y", nil) + if err := webRedirectPolicy(req, via); err == nil { + t.Error("expected too-many-redirects error") + } +} + +func TestWebRedirectPolicy_CrossDomainRejected(t *testing.T) { + original, _ := http.NewRequest(http.MethodGet, "https://agent.example.com/x", nil) + via := []*http.Request{original} + cross, _ := http.NewRequest(http.MethodGet, "https://evil.example.org/x", nil) + if err := webRedirectPolicy(cross, via); err == nil { + t.Error("expected cross-domain redirect error") + } +} + +func TestWebRedirectPolicy_SameSiteAllowed(t *testing.T) { + original, _ := http.NewRequest(http.MethodGet, "https://agent.example.com/x", nil) + via := []*http.Request{original} + same, _ := http.NewRequest(http.MethodGet, "https://www.example.com/x", nil) + if err := webRedirectPolicy(same, via); err != nil { + t.Errorf("expected same-domain redirect to pass, got %v", err) + } +} + +func TestWeb_Resolve_BadFormatPropagates(t *testing.T) { + resolver := NewWeb() + _, err := resolver.Resolve(context.Background(), "did:plc:abc") + if err == nil { + t.Fatal("expected error, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "DID_BAD_FORMAT" { + t.Errorf("expected DID_BAD_FORMAT, got %v", err) + } +} + +// equalSlices avoids importing reflect for one comparison. +func equalSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/adapter/anchor/did/web_testhelpers_test.go b/internal/adapter/anchor/did/web_testhelpers_test.go new file mode 100644 index 0000000..ed5f300 --- /dev/null +++ b/internal/adapter/anchor/did/web_testhelpers_test.go @@ -0,0 +1,33 @@ +package did + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "net/url" + "time" +) + +// newRoutingClient returns an http.Client whose Transport routes +// every connection to the given httptest.Server, regardless of the +// URL host the request targets. The TLS config is taken from the +// httptest server so the resolver's standard chain validation +// passes against the test cert. +// +// This is the core test fixture for did:web: the resolver builds +// "https://agent.example.com/.well-known/did.json" but the actual +// TCP connection terminates at httptest's loopback listener. +func newRoutingClient(server *httptest.Server) *http.Client { + srvURL, _ := url.Parse(server.URL) + return &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: server.Client().Transport.(*http.Transport).TLSClientConfig.Clone(), + DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { + return (&net.Dialer{Timeout: 2 * time.Second}).DialContext(ctx, network, srvURL.Host) + }, + }, + CheckRedirect: webRedirectPolicy, + } +} diff --git a/internal/adapter/anchor/fqdn/resolver.go b/internal/adapter/anchor/fqdn/resolver.go new file mode 100644 index 0000000..d265ff0 --- /dev/null +++ b/internal/adapter/anchor/fqdn/resolver.go @@ -0,0 +1,206 @@ +// Package fqdn implements the ANS-0 §0.A FQDN anchor profile. +// +// FQDN is the dominant anchor type for ANS-registered agents. This +// resolver lifts the FQDN-specific lexical and shape validation that +// previously lived inline in the registration service into a typed +// AnchorResolver. The resolver returns an IdentityClaim whose +// PublicKeyJWK is supplied by the caller (the registration flow has +// already validated the certificate chain at this point); the +// resolver's job is to canonicalize the FQDN, sanity-check it +// against RFC 1123, and stamp the claim's metadata. +// +// Plan G Slice 1 keeps the resolver behavior-preserving: existing +// FQDN registrations land identical IdentityClaim values regardless +// of whether the resolver is on the hot path or bypassed. Subsequent +// slices add the optional external key-locator pre-step +// (anchor-0a-fqdn.md §3.4) and the ACME challenge resolution that is +// currently inline in internal/ra/service/lifecycle.go. The pre-step +// is structured as a pluggable adapter chain so the resolver does +// not couple to any specific parallel agentic-discovery effort. +package fqdn + +import ( + "context" + "strings" + "time" + + "github.com/godaddy/ans/internal/domain" +) + +// ProfileID is the canonical identifier for this resolver in the +// AnchorResolverRegistry advertised through SupportedProfiles(). +// Matches docs/profiles/anchor-0a-fqdn.md. +const ProfileID = "0.A-fqdn" + +// Resolver implements port.AnchorResolver for FQDN anchors. +// +// The resolver is stateless; it carries no DNS client and no cert +// validator of its own. Higher-level service code performs the +// certificate chain validation and DNSSEC checks (the existing +// registration flow does this through the certificate-validator port); +// this resolver shapes the validated material into an IdentityClaim. +// +// A future Slice will pull DNS resolution and DNSSEC validation into +// this package so the resolver becomes the canonical home for FQDN +// resolution rather than a thin shape-converter. For now, keep the +// surface minimal so the abstraction is testable in isolation. +type Resolver struct { + clock func() time.Time +} + +// New constructs a Resolver using time.Now as the clock. +func New() *Resolver { + return &Resolver{clock: time.Now} +} + +// WithClock returns a copy of the resolver with the given clock +// function. Tests use this to make IssuedAt deterministic. +func (r *Resolver) WithClock(clock func() time.Time) *Resolver { + return &Resolver{clock: clock} +} + +// SupportedProfiles satisfies port.AnchorResolver. +func (r *Resolver) SupportedProfiles() []string { + return []string{ProfileID} +} + +// Resolve validates an FQDN input and returns an IdentityClaim. +// +// At this slice, Resolve performs only the lexical step (ANS-0 §4.2 +// step 1) and stamps IssuedAt; the resolution + key authenticity + +// freshness steps remain in the existing registration service code +// path that this resolver will absorb in Slice 4. Calling Resolve +// without an explicit publicKey input would force the resolver to +// fetch the cert chain itself, which couples the package to the +// validator port; the explicit-key shape keeps the resolver narrow +// for now. +// +// Callers building a claim from an already-validated certificate +// should use ResolveWithKey, which is the primary entry point during +// the abstraction migration. Resolve is provided for future use when +// the resolver owns the full chain; today it returns a +// not-implemented error to flag the migration boundary. +func (r *Resolver) Resolve(_ context.Context, _ string) (*domain.IdentityClaim, error) { + return nil, domain.NewInternalError( + "FQDN_RESOLVE_NOT_IMPLEMENTED", + "FQDN resolver is currently shape-only; use ResolveWithKey from "+ + "the registration service until Slice 4 absorbs DNS resolution", + nil, + ) +} + +// ResolveWithKey is the slice-1 entry point used by the registration +// service. The caller (registration service or renewal service) +// validates the certificate chain through the existing certificate- +// validator port and hands the public key in JWK form. The resolver +// canonicalizes the FQDN, validates it lexically, and shapes the +// IdentityClaim. +// +// Returned errors are *domain.Error with codes: +// +// - FQDN_BAD_FORMAT input is empty, too long, or fails RFC 1123 +// - FQDN_LABEL_BAD a label is empty, too long, or non-LDH +// +// metadataURL is optional and typically points at the agent's Trust +// Card location (https:///.well-known/ans/trust-card.json). +// Empty metadataURL produces a claim with an empty MetadataURL field, +// which higher-spec code treats as "fall back to the default +// well-known path." +func (r *Resolver) ResolveWithKey(input string, publicKeyJWK []byte, metadataURL string) (*domain.IdentityClaim, error) { + canonical, err := canonicalize(input) + if err != nil { + return nil, err + } + if len(publicKeyJWK) == 0 { + return nil, domain.NewValidationError( + "MISSING_PUBLIC_KEY", + "public key is required to construct an FQDN identity claim", + ) + } + return &domain.IdentityClaim{ + AnchorType: domain.AnchorTypeFQDN, + ResolvedID: canonical, + PublicKeyJWK: publicKeyJWK, + MetadataURL: metadataURL, + IssuedAt: r.clock().UTC(), + }, nil +} + +// canonicalize lowercases the input, strips any trailing dot, and +// validates the result against RFC 1123 hostname constraints. +func canonicalize(input string) (string, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "", domain.NewValidationError( + "FQDN_BAD_FORMAT", + "FQDN cannot be empty", + ) + } + // Strip a single trailing dot (root label form). Multiple + // trailing dots are malformed. + canonical := strings.TrimSuffix(strings.ToLower(trimmed), ".") + // Total length cap per RFC 1035 §3.1 / RFC 1123 §2.1: 253 chars. + if len(canonical) > 253 { + return "", domain.NewValidationError( + "FQDN_BAD_FORMAT", + "FQDN exceeds 253-character limit", + ) + } + if strings.ContainsAny(canonical, " \t\n\r") { + return "", domain.NewValidationError( + "FQDN_BAD_FORMAT", + "FQDN must not contain whitespace", + ) + } + if !strings.Contains(canonical, ".") { + return "", domain.NewValidationError( + "FQDN_BAD_FORMAT", + "FQDN must contain at least one dot (one or more labels)", + ) + } + for _, label := range strings.Split(canonical, ".") { + if err := validateLabel(label); err != nil { + return "", err + } + } + return canonical, nil +} + +// validateLabel applies RFC 1123 §2.1 label rules. A label must be +// 1-63 LDH characters and must not start or end with a hyphen. +// Underscores are intentionally rejected: ANS-3 reserves underscore- +// prefixed names for the registry-controlled records and the agent's +// FQDN must not collide with that scheme. +func validateLabel(label string) error { + if label == "" { + return domain.NewValidationError( + "FQDN_LABEL_BAD", + "FQDN label cannot be empty (consecutive dots)", + ) + } + if len(label) > 63 { + return domain.NewValidationError( + "FQDN_LABEL_BAD", + "FQDN label exceeds 63 characters", + ) + } + if label[0] == '-' || label[len(label)-1] == '-' { + return domain.NewValidationError( + "FQDN_LABEL_BAD", + "FQDN label must not start or end with a hyphen", + ) + } + for _, c := range label { + switch { + case c >= 'a' && c <= 'z': + case c >= '0' && c <= '9': + case c == '-': + default: + return domain.NewValidationError( + "FQDN_LABEL_BAD", + "FQDN label contains invalid character: "+string(c), + ) + } + } + return nil +} diff --git a/internal/adapter/anchor/fqdn/resolver_test.go b/internal/adapter/anchor/fqdn/resolver_test.go new file mode 100644 index 0000000..2bf6628 --- /dev/null +++ b/internal/adapter/anchor/fqdn/resolver_test.go @@ -0,0 +1,156 @@ +package fqdn + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/godaddy/ans/internal/domain" +) + +func mustClock(t time.Time) func() time.Time { + return func() time.Time { return t } +} + +func TestResolver_ResolveWithKey_Canonical(t *testing.T) { + fixed := time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC) + r := New().WithClock(mustClock(fixed)) + jwk := []byte(`{"kty":"OKP","crv":"Ed25519","x":"Q-..."}`) + + claim, err := r.ResolveWithKey("Agent.Example.COM.", jwk, "") + if err != nil { + t.Fatalf("ResolveWithKey: %v", err) + } + if claim.AnchorType != domain.AnchorTypeFQDN { + t.Errorf("AnchorType = %q, want %q", claim.AnchorType, domain.AnchorTypeFQDN) + } + if claim.ResolvedID != "agent.example.com" { + t.Errorf("ResolvedID = %q, want %q (canonical lowercase, no trailing dot)", + claim.ResolvedID, "agent.example.com") + } + if !claim.IssuedAt.Equal(fixed) { + t.Errorf("IssuedAt = %v, want %v", claim.IssuedAt, fixed) + } + if string(claim.PublicKeyJWK) != string(jwk) { + t.Errorf("PublicKeyJWK was mutated") + } + if err := claim.Validate(); err != nil { + t.Errorf("returned claim fails Validate: %v", err) + } +} + +func TestResolver_ResolveWithKey_MetadataURL(t *testing.T) { + r := New() + jwk := []byte(`{"kty":"OKP","crv":"Ed25519","x":"Q-..."}`) + want := "https://agent.example.com/.well-known/ans/trust-card.json" + claim, err := r.ResolveWithKey("agent.example.com", jwk, want) + if err != nil { + t.Fatalf("ResolveWithKey: %v", err) + } + if claim.MetadataURL != want { + t.Errorf("MetadataURL = %q, want %q", claim.MetadataURL, want) + } +} + +func TestResolver_ResolveWithKey_BadFormat(t *testing.T) { + r := New() + jwk := []byte(`{"kty":"OKP","crv":"Ed25519","x":"Q-..."}`) + + cases := []struct { + name string + input string + wantCode string + }{ + {"empty", "", "FQDN_BAD_FORMAT"}, + {"whitespace only", " ", "FQDN_BAD_FORMAT"}, + {"single label (no dot)", "localhost", "FQDN_BAD_FORMAT"}, + {"too long total", longFQDN(254), "FQDN_BAD_FORMAT"}, + {"contains whitespace", "agent .example.com", "FQDN_BAD_FORMAT"}, + {"empty label", "agent..example.com", "FQDN_LABEL_BAD"}, + {"label too long", "agent." + repeat("a", 64) + ".com", "FQDN_LABEL_BAD"}, + {"leading hyphen", "-bad.example.com", "FQDN_LABEL_BAD"}, + {"trailing hyphen", "bad-.example.com", "FQDN_LABEL_BAD"}, + {"underscore (ANS reserves _-prefixed)", "_ans.example.com", "FQDN_LABEL_BAD"}, + {"non-LDH char", "ag$ent.example.com", "FQDN_LABEL_BAD"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := r.ResolveWithKey(c.input, jwk, "") + if err == nil { + t.Fatal("expected error, got nil") + } + var dErr *domain.Error + ok := errors.As(err, &dErr) + if !ok { + t.Fatalf("error is not *domain.Error: %T", err) + } + if dErr.Code != c.wantCode { + t.Errorf("code = %q, want %q (msg: %s)", dErr.Code, c.wantCode, dErr.Message) + } + }) + } +} + +func TestResolver_ResolveWithKey_MissingKey(t *testing.T) { + r := New() + _, err := r.ResolveWithKey("agent.example.com", nil, "") + if err == nil { + t.Fatal("expected error, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "MISSING_PUBLIC_KEY" { + t.Errorf("expected MISSING_PUBLIC_KEY, got %v", err) + } +} + +func TestResolver_SupportedProfiles(t *testing.T) { + got := New().SupportedProfiles() + if len(got) != 1 || got[0] != ProfileID { + t.Errorf("SupportedProfiles = %v, want [%q]", got, ProfileID) + } + if ProfileID != "0.A-fqdn" { + t.Errorf("ProfileID = %q, want %q (matches docs/profiles/anchor-0a-fqdn.md)", + ProfileID, "0.A-fqdn") + } +} + +func TestResolver_Resolve_NotImplemented(t *testing.T) { + // Slice 1: full Resolve is deferred. The shape-only ResolveWithKey + // is the migration entry point. Once Slice 4 lands, Resolve will + // own DNS resolution + cert chain validation; this test pins the + // not-implemented error so the migration boundary is explicit. + r := New() + _, err := r.Resolve(context.Background(), "agent.example.com") + if err == nil { + t.Fatal("expected not-implemented error, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "FQDN_RESOLVE_NOT_IMPLEMENTED" { + t.Errorf("expected FQDN_RESOLVE_NOT_IMPLEMENTED, got %v", err) + } +} + +// longFQDN returns a string of exactly n characters that has the +// shape of a multi-label FQDN (interspersed dots) so canonicalize's +// label-loop runs and surfaces the size cap before label-loop errors. +func longFQDN(n int) string { + out := make([]byte, 0, n) + for i := range n { + if i > 0 && i%32 == 0 { + out = append(out, '.') + } else { + out = append(out, 'a') + } + } + return string(out[:n]) +} + +func repeat(s string, n int) string { + out := make([]byte, 0, len(s)*n) + for range n { + out = append(out, s...) + } + return string(out) +} diff --git a/internal/adapter/anchor/lei/resolver.go b/internal/adapter/anchor/lei/resolver.go new file mode 100644 index 0000000..4711a61 --- /dev/null +++ b/internal/adapter/anchor/lei/resolver.go @@ -0,0 +1,224 @@ +// Package lei implements the ANS-0 §0.C LEI anchor profile per +// docs/profiles/anchor-0c-lei.md. +// +// This package handles the lexical and check-digit validation that +// is portable across deployments (no GLEIF API access required) and +// stubs the actual GLEIF resolution behind a Resolver type. The +// stubbed Resolve method returns LEI_GLEIF_NOT_CONFIGURED until a +// caller injects a GLEIF client through WithClient. That keeps the +// package useful for testbeds and unit tests without forcing every +// downstream environment to acquire GLEIF API credentials. +// +// Slice 3 ships the format validation, mod-97 check-digit +// verification, canonical normalization, and the SupportedProfiles +// surface. The HTTPS GET pipeline against api.gleif.org and the +// vLEI self-attestation Option A path land in a follow-up slice +// once a GLEIF testbed is wired into CI. +package lei + +import ( + "context" + "strings" + "time" + + "github.com/godaddy/ans/internal/domain" +) + +// ProfileID is the canonical identifier for this resolver in the +// AnchorResolverRegistry advertised through SupportedProfiles(). +// Matches docs/profiles/anchor-0c-lei.md. +const ProfileID = "0.C-lei" + +// freshnessBudget bounds the cache lifetime of a resolved +// IdentityClaim. 7 days matches the recommendation in +// docs/profiles/anchor-0c-lei.md §3.5; verifiers in regulated +// verticals MAY shorten per local policy. +const freshnessBudget = 7 * 24 * time.Hour + +// GLEIFClient abstracts the GLEIF API surface the resolver needs. +// Implementations live outside this package so the resolver does +// not link the GLEIF HTTP transport at compile time. The signature +// is intentionally minimal; richer fields (parent/child +// relationships, registration history) live behind a richer client +// the resolver can compose with later. +type GLEIFClient interface { + // LookupRecord returns the GLEIF Level 1 / Level 2 record for + // the given LEI. The record carries the entity's status, name, + // jurisdiction, and (under the vLEI Option A path) the entity's + // ANS attestation public key as a self-attestation. + LookupRecord(ctx context.Context, lei string) (*GLEIFRecord, error) +} + +// GLEIFRecord is the subset of the GLEIF response the resolver +// needs to construct an IdentityClaim. Adding fields here is safe; +// the JSON decode in a future client implementation ignores anything +// not declared. +type GLEIFRecord struct { + LEI string + EntityName string + EntityStatus string // "ACTIVE", "INACTIVE", "LAPSED", "RETIRED", "MERGED" + Jurisdiction string + AttestationJWK []byte // entity's ANS attestation key in JWK form + UpdatedAt time.Time +} + +// Resolver implements port.AnchorResolver for LEI anchors. The +// stub form (no client injected) handles format-only resolution and +// surfaces a clear LEI_GLEIF_NOT_CONFIGURED error on Resolve. +// Production deployments inject a GLEIFClient via WithClient. +type Resolver struct { + client GLEIFClient + clock func() time.Time +} + +// New constructs a Resolver with no GLEIF client. Format +// validation works; full resolution returns +// LEI_GLEIF_NOT_CONFIGURED until WithClient is used. +func New() *Resolver { + return &Resolver{clock: time.Now} +} + +// WithClient injects a GLEIF client and returns a copy of the +// resolver. The client owns the GLEIF root certificate pinning, +// rate limiting, and any LOU mirror selection per +// anchor-0c-lei.md §3.3. +func (r *Resolver) WithClient(c GLEIFClient) *Resolver { + return &Resolver{client: c, clock: r.clock} +} + +// WithClock returns a copy of the resolver with a deterministic +// clock. Tests use this so IssuedAt is reproducible. +func (r *Resolver) WithClock(clock func() time.Time) *Resolver { + return &Resolver{client: r.client, clock: clock} +} + +// SupportedProfiles satisfies port.AnchorResolver. +func (r *Resolver) SupportedProfiles() []string { + return []string{ProfileID} +} + +// Resolve implements port.AnchorResolver for LEI anchors. +// +// Pipeline: +// 1. Lexical validation: 20 ASCII alphanumeric characters. +// 2. Check-digit validation: ISO 17442 mod-97 == 1. +// 3. Canonicalize to uppercase. +// 4. If no GLEIF client is configured, return +// LEI_GLEIF_NOT_CONFIGURED. This is the slice-3 boundary; a +// follow-up slice wires the production client. +// 5. Fetch the GLEIF record via the configured client. +// 6. Validate the entity status is ACTIVE; reject INACTIVE / +// LAPSED / RETIRED / MERGED with LEI_INACTIVE. +// 7. Construct the IdentityClaim with the entity's attestation +// JWK. ExpiresAt is now + 7 days (the freshness budget). +func (r *Resolver) Resolve(ctx context.Context, input string) (*domain.IdentityClaim, error) { + canonical, err := Canonicalize(input) + if err != nil { + return nil, err + } + if r.client == nil { + return nil, domain.NewInternalError( + "LEI_GLEIF_NOT_CONFIGURED", + "LEI resolver has no GLEIF client configured; format validated but "+ + "full resolution requires WithClient injection", + nil, + ) + } + record, err := r.client.LookupRecord(ctx, canonical) + if err != nil { + return nil, domain.NewValidationError( + "LEI_RESOLUTION_FAILED", + "GLEIF lookup failed: "+err.Error(), + ) + } + if record == nil { + return nil, domain.NewValidationError( + "LEI_UNKNOWN", + "GLEIF returned no record for "+canonical, + ) + } + if !strings.EqualFold(record.EntityStatus, "ACTIVE") { + return nil, domain.NewValidationError( + "LEI_INACTIVE", + "entity status is "+record.EntityStatus+"; only ACTIVE LEIs admitted", + ) + } + if len(record.AttestationJWK) == 0 { + return nil, domain.NewValidationError( + "LEI_NO_ATTESTATION_KEY", + "GLEIF record has no ANS attestation key registered for "+canonical, + ) + } + now := r.clock().UTC() + return &domain.IdentityClaim{ + AnchorType: domain.AnchorTypeLEI, + ResolvedID: canonical, + PublicKeyJWK: record.AttestationJWK, + IssuedAt: now, + ExpiresAt: now.Add(freshnessBudget), + }, nil +} + +// Canonicalize validates the input as an ISO 17442 LEI and returns +// the canonical uppercase form. +// +// The check-digit rule is the ISO 7064 MOD 97-10 form: rewrite the +// LEI's first 18 characters as a numeric string (A=10, B=11, ..., +// Z=35), append the two-digit check field, then compute mod 97. +// The result MUST be 1. +func Canonicalize(input string) (string, error) { + trimmed := strings.TrimSpace(input) + if len(trimmed) != 20 { + return "", domain.NewValidationError( + "LEI_BAD_FORMAT", + "LEI must be exactly 20 characters", + ) + } + upper := strings.ToUpper(trimmed) + for _, r := range upper { + if !isASCIIAlphanumeric(r) { + return "", domain.NewValidationError( + "LEI_BAD_FORMAT", + "LEI contains non-alphanumeric character", + ) + } + } + if !validMod97(upper) { + return "", domain.NewValidationError( + "LEI_BAD_CHECK_DIGITS", + "LEI fails ISO 17442 mod-97 check", + ) + } + return upper, nil +} + +func isASCIIAlphanumeric(r rune) bool { + return (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') +} + +// validMod97 implements the ISO 7064 MOD 97-10 check used by +// ISO 17442 §5.1. Letters expand to two digits (A=10..Z=35); the +// 20-character string then forms a large integer whose modulus 97 +// MUST be 1. +// +// The arithmetic uses incremental modulus to avoid big.Int: at each +// step, multiply the running value by 10 (for digits) or 100 (for +// letters expanded to two digits), add the new digit(s), then take +// mod 97. The check-digit positions (chars 5-6) are part of the +// 20-character string so we never need to extract them separately. +func validMod97(lei string) bool { + const modulus = 97 + r := 0 + for _, c := range lei { + switch { + case c >= '0' && c <= '9': + r = (r*10 + int(c-'0')) % modulus + case c >= 'A' && c <= 'Z': + v := int(c-'A') + 10 // A=10, ..., Z=35 + r = (r*100 + v) % modulus + default: + return false + } + } + return r == 1 +} diff --git a/internal/adapter/anchor/lei/resolver_test.go b/internal/adapter/anchor/lei/resolver_test.go new file mode 100644 index 0000000..d143491 --- /dev/null +++ b/internal/adapter/anchor/lei/resolver_test.go @@ -0,0 +1,240 @@ +package lei + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/godaddy/ans/internal/domain" +) + +// validLEI is the well-known GLEIF documentation example LEI, which +// passes mod-97. Verified against +// https://www.gleif.org/en/about-lei/iso-17442-the-lei-code-structure +const validLEI = "529900T8BM49AURSDO55" + +// invalidCheckLEI is validLEI with the last two check digits perturbed +// to fail mod-97. +const invalidCheckLEI = "529900T8BM49AURSDO99" + +func TestCanonicalize_HappyPath(t *testing.T) { + got, err := Canonicalize(validLEI) + if err != nil { + t.Fatalf("Canonicalize: %v", err) + } + if got != validLEI { + t.Errorf("got %q, want %q", got, validLEI) + } +} + +func TestCanonicalize_LowercaseUppercased(t *testing.T) { + lower := "529900t8bm49aursdo55" + got, err := Canonicalize(lower) + if err != nil { + t.Fatalf("Canonicalize lowercase: %v", err) + } + if got != validLEI { + t.Errorf("uppercase form not enforced: got %q", got) + } +} + +func TestCanonicalize_TrimsWhitespace(t *testing.T) { + got, err := Canonicalize(" " + validLEI + " ") + if err != nil { + t.Fatalf("Canonicalize: %v", err) + } + if got != validLEI { + t.Errorf("got %q", got) + } +} + +func TestCanonicalize_BadFormat(t *testing.T) { + cases := []struct { + name string + input string + wantCode string + }{ + {"empty", "", "LEI_BAD_FORMAT"}, + {"too short", "529900T8BM49", "LEI_BAD_FORMAT"}, + {"too long", validLEI + "X", "LEI_BAD_FORMAT"}, + {"contains hyphen", "529900-T8BM49AURSDO55", "LEI_BAD_FORMAT"}, + {"contains space inside", "529900 T8BM49AURSDO55", "LEI_BAD_FORMAT"}, + {"contains lowercase non-alpha", "529900t8bm49aursdo5!", "LEI_BAD_FORMAT"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := Canonicalize(c.input) + if err == nil { + t.Fatal("expected error, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != c.wantCode { + t.Errorf("expected %q, got %v", c.wantCode, err) + } + }) + } +} + +func TestCanonicalize_BadCheckDigits(t *testing.T) { + _, err := Canonicalize(invalidCheckLEI) + if err == nil { + t.Fatal("expected LEI_BAD_CHECK_DIGITS, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "LEI_BAD_CHECK_DIGITS" { + t.Errorf("expected LEI_BAD_CHECK_DIGITS, got %v", err) + } +} + +func TestResolver_Resolve_NoClient(t *testing.T) { + r := New() + _, err := r.Resolve(context.Background(), validLEI) + if err == nil { + t.Fatal("expected LEI_GLEIF_NOT_CONFIGURED, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "LEI_GLEIF_NOT_CONFIGURED" { + t.Errorf("expected LEI_GLEIF_NOT_CONFIGURED, got %v", err) + } +} + +func TestResolver_Resolve_BadFormatPropagates(t *testing.T) { + r := New() + _, err := r.Resolve(context.Background(), "") + if err == nil { + t.Fatal("expected LEI_BAD_FORMAT, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "LEI_BAD_FORMAT" { + t.Errorf("expected LEI_BAD_FORMAT, got %v", err) + } +} + +// fakeGLEIFClient is a test double for the slice-3 + slice-3.1 +// boundary; once a real GLEIF HTTP client lands it satisfies the +// same interface and these tests carry over unchanged. +type fakeGLEIFClient struct { + record *GLEIFRecord + err error +} + +func (f *fakeGLEIFClient) LookupRecord(_ context.Context, _ string) (*GLEIFRecord, error) { + return f.record, f.err +} + +func TestResolver_Resolve_ActiveEntity(t *testing.T) { + fixed := time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC) + jwk := []byte(`{"kty":"OKP","crv":"Ed25519","x":"LEI-KEY"}`) + r := New(). + WithClock(func() time.Time { return fixed }). + WithClient(&fakeGLEIFClient{ + record: &GLEIFRecord{ + LEI: validLEI, + EntityName: "Acme Corp", + EntityStatus: "ACTIVE", + Jurisdiction: "US-DE", + AttestationJWK: jwk, + UpdatedAt: fixed.Add(-30 * 24 * time.Hour), + }, + }) + + claim, err := r.Resolve(context.Background(), validLEI) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if claim.AnchorType != domain.AnchorTypeLEI { + t.Errorf("AnchorType = %q", claim.AnchorType) + } + if claim.ResolvedID != validLEI { + t.Errorf("ResolvedID = %q, want %q", claim.ResolvedID, validLEI) + } + if string(claim.PublicKeyJWK) != string(jwk) { + t.Errorf("attestation JWK was mutated") + } + if !claim.IssuedAt.Equal(fixed) { + t.Errorf("IssuedAt = %v", claim.IssuedAt) + } + if !claim.ExpiresAt.Equal(fixed.Add(freshnessBudget)) { + t.Errorf("ExpiresAt = %v, want %v", claim.ExpiresAt, fixed.Add(freshnessBudget)) + } + if err := claim.Validate(); err != nil { + t.Errorf("returned claim fails Validate: %v", err) + } +} + +func TestResolver_Resolve_InactiveStatusRejected(t *testing.T) { + cases := []string{"INACTIVE", "LAPSED", "RETIRED", "MERGED"} + for _, status := range cases { + t.Run(status, func(t *testing.T) { + r := New().WithClient(&fakeGLEIFClient{ + record: &GLEIFRecord{ + LEI: validLEI, + EntityStatus: status, + AttestationJWK: []byte(`{"kty":"OKP"}`), + }, + }) + _, err := r.Resolve(context.Background(), validLEI) + if err == nil { + t.Fatal("expected LEI_INACTIVE, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "LEI_INACTIVE" { + t.Errorf("expected LEI_INACTIVE, got %v", err) + } + }) + } +} + +func TestResolver_Resolve_LookupError(t *testing.T) { + r := New().WithClient(&fakeGLEIFClient{err: errors.New("network down")}) + _, err := r.Resolve(context.Background(), validLEI) + if err == nil { + t.Fatal("expected LEI_RESOLUTION_FAILED, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "LEI_RESOLUTION_FAILED" { + t.Errorf("expected LEI_RESOLUTION_FAILED, got %v", err) + } +} + +func TestResolver_Resolve_NilRecord(t *testing.T) { + r := New().WithClient(&fakeGLEIFClient{}) // both record and err nil + _, err := r.Resolve(context.Background(), validLEI) + if err == nil { + t.Fatal("expected LEI_UNKNOWN, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "LEI_UNKNOWN" { + t.Errorf("expected LEI_UNKNOWN, got %v", err) + } +} + +func TestResolver_Resolve_MissingAttestationKey(t *testing.T) { + r := New().WithClient(&fakeGLEIFClient{ + record: &GLEIFRecord{ + LEI: validLEI, + EntityStatus: "ACTIVE", + // AttestationJWK omitted + }, + }) + _, err := r.Resolve(context.Background(), validLEI) + if err == nil { + t.Fatal("expected LEI_NO_ATTESTATION_KEY, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "LEI_NO_ATTESTATION_KEY" { + t.Errorf("expected LEI_NO_ATTESTATION_KEY, got %v", err) + } +} + +func TestResolver_SupportedProfiles(t *testing.T) { + got := New().SupportedProfiles() + if len(got) != 1 || got[0] != ProfileID { + t.Errorf("SupportedProfiles = %v, want [%q]", got, ProfileID) + } + if ProfileID != "0.C-lei" { + t.Errorf("ProfileID = %q, want %q (matches docs/profiles/anchor-0c-lei.md)", + ProfileID, "0.C-lei") + } +} diff --git a/internal/adapter/anchor/registry/registry.go b/internal/adapter/anchor/registry/registry.go new file mode 100644 index 0000000..faa55bf --- /dev/null +++ b/internal/adapter/anchor/registry/registry.go @@ -0,0 +1,208 @@ +// Package registry composes per-profile AnchorResolver +// implementations behind a single facade that the registration +// service calls into. +// +// The facade dispatches by lexical form per ANS-0 §4.1: +// +// - Inputs starting with "did:" route to a DID resolver +// (currently only did:web; subsequent slices add did:plc, +// did:key, did:ethr/did:pkh, did:ion). +// - Inputs that match the ISO 17442 LEI shape route to the LEI +// resolver. +// - Inputs that match RFC 1123 hostname constraints route to the +// FQDN resolver. +// - Anything else returns INVALID_ANCHOR_FORMAT without invoking +// any sub-resolver. +// +// A registry is constructed with a configurable set of profiles. +// Deployments that accept FQDN-only registrations build a registry +// with the FQDN resolver alone; deployments that accept all three +// anchor types register all three. SupportedProfiles returns the +// union, which the configuration validator at startup audits +// against the deployment's accepted-profile list. +package registry + +import ( + "context" + "strings" + + "github.com/godaddy/ans/internal/adapter/anchor/did" + "github.com/godaddy/ans/internal/adapter/anchor/lei" + "github.com/godaddy/ans/internal/domain" +) + +// Compile-time check that *Registry satisfies the +// port.AnchorResolver contract. The actual import is deferred +// because stating the interface compile-time is the cleanest +// expression and a port import without use would lint-fail. +// +// var _ port.AnchorResolver = (*Registry)(nil) +// +// The check is informally documented; runtime composition in the +// registration service plumbs the registry into the port slot. + +// fqdnShapeResolver is the subset of the FQDN resolver's API the +// registry uses. The full FQDN package owns ResolveWithKey for the +// migration period; the facade routes to Resolve which today is a +// stub. Once Slice 4-of-the-FQDN-package owns DNS resolution, both +// the registry and the registration service will move to Resolve. +type fqdnShapeResolver interface { + Resolve(ctx context.Context, input string) (*domain.IdentityClaim, error) + SupportedProfiles() []string +} + +// Registry implements port.AnchorResolver as a facade over the +// per-profile resolvers a deployment configures. +type Registry struct { + fqdn fqdnShapeResolver + did *did.Web + lei *lei.Resolver +} + +// New constructs an empty Registry. Use With* methods to add +// per-profile resolvers; an empty registry rejects every input +// with INVALID_ANCHOR_FORMAT, which is appropriate during config +// validation but never in production. +func New() *Registry { + return &Registry{} +} + +// WithFQDN registers an FQDN resolver (§0.A profile). +func (r *Registry) WithFQDN(resolver fqdnShapeResolver) *Registry { + out := *r + out.fqdn = resolver + return &out +} + +// WithDIDWeb registers the did:web resolver (§0.B profile, sub- +// profile did:web). Other DID methods are added through subsequent +// With*DID* methods as they land. +func (r *Registry) WithDIDWeb(resolver *did.Web) *Registry { + out := *r + out.did = resolver + return &out +} + +// WithLEI registers the LEI resolver (§0.C profile). +func (r *Registry) WithLEI(resolver *lei.Resolver) *Registry { + out := *r + out.lei = resolver + return &out +} + +// SupportedProfiles satisfies port.AnchorResolver. The slice is the +// union of every registered sub-resolver's profiles, in stable +// lexical order so the configuration validator's diff output is +// reproducible. +func (r *Registry) SupportedProfiles() []string { + out := []string{} + if r.fqdn != nil { + out = append(out, r.fqdn.SupportedProfiles()...) + } + if r.did != nil { + out = append(out, r.did.SupportedProfiles()...) + } + if r.lei != nil { + out = append(out, r.lei.SupportedProfiles()...) + } + return out +} + +// Resolve dispatches to the appropriate sub-resolver based on the +// input's lexical form. +// +// Dispatch order: +// 1. "did:" prefix → DID branch. +// 2. 20-char ASCII alphanumeric → LEI branch. +// 3. RFC 1123 hostname shape → FQDN branch. +// 4. Anything else → INVALID_ANCHOR_FORMAT. +// +// If the dispatched profile is not configured (e.g. input is a DID +// URI but no DID resolver was registered), the registry returns +// PROFILE_NOT_CONFIGURED. A configuration validator at startup +// catches this case before the first registration request lands. +func (r *Registry) Resolve(ctx context.Context, input string) (*domain.IdentityClaim, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return nil, domain.NewValidationError( + "INVALID_ANCHOR_FORMAT", + "anchor input is empty", + ) + } + + switch { + case strings.HasPrefix(strings.ToLower(trimmed), "did:"): + if r.did == nil { + return nil, domain.NewValidationError( + "PROFILE_NOT_CONFIGURED", + "DID anchor input received but no DID resolver registered", + ) + } + return r.did.Resolve(ctx, trimmed) + + case looksLikeLEI(trimmed): + if r.lei == nil { + return nil, domain.NewValidationError( + "PROFILE_NOT_CONFIGURED", + "LEI anchor input received but no LEI resolver registered", + ) + } + return r.lei.Resolve(ctx, trimmed) + + case looksLikeFQDN(trimmed): + if r.fqdn == nil { + return nil, domain.NewValidationError( + "PROFILE_NOT_CONFIGURED", + "FQDN anchor input received but no FQDN resolver registered", + ) + } + return r.fqdn.Resolve(ctx, trimmed) + } + + return nil, domain.NewValidationError( + "INVALID_ANCHOR_FORMAT", + "input did not match did:, LEI, or FQDN shape", + ) +} + +// looksLikeLEI checks the input's ASCII shape only; the LEI +// resolver does the full ISO 17442 mod-97 validation. The lexical +// check is intentionally cheap so the dispatch decision is fast +// and the actual validation error (if any) comes from the LEI +// resolver itself. +func looksLikeLEI(input string) bool { + if len(input) != 20 { + return false + } + for _, r := range input { + switch { + case r >= '0' && r <= '9': + case r >= 'A' && r <= 'Z': + case r >= 'a' && r <= 'z': + default: + return false + } + } + return true +} + +// looksLikeFQDN does a lexical check sufficient to distinguish +// FQDN from "everything else." The FQDN resolver itself enforces +// the strict RFC 1123 rules; here we just need at least one dot +// and admissible characters. +func looksLikeFQDN(input string) bool { + if !strings.Contains(input, ".") { + return false + } + for _, r := range input { + switch { + case r >= 'a' && r <= 'z': + case r >= 'A' && r <= 'Z': + case r >= '0' && r <= '9': + case r == '.' || r == '-': + default: + return false + } + } + return true +} diff --git a/internal/adapter/anchor/registry/registry_test.go b/internal/adapter/anchor/registry/registry_test.go new file mode 100644 index 0000000..fb84245 --- /dev/null +++ b/internal/adapter/anchor/registry/registry_test.go @@ -0,0 +1,282 @@ +package registry + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/godaddy/ans/internal/adapter/anchor/did" + "github.com/godaddy/ans/internal/adapter/anchor/lei" + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/port" +) + +// Compile-time check that *Registry satisfies port.AnchorResolver. +var _ port.AnchorResolver = (*Registry)(nil) + +const validLEI = "529900T8BM49AURSDO55" + +// fakeFQDNResolver is a test double for the slice-3 FQDN resolver +// surface. The production package's Resolve currently returns a +// not-implemented stub; tests against the registry want a live +// success path so we double the surface here. +type fakeFQDNResolver struct { + resolveErr error + claim *domain.IdentityClaim +} + +func (f *fakeFQDNResolver) Resolve(_ context.Context, _ string) (*domain.IdentityClaim, error) { + if f.resolveErr != nil { + return nil, f.resolveErr + } + return f.claim, nil +} + +func (f *fakeFQDNResolver) SupportedProfiles() []string { + return []string{"0.A-fqdn"} +} + +// fakeGLEIFClient lets the LEI sub-resolver succeed in tests +// without an HTTP transport. Mirrors the test double in +// internal/adapter/anchor/lei. +type fakeGLEIFClient struct { + record *lei.GLEIFRecord + err error +} + +func (f *fakeGLEIFClient) LookupRecord(_ context.Context, _ string) (*lei.GLEIFRecord, error) { + return f.record, f.err +} + +func TestRegistry_DispatchByLexicalForm(t *testing.T) { + cases := []struct { + name string + input string + wantBranch string + }{ + {"FQDN", "agent.example.com", "fqdn"}, + {"DID lowercased", "did:web:agent.example.com", "did"}, + {"DID uppercase prefix", "DID:web:agent.example.com", "did"}, + {"LEI 20-char alphanumeric", validLEI, "lei"}, + {"FQDN-shape with dot beats LEI shape (lexical match)", "1234567890ABCDEF.com", "fqdn"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + fqdnHits := false + r := New(). + WithFQDN(&fakeFQDNResolver{ + claim: &domain.IdentityClaim{AnchorType: domain.AnchorTypeFQDN, ResolvedID: c.input}, + }) + + r = r.WithDIDWeb(did.NewWeb()).WithLEI(lei.New()) + _ = fqdnHits + + claim, err := r.Resolve(context.Background(), c.input) + switch c.wantBranch { + case "fqdn": + if err != nil { + t.Errorf("FQDN dispatch should reach fake (no error), got %v", err) + } + if claim == nil || claim.AnchorType != domain.AnchorTypeFQDN { + t.Errorf("FQDN dispatch returned %+v", claim) + } + case "did": + // did.NewWeb has no client transport here, so the + // fetch fails — but it must fail with a DID-shaped + // error code, proving the dispatch chose the DID + // branch. + if err == nil { + t.Errorf("expected DID resolver to error without configured client, got nil") + } + var dErr *domain.Error + if errors.As(err, &dErr) { + if !startsWith(dErr.Code, "DID_") { + t.Errorf("DID branch returned non-DID code %q", dErr.Code) + } + } + case "lei": + // LEI resolver with no client returns + // LEI_GLEIF_NOT_CONFIGURED proving the LEI + // branch fired. + if err == nil { + t.Errorf("expected LEI resolver to surface stub error, got nil") + } + var dErr *domain.Error + if errors.As(err, &dErr) { + if !startsWith(dErr.Code, "LEI_") { + t.Errorf("LEI branch returned non-LEI code %q", dErr.Code) + } + } + } + }) + } +} + +func TestRegistry_EmptyInput(t *testing.T) { + r := New().WithFQDN(&fakeFQDNResolver{}) + _, err := r.Resolve(context.Background(), "") + if err == nil { + t.Fatal("expected INVALID_ANCHOR_FORMAT, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "INVALID_ANCHOR_FORMAT" { + t.Errorf("expected INVALID_ANCHOR_FORMAT, got %v", err) + } +} + +func TestRegistry_UnknownShape(t *testing.T) { + r := New().WithFQDN(&fakeFQDNResolver{}).WithLEI(lei.New()).WithDIDWeb(did.NewWeb()) + cases := []string{ + "not a real input", // contains spaces, no dot + "only-one-label-no-dots", // no dot + "shorter-than-twenty", // hyphen, no dot + "ABCDEFGHIJ123456789", // 19 chars, looks LEI-ish but length wrong + "#%$@!()", // punctuation + } + for _, c := range cases { + t.Run(c, func(t *testing.T) { + _, err := r.Resolve(context.Background(), c) + if err == nil { + t.Fatal("expected INVALID_ANCHOR_FORMAT, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "INVALID_ANCHOR_FORMAT" { + t.Errorf("input %q: expected INVALID_ANCHOR_FORMAT, got %v", c, err) + } + }) + } +} + +func TestRegistry_DIDWithoutDIDResolverConfigured(t *testing.T) { + r := New().WithFQDN(&fakeFQDNResolver{}) // no DID resolver + _, err := r.Resolve(context.Background(), "did:web:agent.example.com") + if err == nil { + t.Fatal("expected PROFILE_NOT_CONFIGURED, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "PROFILE_NOT_CONFIGURED" { + t.Errorf("expected PROFILE_NOT_CONFIGURED, got %v", err) + } +} + +func TestRegistry_LEIWithoutLEIResolverConfigured(t *testing.T) { + r := New().WithFQDN(&fakeFQDNResolver{}) // no LEI resolver + _, err := r.Resolve(context.Background(), validLEI) + if err == nil { + t.Fatal("expected PROFILE_NOT_CONFIGURED, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "PROFILE_NOT_CONFIGURED" { + t.Errorf("expected PROFILE_NOT_CONFIGURED, got %v", err) + } +} + +func TestRegistry_FQDNWithoutFQDNResolverConfigured(t *testing.T) { + r := New().WithLEI(lei.New()) // no FQDN resolver + _, err := r.Resolve(context.Background(), "agent.example.com") + if err == nil { + t.Fatal("expected PROFILE_NOT_CONFIGURED, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "PROFILE_NOT_CONFIGURED" { + t.Errorf("expected PROFILE_NOT_CONFIGURED, got %v", err) + } +} + +func TestRegistry_SupportedProfilesUnion(t *testing.T) { + r := New(). + WithFQDN(&fakeFQDNResolver{}). + WithDIDWeb(did.NewWeb()). + WithLEI(lei.New()) + got := r.SupportedProfiles() + if len(got) != 3 { + t.Errorf("SupportedProfiles len = %d, want 3 (got %v)", len(got), got) + } + wantContains := []string{"0.A-fqdn", "0.B-did:web", "0.C-lei"} + for _, w := range wantContains { + found := false + for _, g := range got { + if g == w { + found = true + break + } + } + if !found { + t.Errorf("SupportedProfiles missing %q (got %v)", w, got) + } + } +} + +func TestRegistry_LEIBranchSucceedsWithFakeClient(t *testing.T) { + fixed := time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC) + jwk := []byte(`{"kty":"OKP","crv":"Ed25519","x":"LEI-K"}`) + leiResolver := lei.New(). + WithClock(func() time.Time { return fixed }). + WithClient(&fakeGLEIFClient{ + record: &lei.GLEIFRecord{ + LEI: validLEI, + EntityStatus: "ACTIVE", + AttestationJWK: jwk, + }, + }) + r := New().WithLEI(leiResolver) + claim, err := r.Resolve(context.Background(), validLEI) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if claim.AnchorType != domain.AnchorTypeLEI { + t.Errorf("expected LEI claim, got %+v", claim) + } +} + +func TestLooksLikeLEI(t *testing.T) { + cases := []struct { + input string + want bool + }{ + {validLEI, true}, + {"abcdefghijklmnopqrst", true}, // shape only; mod-97 is the LEI resolver's job + {"529900T8BM49AURSDO5", false}, // 19 chars + {"529900T8BM49AURSDO551", false}, // 21 chars + {"529900T8BM49AURSDO5-", false}, // hyphen + {"529900T8BM49AURSDO5 ", false}, // space + } + for _, c := range cases { + if got := looksLikeLEI(c.input); got != c.want { + t.Errorf("looksLikeLEI(%q) = %v, want %v", c.input, got, c.want) + } + } +} + +func TestLooksLikeFQDN(t *testing.T) { + cases := []struct { + input string + want bool + }{ + {"agent.example.com", true}, + {"a.b", true}, + {"single", false}, // no dot + {"has.spaces .com", false}, + {"has_underscore.com", false}, + {"has@symbol.com", false}, + } + for _, c := range cases { + if got := looksLikeFQDN(c.input); got != c.want { + t.Errorf("looksLikeFQDN(%q) = %v, want %v", c.input, got, c.want) + } + } +} + +// startsWith is a tiny helper to keep imports minimal. +func startsWith(s, prefix string) bool { + if len(s) < len(prefix) { + return false + } + for i := range len(prefix) { + if s[i] != prefix[i] { + return false + } + } + return true +} diff --git a/internal/domain/anchor.go b/internal/domain/anchor.go new file mode 100644 index 0000000..2ab7e43 --- /dev/null +++ b/internal/domain/anchor.go @@ -0,0 +1,121 @@ +package domain + +import ( + "strings" + "time" +) + +// AnchorType identifies which family of identity an agent claims. +// +// The three values correspond to the three anchor profiles defined in +// the layered ANS spec under ANS-0 (Identity Anchor): +// +// - 0.A FQDN: agent identity is a fully-qualified domain name. +// - 0.B DID: agent identity is a W3C Decentralized Identifier +// (did:web priority; did:plc, did:key, did:ethr/did:pkh, did:ion +// supported as profile sub-cases). +// - 0.C LEI: agent identity is an ISO 17442 Legal Entity Identifier. +// +// The enum is intentionally closed: adding a fourth top-level anchor +// type (e.g., SPIFFE) is an ANS-0 amendment, not a profile addition. +// New DID methods or new LEI registration regimes are profile changes +// and stay under their existing AnchorType. See the proposal at +// docs/proposals/2026-05-16-spec-skeletons/ans-0-identity-anchor.md. +type AnchorType string + +const ( + AnchorTypeFQDN AnchorType = "fqdn" + AnchorTypeDID AnchorType = "did" + AnchorTypeLEI AnchorType = "lei" +) + +// IsValid reports whether t is one of the three defined anchor types. +func (t AnchorType) IsValid() bool { + switch t { + case AnchorTypeFQDN, AnchorTypeDID, AnchorTypeLEI: + return true + } + return false +} + +// IdentityClaim is the typed result of a successful anchor resolution. +// +// Higher-spec code (ANS-1 RegistrationService, ANS-5 VerificationWorker, +// ANS-6 TrustIndex) reads identity through this struct only and never +// branches on the underlying anchor type. The struct shape matches the +// TypeScript signature in docs/spec/ANS_SPEC.md §3.1; profile docs +// under docs/profiles/anchor-0*.md specify how each anchor type is +// resolved. +// +// Field invariants: +// - AnchorType is non-empty and passes IsValid. +// - ResolvedID is the canonical normalized form for the anchor type +// (lowercase no-trailing-dot FQDN, canonical DID URI per W3C DID +// Core §3.1, 20-char ISO 17442 LEI). +// - PublicKeyJWK is the JWK-encoded form of the active verification +// key. The AnchorResolver guarantees the key is currently +// authoritative for the anchor. +// - IssuedAt is the time of resolution. Verifiers cache by +// (anchor input, issuedAt) and re-resolve when the cache exceeds +// the profile's freshness budget. +// - ExpiresAt is set when the anchor has an inherent expiration +// (DNSSEC RRSIG validity end for FQDN, DID document expiry for +// some DID methods, vLEI credential expiry). Zero-value means no +// hard expiry; the freshness budget alone bounds cache lifetime. +type IdentityClaim struct { + AnchorType AnchorType + ResolvedID string + PublicKeyJWK []byte + MetadataURL string + IssuedAt time.Time + ExpiresAt time.Time // zero-value when the anchor has no hard expiry +} + +// IsZero reports whether the claim is unset (zero value). +func (c IdentityClaim) IsZero() bool { + return c.AnchorType == "" && c.ResolvedID == "" && len(c.PublicKeyJWK) == 0 +} + +// Validate checks the structural invariants. It does NOT re-validate +// the resolution chain — the AnchorResolver did that work. Validate +// is a defense-in-depth check at API boundaries (e.g., when a claim +// is loaded from storage and passed to a service that did not produce +// it itself). +func (c IdentityClaim) Validate() error { + if !c.AnchorType.IsValid() { + return NewValidationError( + "INVALID_ANCHOR_TYPE", + "anchorType must be one of fqdn, did, lei", + ) + } + if strings.TrimSpace(c.ResolvedID) == "" { + return NewValidationError( + "MISSING_RESOLVED_ID", + "identity claim missing resolvedId", + ) + } + if len(c.PublicKeyJWK) == 0 { + return NewValidationError( + "MISSING_PUBLIC_KEY", + "identity claim missing publicKey", + ) + } + if c.IssuedAt.IsZero() { + return NewValidationError( + "MISSING_ISSUED_AT", + "identity claim missing issuedAt", + ) + } + return nil +} + +// FQDN returns the canonical FQDN when the anchor is type fqdn, +// otherwise the empty string. Callers that need the FQDN regardless +// of anchor type should derive it from the registration's AgentHost +// field rather than from the claim. +func (c IdentityClaim) FQDN() string { + if c.AnchorType == AnchorTypeFQDN { + return c.ResolvedID + } + return "" +} diff --git a/internal/domain/anchor_test.go b/internal/domain/anchor_test.go new file mode 100644 index 0000000..0ef4ea3 --- /dev/null +++ b/internal/domain/anchor_test.go @@ -0,0 +1,184 @@ +package domain + +import ( + "errors" + "testing" + "time" +) + +func TestAnchorType_IsValid(t *testing.T) { + cases := []struct { + name string + typ AnchorType + want bool + }{ + {"fqdn", AnchorTypeFQDN, true}, + {"did", AnchorTypeDID, true}, + {"lei", AnchorTypeLEI, true}, + {"empty", AnchorType(""), false}, + {"unknown", AnchorType("spiffe"), false}, + {"uppercase rejected", AnchorType("FQDN"), false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := c.typ.IsValid(); got != c.want { + t.Errorf("IsValid(%q) = %v, want %v", c.typ, got, c.want) + } + }) + } +} + +func TestIdentityClaim_Validate(t *testing.T) { + now := time.Now().UTC() + jwk := []byte(`{"kty":"OKP","crv":"Ed25519","x":"Q-..."}`) + + cases := []struct { + name string + claim IdentityClaim + wantCode string + wantValid bool + }{ + { + name: "valid fqdn claim", + claim: IdentityClaim{ + AnchorType: AnchorTypeFQDN, + ResolvedID: "agent.example.com", + PublicKeyJWK: jwk, + IssuedAt: now, + }, + wantValid: true, + }, + { + name: "valid did claim with metadataUrl", + claim: IdentityClaim{ + AnchorType: AnchorTypeDID, + ResolvedID: "did:web:agent.example.com", + PublicKeyJWK: jwk, + MetadataURL: "https://agent.example.com/.well-known/did.json", + IssuedAt: now, + }, + wantValid: true, + }, + { + name: "invalid anchor type", + claim: IdentityClaim{ + AnchorType: AnchorType("spiffe"), + ResolvedID: "spiffe://example.com/foo", + PublicKeyJWK: jwk, + IssuedAt: now, + }, + wantCode: "INVALID_ANCHOR_TYPE", + }, + { + name: "missing resolvedId", + claim: IdentityClaim{ + AnchorType: AnchorTypeFQDN, + PublicKeyJWK: jwk, + IssuedAt: now, + }, + wantCode: "MISSING_RESOLVED_ID", + }, + { + name: "missing public key", + claim: IdentityClaim{ + AnchorType: AnchorTypeFQDN, + ResolvedID: "agent.example.com", + IssuedAt: now, + }, + wantCode: "MISSING_PUBLIC_KEY", + }, + { + name: "missing issuedAt", + claim: IdentityClaim{ + AnchorType: AnchorTypeFQDN, + ResolvedID: "agent.example.com", + PublicKeyJWK: jwk, + }, + wantCode: "MISSING_ISSUED_AT", + }, + { + name: "whitespace-only resolvedId", + claim: IdentityClaim{ + AnchorType: AnchorTypeFQDN, + ResolvedID: " ", + PublicKeyJWK: jwk, + IssuedAt: now, + }, + wantCode: "MISSING_RESOLVED_ID", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := c.claim.Validate() + if c.wantValid { + if err != nil { + t.Errorf("expected valid, got error: %v", err) + } + return + } + if err == nil { + t.Fatal("expected validation error, got nil") + } + var code string + var dErr *Error + if errors.As(err, &dErr) { + code = dErr.Code + } + if code != c.wantCode { + t.Errorf("error code = %q, want %q (err: %v)", code, c.wantCode, err) + } + }) + } +} + +func TestIdentityClaim_IsZero(t *testing.T) { + if !(IdentityClaim{}).IsZero() { + t.Error("zero-value IdentityClaim should be IsZero") + } + populated := IdentityClaim{ + AnchorType: AnchorTypeFQDN, + ResolvedID: "x", + PublicKeyJWK: []byte("y"), + } + if populated.IsZero() { + t.Error("populated IdentityClaim should not be IsZero") + } +} + +func TestIdentityClaim_FQDN(t *testing.T) { + cases := []struct { + name string + claim IdentityClaim + want string + }{ + { + name: "fqdn anchor returns resolvedId", + claim: IdentityClaim{ + AnchorType: AnchorTypeFQDN, + ResolvedID: "agent.example.com", + }, + want: "agent.example.com", + }, + { + name: "did anchor returns empty", + claim: IdentityClaim{ + AnchorType: AnchorTypeDID, + ResolvedID: "did:web:agent.example.com", + }, + want: "", + }, + { + name: "zero claim returns empty", + claim: IdentityClaim{}, + want: "", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := c.claim.FQDN(); got != c.want { + t.Errorf("FQDN() = %q, want %q", got, c.want) + } + }) + } +} diff --git a/internal/port/anchor.go b/internal/port/anchor.go new file mode 100644 index 0000000..40cea0e --- /dev/null +++ b/internal/port/anchor.go @@ -0,0 +1,52 @@ +// Package port — anchor resolver contract. Defines the AnchorResolver +// interface higher-level RA code calls into to translate an arbitrary +// anchor input (FQDN, DID URI, LEI string) into a verified +// domain.IdentityClaim. Aligns with the proposed +// docs/spec/ans-0-identity-anchor.md §4 contract. +package port + +import ( + "context" + + "github.com/godaddy/ans/internal/domain" +) + +// AnchorResolver translates an anchor input string into a verified +// IdentityClaim. Implementations live under +// internal/adapter/anchor// and conform to a profile document +// under docs/profiles/anchor-0*.md. +// +// Resolve performs all four ANS-0 §4.2 verification checks before +// returning: lexical validation, resolution, key authenticity, +// freshness. A failure at any step returns a typed *domain.Error +// whose Code identifies the failure mode (FQDN_BAD_FORMAT, +// FQDN_CERT_CHAIN_INVALID, DID_RESOLUTION_FAILED, LEI_INACTIVE, etc.). +// +// SupportedProfiles enables configuration-driven composition: an RA +// configured to accept FQDN registrations only configures a resolver +// whose SupportedProfiles returns ["0.A-fqdn"]. A multi-anchor RA +// returns the union of profiles its configuration enabled. The set is +// mechanically auditable. +// +// Implementations MAY compose sub-resolvers behind a single +// AnchorResolver facade. The facade dispatches by inspecting the +// input's lexical form (per ANS-0 §4.1): +// +// -