From 09911378dcd75690895cc263aedeb40402c10e23 Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Sat, 16 May 2026 14:36:17 -0400 Subject: [PATCH 1/5] feat(anchor): introduce AnchorResolver abstraction with FQDN profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan G Slice 1: lift FQDN-specific identity handling into a typed AnchorResolver port + IdentityClaim domain type, behind a per-profile adapter package. Subsequent slices add did:web (Slice 2), LEI stub (Slice 3), and the multi-profile registry facade (Slice 4) without disturbing the existing FQDN registration flow. Domain: - AnchorType enum with three values (fqdn, did, lei) matching ANS-0 §3 in the proposal at docs/proposals/2026-05-16-spec-skeletons/ ans-0-identity-anchor.md. The enum is intentionally closed: a fourth top-level type is an ANS-0 amendment, not a profile addition. - IdentityClaim struct shaped to match the TypeScript signature in the proposal's architectural-index skeleton §3.1. PublicKeyJWK as []byte rather than a parsed key keeps the package free of a JWK library dependency at this layer; downstream consumers parse on demand. - IdentityClaim.Validate() is a defense-in-depth check at API boundaries; AnchorResolver implementations are the authoritative source for the four ANS-0 §4.2 verification checks. - IdentityClaim.FQDN() returns the canonical FQDN when the anchor is type fqdn, empty otherwise; callers needing the FQDN regardless of anchor type continue to read AgentRegistration.AgentHost. Port: - AnchorResolver interface with Resolve(ctx, input) and SupportedProfiles(). Higher-spec code (RegistrationService, VerificationWorker, TrustIndex once those refactor lands) reads identity through this interface only. FQDN adapter: - internal/adapter/anchor/fqdn implements AnchorResolver for the ANS-0 §0.A FQDN profile. Slice 1 keeps the resolver shape-only: ResolveWithKey takes a pre-validated public key from the caller (registration service) and shapes the IdentityClaim. Resolve itself returns FQDN_RESOLVE_NOT_IMPLEMENTED to flag the migration boundary; Slice 4 absorbs DNS resolution + cert chain validation into this package. - Canonicalization rules: lowercase, strip trailing dot, RFC 1123 hostname constraints, label LDH-only, no underscore-prefixed labels (ANS-3 reserves _-prefixed names for the registry's own records). - Tests cover canonical happy path, metadata URL, all the malformed- format cases (empty, single-label, too-long, whitespace, empty label, oversize label, hyphen edges, underscore rejection, non-LDH characters), missing public key, and the migration- boundary error code. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/adapter/anchor/fqdn/resolver.go | 204 ++++++++++++++++++ internal/adapter/anchor/fqdn/resolver_test.go | 156 ++++++++++++++ internal/domain/anchor.go | 121 +++++++++++ internal/domain/anchor_test.go | 184 ++++++++++++++++ internal/port/anchor.go | 52 +++++ 5 files changed, 717 insertions(+) create mode 100644 internal/adapter/anchor/fqdn/resolver.go create mode 100644 internal/adapter/anchor/fqdn/resolver_test.go create mode 100644 internal/domain/anchor.go create mode 100644 internal/domain/anchor_test.go create mode 100644 internal/port/anchor.go diff --git a/internal/adapter/anchor/fqdn/resolver.go b/internal/adapter/anchor/fqdn/resolver.go new file mode 100644 index 0000000..002ddf8 --- /dev/null +++ b/internal/adapter/anchor/fqdn/resolver.go @@ -0,0 +1,204 @@ +// 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 DNSid pre-step (anchor-0a-fqdn.md §3.4) +// and the ACME challenge resolution that is currently inline in +// internal/ra/service/lifecycle.go. +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/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): +// +// -