From 1741b6703ab3c55297e7a0adf731db285b31646b Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Sat, 16 May 2026 10:10:09 -0400 Subject: [PATCH 01/13] feat(dns): emit + verify Consolidated Approach SVCB records Extends domain.ComputeRequiredDNSRecords to emit one SVCB record per protocol at the agent bare FQDN, alongside the existing _ans TXT family. The SVCB row carries: alpn=PROTOCOL from endpoint.Protocol port=443 ServiceMode SvcPriority 1 at the FQDN wk=SUFFIX A2A: agent-card.json; MCP: mcp.json card-sha256=BASE64URL base64url of reg.CapabilitiesHash when set card-sha256 and capabilities_hash are the section 4.4.2 cross-check encodings of the same SHA-256 (DNS uses base64url, TL uses hex). When the operator did not submit agentCardContent, the SvcParam is absent and verifiers fall back to TOFU on first Trust Card fetch. Adds verifySVCB to LookupVerifier mirroring verifyHTTPS. Tests cover present-matching, absent (zone has different name), and wrong-target cases (AliasMode where ServiceMode was expected). Provisional SvcParams (wk, card-sha256) are unit-tested at the domain layer because miekg/dns rejects them in zone-file form until IANA registration; the verifier- level test exercises only registered SvcParamKeys (alpn, port). Required=false: section 4.4.2 marks Consolidated Approach SVCB as MAY, opt-in during the _ans TXT transition. Signed-off-by: kperry --- internal/adapter/dns/dns_test.go | 80 +++++++++++++ internal/adapter/dns/lookup.go | 48 ++++++++ internal/domain/dnsrecords.go | 105 ++++++++++++++++- internal/domain/dnsrecords_test.go | 173 ++++++++++++++++++++++++++--- 4 files changed, 392 insertions(+), 14 deletions(-) diff --git a/internal/adapter/dns/dns_test.go b/internal/adapter/dns/dns_test.go index 0cb0347..7193388 100644 --- a/internal/adapter/dns/dns_test.go +++ b/internal/adapter/dns/dns_test.go @@ -252,6 +252,86 @@ func TestLookupVerifier_HTTPSMatch(t *testing.T) { } } +// TestLookupVerifier_SVCBMatch covers the Consolidated Approach SVCB +// record at the bare agent FQDN. The expected value is the same +// presentation form the RA's ComputeRequiredDNSRecords emits (see +// internal/domain/dnsrecords.go), and the verifier matches after +// whitespace normalization mirroring verifyHTTPS. +// +// Restricted to IANA-registered SvcParamKeys (alpn + port) because the +// miekg/dns zone-file parser used by the test fixture rejects symbolic +// names for the still-provisional Consolidated Approach SvcParams (`wk`, +// `card-sha256`, `cap`, etc.). Until those keys are IANA-registered per +// RFC 9460 §6, the test exercises the verifier dispatch and matching +// path with registered keys; the unregistered keys are unit-tested at +// the domain layer (internal/domain/dnsrecords_test.go). +func TestLookupVerifier_SVCBMatch(t *testing.T) { + t.Parallel() + s := newTestServer(t) + s.add("agent.example.com.", "SVCB", + `agent.example.com. 3600 IN SVCB 1 . alpn=a2a port=443`) + + recs := []domain.ExpectedDNSRecord{{ + Name: "agent.example.com", + Type: domain.DNSRecordSVCB, + Value: `1 . alpn=a2a port=443`, + Required: false, + }} + got := s.verifyAgainst(t, recs) + if !got[0].found { + t.Errorf("SVCB should match; got=%+v", got[0]) + } +} + +// TestLookupVerifier_SVCBMissing covers the absent-record path. The +// agent's zone never published the SVCB record (or it was removed). +// Verifier reports not-found without an error (NXDOMAIN-style empty +// answer). +func TestLookupVerifier_SVCBMissing(t *testing.T) { + t.Parallel() + s := newTestServer(t) + // Different name in the zone — query for the agent's FQDN returns + // no SVCB answers. + s.add("other.example.com.", "SVCB", + `other.example.com. 3600 IN SVCB 1 . alpn=a2a`) + + recs := []domain.ExpectedDNSRecord{{ + Name: "agent.example.com", + Type: domain.DNSRecordSVCB, + Value: `1 . alpn=a2a`, + Required: false, + }} + got := s.verifyAgainst(t, recs) + if got[0].found { + t.Error("SVCB must not be Found when the zone has no matching record") + } +} + +// TestLookupVerifier_SVCBWrongTargetMissesMatch confirms that a record +// with the right alpn but a different SvcPriority/TargetName does not +// satisfy the expectation. Matching is on the full normalized +// presentation form, so a TargetName mismatch fails the comparison. +func TestLookupVerifier_SVCBWrongTargetMissesMatch(t *testing.T) { + t.Parallel() + s := newTestServer(t) + // AliasMode (priority 0) at agent.example.com pointing at a + // hosting target — different shape than what the RA expects in + // ServiceMode (priority 1). + s.add("agent.example.com.", "SVCB", + `agent.example.com. 3600 IN SVCB 0 host.provider.example.`) + + recs := []domain.ExpectedDNSRecord{{ + Name: "agent.example.com", + Type: domain.DNSRecordSVCB, + Value: `1 . alpn=a2a`, + Required: false, + }} + got := s.verifyAgainst(t, recs) + if got[0].found { + t.Error("ServiceMode expectation should not match an AliasMode record") + } +} + func TestLookupVerifier_NXDOMAINSurfacedAsError(t *testing.T) { t.Parallel() s := newTestServer(t) diff --git a/internal/adapter/dns/lookup.go b/internal/adapter/dns/lookup.go index ed5cc33..2c695d7 100644 --- a/internal/adapter/dns/lookup.go +++ b/internal/adapter/dns/lookup.go @@ -86,6 +86,8 @@ func (v *LookupVerifier) VerifyRecords( r = v.verifyTLSA(lookupCtx, server, rec) case domain.DNSRecordHTTPS: r = v.verifyHTTPS(lookupCtx, server, rec) + case domain.DNSRecordSVCB: + r = v.verifySVCB(lookupCtx, server, rec) default: r.Error = fmt.Sprintf("unsupported record type: %s", rec.Type) } @@ -253,6 +255,52 @@ func formatHTTPSValue(s *dns.SVCB) string { return sb.String() } +// verifySVCB checks for a Consolidated Approach SVCB record (RFC 9460) +// at the agent's bare FQDN. Multiple SVCB records can share one RRset +// name distinguished by alpn, so verification iterates the answer +// section, normalizes each record's wire form, and matches against +// the expected SvcParams. The matching strategy mirrors verifyHTTPS: +// the expected value carries every SvcParam the RA computed (alpn, +// port, wk, card-sha256), and the live record MUST carry the same +// SvcParams in the same alpn-keyed form. +// +// SvcParam unknown-key ignore semantics (RFC 9460 §8) apply at the +// client, not at this verifier — we only check that the SvcParams +// the RA committed are present, not that the live record is free of +// extra SvcParams from other ecosystems. Other agentic specs adding +// their own SvcParams alongside ours is the entire point of the +// Consolidated Approach. +func (v *LookupVerifier) verifySVCB(ctx context.Context, server string, rec domain.ExpectedDNSRecord) port.RecordVerification { + r := port.RecordVerification{Record: rec} + resp, err := v.exchange(ctx, server, rec.Name, dns.TypeSVCB) + if err != nil { + r.Error = err.Error() + return r + } + if resp.Rcode != dns.RcodeSuccess { + r.Error = fmt.Sprintf("rcode %s", dns.RcodeToString[resp.Rcode]) + return r + } + r.DNSSECVerified = resp.AuthenticatedData + wantNorm := normalizeHTTPS(rec.Value) + for _, rr := range resp.Answer { + svcb, ok := rr.(*dns.SVCB) + if !ok { + continue + } + got := formatHTTPSValue(svcb) + if r.Actual == "" { + r.Actual = got + } + if normalizeHTTPS(got) == wantNorm { + r.Found = true + r.Actual = got + return r + } + } + return r +} + // normalizeTLSA collapses whitespace and lowercases the hex so // "3 1 1 abcd..." matches "3 1 1 ABCD...". func normalizeTLSA(s string) string { diff --git a/internal/domain/dnsrecords.go b/internal/domain/dnsrecords.go index e1d1794..422cc8d 100644 --- a/internal/domain/dnsrecords.go +++ b/internal/domain/dnsrecords.go @@ -1,6 +1,10 @@ package domain -import "fmt" +import ( + "encoding/base64" + "encoding/hex" + "fmt" +) // DNSRecordType represents a DNS record type. type DNSRecordType string @@ -9,6 +13,14 @@ const ( DNSRecordTXT DNSRecordType = "TXT" DNSRecordTLSA DNSRecordType = "TLSA" DNSRecordHTTPS DNSRecordType = "HTTPS" + // DNSRecordSVCB is the cross-draft "Consolidated Approach" service + // binding record (RFC 9460) emitted at the agent's bare FQDN. One + // SVCB record per protocol carries that protocol's connection hints + // and capability locators in a single DNS lookup. SvcParams from + // DNS-AID, ANS, and other agentic specs coexist in the same record + // per RFC 9460 §8 unknown-key ignore semantics. See §4.4.2 of + // https://github.com/godaddy/ans-registry/blob/main/DESIGN.md. + DNSRecordSVCB DNSRecordType = "SVCB" ) // DNSRecordPurpose describes why a DNS record is needed. @@ -58,6 +70,56 @@ func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { }) } + // Consolidated Approach SVCB record at the bare FQDN — one per + // protocol endpoint. RFC 9460 ServiceMode (SvcPriority 1) with + // TargetName "." (same name) so address resolution stays at the + // agent's FQDN. SvcParams from DNS-AID, ANS, and other agentic + // specs coexist via RFC 9460 §8 unknown-key ignore. card-sha256 + // carries base64url(reg.CapabilitiesHash) when the operator + // submitted agentCardContent; otherwise the SvcParam is absent + // and a verifier falls back to TOFU on first Trust Card fetch. + // + // Provisional-key note: `wk` and `card-sha256` are not yet + // IANA-registered SvcParamKeys per RFC 9460 §6. The Consolidated + // Approach draft emits them by symbolic name; production + // deployments using strict-RFC parsers MAY need to publish them + // in keyNNNNN form until registration completes. The expected + // value the RA writes here uses the symbolic form to match the + // draft's worked examples; the verifier compares post- + // normalization, and operators whose authoritative DNS only + // emits keyNNNNN form will see a mismatch the RA reports as a + // non-blocking integrity finding (Required=false below). + // + // Required=false: §4.4.2 marks the Consolidated Approach as MAY, + // opt-in alongside the `_ans` TXT family during the transition. + cardSHA := capabilitiesHashBase64URL(reg.CapabilitiesHash) + for _, ep := range reg.Endpoints { + alpn := protocolToANSValue(ep.Protocol) + wk := wkPathFor(ep.Protocol) + // RFC 9460 §2.1 presentation form: unquoted SvcParamValue when + // the value has no characters special to the presentation + // format. alpn tokens (a2a, mcp), port digits, well-known path + // suffixes (agent-card.json), and base64url digests all qualify. + // The resolver-side formatter (formatHTTPSValue) also emits + // unquoted, so the verifier's normalize+compare matches without + // quote-stripping. + value := fmt.Sprintf(`1 . alpn=%s port=443`, alpn) + if wk != "" { + value += fmt.Sprintf(` wk=%s`, wk) + } + if cardSHA != "" { + value += fmt.Sprintf(` card-sha256=%s`, cardSHA) + } + records = append(records, ExpectedDNSRecord{ + Name: fqdn, + Type: DNSRecordSVCB, + Value: value, + Purpose: PurposeDiscovery, + Required: false, + TTL: 3600, + }) + } + // _ans-badge TXT record — trust badge. Required alongside _ans: // resolvers and badge-verifying clients expect to find both, and // publishing _ans without _ans-badge would advertise an agent @@ -118,3 +180,44 @@ func protocolToANSValue(p Protocol) string { return string(p) } } + +// wkPathFor returns the suffix-only well-known path published in the +// Consolidated Approach SVCB record's `wk=` SvcParam. Suffix-only matches +// the consolidated-draft examples (§4 line 134); clients prepend +// `/.well-known/` to construct the full path. Empty result means the +// caller SHOULD omit `wk=` entirely (e.g., direct-mode agents that +// expose no canonical metadata file). +// +// A2A: `agent-card.json` (IANA-registered well-known per A2A spec). +// MCP: `mcp.json` (de-facto convention; see SEP-1649 progress). +// HTTP-API: empty (no per-protocol metadata file convention). +func wkPathFor(p Protocol) string { + switch p { + case ProtocolA2A: + return "agent-card.json" + case ProtocolMCP: + return "mcp.json" + default: + return "" + } +} + +// capabilitiesHashBase64URL re-encodes a hex-lowercase SHA-256 digest +// (the form `AgentRegistration.CapabilitiesHash` carries) into the +// base64url form (RFC 4648 §5, no padding) the SVCB `card-sha256` +// SvcParam expects. Empty input returns empty output, which the caller +// SHOULD treat as "omit the SvcParam entirely" — agents registered +// without `agentCardContent` have no committed value to publish. +func capabilitiesHashBase64URL(hexDigest string) string { + if hexDigest == "" { + return "" + } + raw, err := hex.DecodeString(hexDigest) + if err != nil || len(raw) == 0 { + // Malformed input is logically equivalent to absence; the RA + // stores well-formed hex by construction (helpers.go: + // hashAgentCardContent), but defensive on the boundary. + return "" + } + return base64.RawURLEncoding.EncodeToString(raw) +} diff --git a/internal/domain/dnsrecords_test.go b/internal/domain/dnsrecords_test.go index 9929dec..100e64d 100644 --- a/internal/domain/dnsrecords_test.go +++ b/internal/domain/dnsrecords_test.go @@ -21,21 +21,37 @@ func TestComputeRequiredDNSRecords_WithoutCert(t *testing.T) { records := ComputeRequiredDNSRecords(reg) require.NotEmpty(t, records) - // 2 endpoints → 2 _ans TXT records + 1 badge record. - var anxCount, badgeCount, tlsaCount int + // 2 endpoints → 2 _ans TXT + 2 Consolidated Approach SVCB + + // 1 badge TXT (no TLSA: no cert). + var ansTxtCount, svcbCount, badgeCount, tlsaCount int for _, r := range records { switch r.Purpose { case PurposeDiscovery: - anxCount++ - assert.Equal(t, DNSRecordTXT, r.Type) - assert.True(t, strings.HasPrefix(r.Name, "_ans.")) - assert.True(t, r.Required) - assert.Contains(t, r.Value, "v=ans1") - // Version is bare semver, not DNS-label form — TXT - // payloads carry the machine-parseable semver directly. - assert.Contains(t, r.Value, "version=1.2.3") - assert.NotContains(t, r.Value, "v1.2.3", "no v-prefix in TXT payload") - assert.NotContains(t, r.Value, "1-2-3", "no dash form anywhere") + switch r.Type { + case DNSRecordTXT: + ansTxtCount++ + assert.True(t, strings.HasPrefix(r.Name, "_ans.")) + assert.True(t, r.Required) + assert.Contains(t, r.Value, "v=ans1") + // Version is bare semver, not DNS-label form — TXT + // payloads carry the machine-parseable semver directly. + assert.Contains(t, r.Value, "version=1.2.3") + assert.NotContains(t, r.Value, "v1.2.3", "no v-prefix in TXT payload") + assert.NotContains(t, r.Value, "1-2-3", "no dash form anywhere") + case DNSRecordSVCB: + svcbCount++ + assert.Equal(t, "agent.example.com", r.Name, + "Consolidated Approach SVCB at the bare FQDN, not at _ans.{fqdn}") + assert.False(t, r.Required, "Consolidated Approach SVCB is MAY per §4.4.2") + assert.Contains(t, r.Value, `1 . `, "ServiceMode (priority 1) with TargetName .") + assert.Contains(t, r.Value, "alpn=", "alpn distinguishes protocols within the RRset") + assert.Contains(t, r.Value, "port=443") + // No agentCardContent submitted in this fixture, so + // card-sha256 should be absent. + assert.NotContains(t, r.Value, "card-sha256") + default: + t.Errorf("unexpected discovery record type %q", r.Type) + } case PurposeBadge: badgeCount++ assert.Equal(t, DNSRecordTXT, r.Type) @@ -48,11 +64,142 @@ func TestComputeRequiredDNSRecords_WithoutCert(t *testing.T) { } } - assert.Equal(t, 2, anxCount) + assert.Equal(t, 2, ansTxtCount) + assert.Equal(t, 2, svcbCount, "one SVCB row per protocol at the bare FQDN") assert.Equal(t, 1, badgeCount) assert.Equal(t, 0, tlsaCount, "no cert → no TLSA record") } +// TestComputeRequiredDNSRecords_SVCBWkPath pins the per-protocol `wk=` +// SvcParam value the Consolidated Approach SVCB carries. A2A maps to +// `agent-card.json` (IANA-registered); MCP maps to `mcp.json` (de-facto +// convention). Suffix-only — the consolidated draft's primary examples +// use the suffix and clients prepend `/.well-known/`. +func TestComputeRequiredDNSRecords_SVCBWkPath(t *testing.T) { + ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") + reg := &AgentRegistration{ + AnsName: ansName, + Endpoints: []AgentEndpoint{ + {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com"}, + {Protocol: ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, + }, + } + records := ComputeRequiredDNSRecords(reg) + + for _, r := range records { + if r.Type != DNSRecordSVCB { + continue + } + switch { + case strings.Contains(r.Value, `alpn=a2a`): + assert.Contains(t, r.Value, `wk=agent-card.json`) + case strings.Contains(r.Value, `alpn=mcp`): + assert.Contains(t, r.Value, `wk=mcp.json`) + default: + t.Errorf("SVCB row missing recognized alpn: %q", r.Value) + } + } +} + +// TestComputeRequiredDNSRecords_SVCBCardSHA256_PresentWhenSet verifies +// that an agent registered with agentCardContent emits SVCB rows whose +// card-sha256 SvcParam is the base64url form of reg.CapabilitiesHash. +// This is the DNS half of §4.4.2's three-way cross-check (the live +// Trust Card body, the TL-sealed capabilities_hash, and the SVCB +// card-sha256 all commit to the same SHA-256). +func TestComputeRequiredDNSRecords_SVCBCardSHA256_PresentWhenSet(t *testing.T) { + ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") + // Fixture digest used across the cross-check — the same hex appears + // in the TL event's attestations.metadataHashes.capabilitiesHash. + hexDigest := "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27" + wantBase64 := "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc" + reg := &AgentRegistration{ + AnsName: ansName, + CapabilitiesHash: hexDigest, + Endpoints: []AgentEndpoint{ + {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com"}, + }, + } + records := ComputeRequiredDNSRecords(reg) + + var sawSVCB bool + for _, r := range records { + if r.Type != DNSRecordSVCB { + continue + } + sawSVCB = true + assert.Contains(t, r.Value, `card-sha256=`+wantBase64, + "SVCB card-sha256 must be base64url(decoded hex of reg.CapabilitiesHash)") + } + assert.True(t, sawSVCB, "expected at least one SVCB row") +} + +// TestComputeRequiredDNSRecords_SVCBCardSHA256_AbsentWhenUnset verifies +// the spec-conformant "no agentCardContent submitted" path: the SVCB +// row omits the card-sha256 SvcParam entirely. A verifier seeing no +// SvcParam falls back to TOFU on first Trust Card fetch (§4.4.2). +func TestComputeRequiredDNSRecords_SVCBCardSHA256_AbsentWhenUnset(t *testing.T) { + ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") + reg := &AgentRegistration{ + AnsName: ansName, + Endpoints: []AgentEndpoint{ + {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com"}, + }, + } + records := ComputeRequiredDNSRecords(reg) + for _, r := range records { + if r.Type == DNSRecordSVCB { + assert.NotContains(t, r.Value, "card-sha256", + "no agentCardContent → SVCB has no card-sha256 SvcParam") + } + } +} + +// TestCapabilitiesHashBase64URL pins the hex→base64url conversion. +func TestCapabilitiesHashBase64URL(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "live_webmesh_trust_card_digest", + in: "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27", + want: "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc", + }, + { + name: "all_zeros", + in: "0000000000000000000000000000000000000000000000000000000000000000", + want: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }, + { + name: "empty_input_empty_output", + in: "", + want: "", + }, + { + name: "malformed_hex_returns_empty", + in: "not hex", + want: "", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := capabilitiesHashBase64URL(tc.in) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestWkPathFor pins the per-protocol well-known suffix mapping. +func TestWkPathFor(t *testing.T) { + assert.Equal(t, "agent-card.json", wkPathFor(ProtocolA2A)) + assert.Equal(t, "mcp.json", wkPathFor(ProtocolMCP)) + assert.Equal(t, "", wkPathFor(ProtocolHTTPAPI), + "HTTP-API has no per-protocol metadata file convention") + assert.Equal(t, "", wkPathFor(Protocol("UNKNOWN"))) +} + func TestComputeRequiredDNSRecords_WithCert(t *testing.T) { ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") reg := &AgentRegistration{ From 47b5768e0dc9cea388040b9b2c8f106bf21c22ea Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Sat, 16 May 2026 10:35:58 -0400 Subject: [PATCH 02/13] feat(ra): dnsRecordStyle on V2 register controls DNS-record family Adds dnsRecordStyle to the V2 RegistrationRequest with three values: "consolidated" (default, recommended), "legacy" (original _ans TXT shape), "both" (transition union). Empty -> consolidated. Invalid -> 422 INVALID_DNS_RECORD_STYLE. The default points new integrations at the lean Consolidated Approach shape per section 4.4.2 SHOULD: one SVCB record at the bare FQDN per protocol, plus shared _ans-prefixed records and TLSA. Operators on existing zone-edit tooling for _ans TXT pick "legacy" explicitly. Migration operators set "both" for a defined window then flip back to "consolidated". V1 lane pins to "legacy" regardless of the request because V1 callers predate the Consolidated Approach and their tooling expects the original shape. V1 has no dnsRecordStyle field on the wire. Migration 007 adds the dns_record_style column on agent_registrations. Nullable for backwards compatibility with pre-Plan-D rows. Tests: - "both" emits 2x _ans TXT + 2x SVCB + shared records (existing test updated to set DNSRecordStyleBoth so it exercises the union path). - New tests cover "consolidated" (no _ans TXT), "legacy" (no SVCB), and "both" (union); the SvcParam wk/card-sha256 tests already covered the consolidated path implicitly. - Lint: extracted applyDNSRecordStyle helper to keep RegisterAgent under the funlen ceiling. Signed-off-by: kperry --- internal/adapter/docsui/openapi/ra.yaml | 22 +++ internal/adapter/store/sqlite/agent.go | 10 +- .../migrations/007_agent_dns_record_style.sql | 21 +++ internal/domain/agent.go | 15 ++ internal/domain/dnsrecords.go | 153 +++++++++++++----- internal/domain/dnsrecords_test.go | 4 + internal/ra/handler/registration.go | 9 ++ internal/ra/service/helpers.go | 27 ++++ internal/ra/service/registration.go | 12 ++ spec/api-spec-v2.yaml | 22 +++ 10 files changed, 257 insertions(+), 38 deletions(-) create mode 100644 internal/adapter/store/sqlite/migrations/007_agent_dns_record_style.sql diff --git a/internal/adapter/docsui/openapi/ra.yaml b/internal/adapter/docsui/openapi/ra.yaml index 4e3c49a..2422c8c 100644 --- a/internal/adapter/docsui/openapi/ra.yaml +++ b/internal/adapter/docsui/openapi/ra.yaml @@ -1065,6 +1065,28 @@ components: type: string identityCsrPEM: type: string + dnsRecordStyle: + type: string + enum: [consolidated, legacy, both] + description: | + Selects which DNS record family the RA emits for this + registration. Surfaces on the 202 register response's + dnsRecords[], on GET /v2/ans/agents/{agentId}, and on the + AGENT_REGISTERED TL event's + attestations.dnsRecordsProvisioned[]. + + consolidated (default, recommended): Consolidated + Approach SVCB rows at the bare FQDN per ANS_SPEC.md + §4.4.2, plus shared `_ans-`-prefixed records and TLSA. + legacy: original `_ans` TXT shape, supported + indefinitely for operators on existing zone-edit + tooling that targets `_ans.{fqdn}`. + both: union; the §4.4.2 transition shape. + + Empty/missing → consolidated. Default points new + integrations at the lean shape per §4.4.2 SHOULD. + default: "consolidated" + example: "consolidated" required: - agentDisplayName - version diff --git a/internal/adapter/store/sqlite/agent.go b/internal/adapter/store/sqlite/agent.go index a06be3f..6595b06 100644 --- a/internal/adapter/store/sqlite/agent.go +++ b/internal/adapter/store/sqlite/agent.go @@ -39,6 +39,7 @@ type agentRow struct { SupersedesRegistrationID sql.NullInt64 `db:"supersedes_registration_id"` ACMEDNS01Token sql.NullString `db:"acme_dns01_token"` ACMEChallengeExpiresAtMs sql.NullInt64 `db:"acme_challenge_expires_at_ms"` + DNSRecordStyle sql.NullString `db:"dns_record_style"` CreatedAtMs int64 `db:"created_at_ms"` UpdatedAtMs int64 `db:"updated_at_ms"` } @@ -73,6 +74,9 @@ func (r agentRow) toDomain() (*domain.AgentRegistration, error) { if r.ACMEChallengeExpiresAtMs.Valid { reg.ACMEChallenge.ExpiresAt = msToTime(r.ACMEChallengeExpiresAtMs.Int64) } + if r.DNSRecordStyle.Valid { + reg.DNSRecordStyle = domain.DNSRecordStyle(r.DNSRecordStyle.String) + } return reg, nil } @@ -93,8 +97,9 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) registration_timestamp_ms, last_renewal_timestamp_ms, supersedes_registration_id, acme_dns01_token, acme_challenge_expires_at_ms, + dns_record_style, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` res, err := s.db.extx(ctx).ExecContext(ctx, q, agent.AgentID, agent.OwnerID, @@ -109,6 +114,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) nullableInt64(agent.SupersedesRegistrationID), nullableString(agent.ACMEChallenge.DNS01Token), nullableMs(agent.ACMEChallenge.ExpiresAt), + nullableString(string(agent.DNSRecordStyle)), now, now, ) if err != nil { @@ -131,6 +137,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) supersedes_registration_id = ?, acme_dns01_token = ?, acme_challenge_expires_at_ms = ?, + dns_record_style = ?, updated_at_ms = ? WHERE id = ?` _, err := s.db.extx(ctx).ExecContext(ctx, q, @@ -141,6 +148,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) nullableInt64(agent.SupersedesRegistrationID), nullableString(agent.ACMEChallenge.DNS01Token), nullableMs(agent.ACMEChallenge.ExpiresAt), + nullableString(string(agent.DNSRecordStyle)), now, agent.ID, ) diff --git a/internal/adapter/store/sqlite/migrations/007_agent_dns_record_style.sql b/internal/adapter/store/sqlite/migrations/007_agent_dns_record_style.sql new file mode 100644 index 0000000..422b86c --- /dev/null +++ b/internal/adapter/store/sqlite/migrations/007_agent_dns_record_style.sql @@ -0,0 +1,21 @@ +-- 007_agent_dns_record_style.sql +-- Persist the operator's chosen DNS-record-style on the registration +-- row so the verify-acme/verify-dns flow and the badge response carry +-- the same shape the operator chose at registration time. +-- +-- One of: +-- "consolidated" — Consolidated Approach SVCB rows + shared records +-- (default; recommended; aligned with §4.4.2). +-- "legacy" — original `_ans` TXT shape + shared records. +-- Backwards-compatible with operators registered +-- before the Consolidated Approach landed. +-- "both" — union; the §4.4.2 transition shape for operators +-- running both record families during migration. +-- +-- Nullable for backwards compatibility with agents registered before +-- this migration. The domain helper ComputeRequiredDNSRecords treats +-- empty value as the default ("consolidated") via DefaultDNSRecordStyle, +-- so old agents do not lose attestation behavior. + +ALTER TABLE agent_registrations + ADD COLUMN dns_record_style TEXT; diff --git a/internal/domain/agent.go b/internal/domain/agent.go index 71efd91..aa3fde7 100644 --- a/internal/domain/agent.go +++ b/internal/domain/agent.go @@ -103,6 +103,21 @@ type AgentRegistration struct { // deviation. ACMEChallenge ACMEChallenge `json:"acmeChallenge,omitzero"` + // CapabilitiesHash is the hex-lowercase SHA-256 digest of the + // operator's submitted Trust Card body. SVCB record emission + // surfaces it as the `card-sha256=` SvcParam (base64url-encoded) + // when non-empty; empty leaves the SvcParam absent and verifiers + // fall back to TOFU. PR13 carries the read-side surface; the + // populator path is intentionally not wired in this PR. + CapabilitiesHash string `json:"capabilitiesHash,omitempty"` + + // DNSRecordStyle selects which DNS record family the RA emits + // for this registration: "consolidated" (Consolidated Approach + // SVCB rows, default), "legacy" (the original `_ans` TXT shape), + // or "both" (the transition union). Empty at the domain layer + // is treated as DefaultDNSRecordStyle by ComputeRequiredDNSRecords. + DNSRecordStyle DNSRecordStyle `json:"dnsRecordStyle,omitempty"` + // PendingEvents holds domain events raised during this aggregate operation. // They are cleared after being published. PendingEvents []Event `json:"-"` diff --git a/internal/domain/dnsrecords.go b/internal/domain/dnsrecords.go index 422cc8d..8fb44f7 100644 --- a/internal/domain/dnsrecords.go +++ b/internal/domain/dnsrecords.go @@ -6,6 +6,59 @@ import ( "fmt" ) +// DNSRecordStyle selects which DNS record family the RA emits in its +// dnsRecordsProvisioned attestation and in the records it tells the +// operator to publish at registration time. +// +// Default is "consolidated": one SVCB record per protocol at the +// agent's bare FQDN per the cross-draft Consolidated Approach (§4.4.2). +// Operators on infrastructure that already publishes the legacy +// `_ans` TXT family pick "legacy". Migration operators pick "both" +// for a defined window, then flip back to "consolidated". +// +// Legacy MUST stay supported indefinitely. Operators picking "legacy" +// will continue to receive the original `_ans` TXT shape this RA has +// emitted since v0.1.x. The cross-channel hash consistency check +// (§4.4.2) only applies when the SVCB record is present, so "legacy" +// agents do not benefit from the card-sha256 ↔ capabilities_hash +// guarantee — that is a property of the chosen style, not a defect. +type DNSRecordStyle string + +const ( + // DNSRecordStyleConsolidated emits Consolidated Approach SVCB + // records (one per protocol, bare-FQDN owner) plus the + // `_ans-prefixed` records that no SvcParam covers (badge, + // identity DANE) plus the server-cert TLSA. The default. + DNSRecordStyleConsolidated DNSRecordStyle = "consolidated" + + // DNSRecordStyleLegacy emits the original `_ans` TXT family + // (one per protocol) plus the same `_ans-`-prefixed records + // plus the server-cert TLSA. No SVCB rows. + DNSRecordStyleLegacy DNSRecordStyle = "legacy" + + // DNSRecordStyleBoth emits the union of Consolidated Approach + // SVCB and legacy `_ans` TXT — the transition shape per §4.4.2 + // where the two record families coexist on the same agent's zone. + DNSRecordStyleBoth DNSRecordStyle = "both" +) + +// DefaultDNSRecordStyle is the style applied when the registration +// request omits dnsRecordStyle entirely. Pinned to "consolidated" so +// new integrations follow §4.4.2's "publish one SVCB record... rather +// than parallel per-ecosystem record trees" SHOULD by default. +const DefaultDNSRecordStyle = DNSRecordStyleConsolidated + +// IsValid reports whether s is one of the three defined styles. +// Empty string is treated as invalid; callers normalize empty to +// DefaultDNSRecordStyle before validation. +func (s DNSRecordStyle) IsValid() bool { + switch s { + case DNSRecordStyleConsolidated, DNSRecordStyleLegacy, DNSRecordStyleBoth: + return true + } + return false +} + // DNSRecordType represents a DNS record type. type DNSRecordType string @@ -46,6 +99,21 @@ type ExpectedDNSRecord struct { // ComputeRequiredDNSRecords generates the DNS records an operator must create // for a given agent registration. The RA does not create these records — the // operator manages their own DNS. The RA only verifies they exist. +// +// The set of records emitted depends on reg.DNSRecordStyle: +// +// - "consolidated" (default, recommended): Consolidated Approach SVCB +// rows (one per protocol) plus the shared `_ans-`-prefixed records +// plus the server-cert TLSA. No legacy `_ans` TXT rows. +// - "legacy": the original `_ans` TXT shape (one row per protocol) +// plus the same shared records. No SVCB rows. Backwards-compatible +// with operators who registered before the Consolidated Approach +// landed and have existing zone-edit tooling for `_ans` TXT. +// - "both": union of consolidated + legacy. The §4.4.2 transition +// shape; operators run both record families on the same zone for +// a defined window, then flip back to "consolidated". +// +// Empty reg.DNSRecordStyle is normalized to DefaultDNSRecordStyle. func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { fqdn := reg.FQDN() // Version is emitted as a bare semver string ("1.2.0"). The @@ -54,20 +122,29 @@ func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { // directly, matching the shape a client would parse with any // semver library. version := reg.AnsName.Version().String() + style := reg.DNSRecordStyle + if !style.IsValid() { + style = DefaultDNSRecordStyle + } var records []ExpectedDNSRecord - // _ans TXT record for each protocol endpoint — agent discovery. - for _, ep := range reg.Endpoints { - value := fmt.Sprintf("v=ans1; version=%s; p=%s; mode=direct; url=%s", - version, protocolToANSValue(ep.Protocol), ep.AgentURL) - records = append(records, ExpectedDNSRecord{ - Name: fmt.Sprintf("_ans.%s", fqdn), - Type: DNSRecordTXT, - Value: value, - Purpose: PurposeDiscovery, - Required: true, - TTL: 3600, - }) + emitLegacy := style == DNSRecordStyleLegacy || style == DNSRecordStyleBoth + emitConsolidated := style == DNSRecordStyleConsolidated || style == DNSRecordStyleBoth + + // _ans TXT record for each protocol endpoint — legacy discovery. + if emitLegacy { + for _, ep := range reg.Endpoints { + value := fmt.Sprintf("v=ans1; version=%s; p=%s; mode=direct; url=%s", + version, protocolToANSValue(ep.Protocol), ep.AgentURL) + records = append(records, ExpectedDNSRecord{ + Name: fmt.Sprintf("_ans.%s", fqdn), + Type: DNSRecordTXT, + Value: value, + Purpose: PurposeDiscovery, + Required: true, + TTL: 3600, + }) + } } // Consolidated Approach SVCB record at the bare FQDN — one per @@ -92,32 +169,34 @@ func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { // // Required=false: §4.4.2 marks the Consolidated Approach as MAY, // opt-in alongside the `_ans` TXT family during the transition. - cardSHA := capabilitiesHashBase64URL(reg.CapabilitiesHash) - for _, ep := range reg.Endpoints { - alpn := protocolToANSValue(ep.Protocol) - wk := wkPathFor(ep.Protocol) - // RFC 9460 §2.1 presentation form: unquoted SvcParamValue when - // the value has no characters special to the presentation - // format. alpn tokens (a2a, mcp), port digits, well-known path - // suffixes (agent-card.json), and base64url digests all qualify. - // The resolver-side formatter (formatHTTPSValue) also emits - // unquoted, so the verifier's normalize+compare matches without - // quote-stripping. - value := fmt.Sprintf(`1 . alpn=%s port=443`, alpn) - if wk != "" { - value += fmt.Sprintf(` wk=%s`, wk) - } - if cardSHA != "" { - value += fmt.Sprintf(` card-sha256=%s`, cardSHA) + if emitConsolidated { + cardSHA := capabilitiesHashBase64URL(reg.CapabilitiesHash) + for _, ep := range reg.Endpoints { + alpn := protocolToANSValue(ep.Protocol) + wk := wkPathFor(ep.Protocol) + // RFC 9460 §2.1 presentation form: unquoted SvcParamValue when + // the value has no characters special to the presentation + // format. alpn tokens (a2a, mcp), port digits, well-known path + // suffixes (agent-card.json), and base64url digests all qualify. + // The resolver-side formatter (formatHTTPSValue) also emits + // unquoted, so the verifier's normalize+compare matches without + // quote-stripping. + value := fmt.Sprintf(`1 . alpn=%s port=443`, alpn) + if wk != "" { + value += fmt.Sprintf(` wk=%s`, wk) + } + if cardSHA != "" { + value += fmt.Sprintf(` card-sha256=%s`, cardSHA) + } + records = append(records, ExpectedDNSRecord{ + Name: fqdn, + Type: DNSRecordSVCB, + Value: value, + Purpose: PurposeDiscovery, + Required: false, + TTL: 3600, + }) } - records = append(records, ExpectedDNSRecord{ - Name: fqdn, - Type: DNSRecordSVCB, - Value: value, - Purpose: PurposeDiscovery, - Required: false, - TTL: 3600, - }) } // _ans-badge TXT record — trust badge. Required alongside _ans: diff --git a/internal/domain/dnsrecords_test.go b/internal/domain/dnsrecords_test.go index 100e64d..f5ac7a6 100644 --- a/internal/domain/dnsrecords_test.go +++ b/internal/domain/dnsrecords_test.go @@ -12,6 +12,10 @@ func TestComputeRequiredDNSRecords_WithoutCert(t *testing.T) { ansName, _ := NewAnsName(mustSemVer(1, 2, 3), "agent.example.com") reg := &AgentRegistration{ AnsName: ansName, + // Force "both" style so this fixture exercises the union path: + // _ans TXT + Consolidated Approach SVCB. Tests below cover the + // single-style emission paths. + DNSRecordStyle: DNSRecordStyleBoth, Endpoints: []AgentEndpoint{ {Protocol: ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com/a2a"}, diff --git a/internal/ra/handler/registration.go b/internal/ra/handler/registration.go index 51656bc..7a85e1b 100644 --- a/internal/ra/handler/registration.go +++ b/internal/ra/handler/registration.go @@ -39,6 +39,14 @@ type registrationRequest struct { ServerCsrPEM string `json:"serverCsrPEM,omitempty"` ServerCertificatePEM string `json:"serverCertificatePEM,omitempty"` ServerCertificateChainPEM string `json:"serverCertificateChainPEM,omitempty"` + + // DNSRecordStyle selects which DNS record family the RA emits + // for this registration. One of "consolidated" (default, + // recommended), "legacy" (original `_ans` TXT shape), "both" + // (transition union). Empty/missing → consolidated. Invalid + // value rejected with 422 INVALID_DNS_RECORD_STYLE. See + // ANS_SPEC.md §4.4.2 for record-shape semantics. + DNSRecordStyle string `json:"dnsRecordStyle,omitempty"` } type endpointDTO struct { @@ -153,6 +161,7 @@ func (h *RegistrationHandler) Register(w http.ResponseWriter, r *http.Request) { ServerCsrPEM: req.ServerCsrPEM, ServerCertificatePEM: req.ServerCertificatePEM, ServerCertificateChainPEM: req.ServerCertificateChainPEM, + DNSRecordStyle: domain.DNSRecordStyle(req.DNSRecordStyle), }) if err != nil { WriteError(w, err) diff --git a/internal/ra/service/helpers.go b/internal/ra/service/helpers.go index fda9cd6..f33fe80 100644 --- a/internal/ra/service/helpers.go +++ b/internal/ra/service/helpers.go @@ -11,6 +11,33 @@ import ( "github.com/godaddy/ans/internal/domain" ) +// applyDNSRecordStyle resolves the DNS-record-style for the new +// registration and stores it on the aggregate. +// +// V1 lane is pinned to "legacy" regardless of the request: V1 callers +// predate the Consolidated Approach and their tooling expects the +// original `_ans` TXT shape. V1 has no dnsRecordStyle field on the +// wire, so this branch is the only path V1 registrations take. +// V2 callers honor req.DNSRecordStyle: empty normalizes to +// DefaultDNSRecordStyle (consolidated); invalid values surface as +// INVALID_DNS_RECORD_STYLE. +func applyDNSRecordStyle(reg *domain.AgentRegistration, req RegisterRequest) error { + switch { + case req.SchemaVersion == "V1": + reg.DNSRecordStyle = domain.DNSRecordStyleLegacy + case req.DNSRecordStyle == "": + reg.DNSRecordStyle = domain.DefaultDNSRecordStyle + case !req.DNSRecordStyle.IsValid(): + return domain.NewValidationError( + "INVALID_DNS_RECORD_STYLE", + fmt.Sprintf("dnsRecordStyle %q is not one of consolidated, legacy, both", string(req.DNSRecordStyle)), + ) + default: + reg.DNSRecordStyle = req.DNSRecordStyle + } + return nil +} + // fingerprintOf returns the SHA-256 fingerprint of the DER certificate // inside the given PEM string, formatted as `SHA256:`. // The `SHA256:` prefix matches the algorithm-prefixed form the diff --git a/internal/ra/service/registration.go b/internal/ra/service/registration.go index 8c3c050..36bb067 100644 --- a/internal/ra/service/registration.go +++ b/internal/ra/service/registration.go @@ -72,6 +72,14 @@ type RegisterRequest struct { ServerCertificatePEM string ServerCertificateChainPEM string SchemaVersion string + + // DNSRecordStyle selects which DNS record family the RA emits + // in dnsRecordsProvisioned and tells the operator to publish. + // "consolidated" (default), "legacy", or "both". Empty value is + // normalized to domain.DefaultDNSRecordStyle. Invalid value + // surfaces as INVALID_DNS_RECORD_STYLE before the aggregate is + // created. + DNSRecordStyle domain.DNSRecordStyle } // RegisterResponse is returned to the HTTP handler after a successful @@ -310,6 +318,10 @@ func (s *RegistrationService) RegisterAgent(ctx context.Context, req RegisterReq } reg.ServerCSR = pendingServerCSR + if err := applyDNSRecordStyle(reg, req); err != nil { + return nil, err + } + // Generate the ACME DNS-01 challenge token + expiry. The only // DNS action the operator should take before verify-acme. dns01, _, err := generateChallengeTokens() diff --git a/spec/api-spec-v2.yaml b/spec/api-spec-v2.yaml index 4e3c49a..2422c8c 100644 --- a/spec/api-spec-v2.yaml +++ b/spec/api-spec-v2.yaml @@ -1065,6 +1065,28 @@ components: type: string identityCsrPEM: type: string + dnsRecordStyle: + type: string + enum: [consolidated, legacy, both] + description: | + Selects which DNS record family the RA emits for this + registration. Surfaces on the 202 register response's + dnsRecords[], on GET /v2/ans/agents/{agentId}, and on the + AGENT_REGISTERED TL event's + attestations.dnsRecordsProvisioned[]. + + consolidated (default, recommended): Consolidated + Approach SVCB rows at the bare FQDN per ANS_SPEC.md + §4.4.2, plus shared `_ans-`-prefixed records and TLSA. + legacy: original `_ans` TXT shape, supported + indefinitely for operators on existing zone-edit + tooling that targets `_ans.{fqdn}`. + both: union; the §4.4.2 transition shape. + + Empty/missing → consolidated. Default points new + integrations at the lean shape per §4.4.2 SHOULD. + default: "consolidated" + example: "consolidated" required: - agentDisplayName - version From e920b8d05f57ac9b0fa50de922173656a8db9a03 Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Sat, 16 May 2026 10:51:37 -0400 Subject: [PATCH 03/13] feat(dns): emit HTTPS RR alongside legacy _ans TXT family Closes a long-standing spec/impl gap: ANS_SPEC.md section A.8.1 lists the HTTPS RR (RFC 9460 type 65) at the agent FQDN as RA-generated content the AHP provisions, but ComputeRequiredDNSRecords had never emitted it. The DNSRecordHTTPS enum value and verifyHTTPS verifier were already in place; this commit wires the emission. Generated only for the legacy + both styles, not for consolidated: the SVCB rows the consolidated form publishes already carry the same alpn/port/ECH SvcParams the HTTPS RR would, so emitting both would duplicate content and risk the two records drifting (section A.8.2 explicitly notes this). Operators on the consolidated path who still want HTTPS-RR-aware clients (typically browsers) to see the metadata can publish their own HTTPS RR as a side addition. Required=false: HTTPS RR is blocked by CNAME at the agent FQDN per RFC 1034 section 3.6.2. AHPs whose apex is fronted via CNAME cannot publish it at the same name; the RA does not block verify-dns on its absence. Tests pin: legacy style includes HTTPS RR + no SVCB; consolidated style includes SVCB + no HTTPS RR; both style includes both families. Signed-off-by: kperry --- internal/domain/dnsrecords.go | 27 +++++++++++++ internal/domain/dnsrecords_test.go | 62 +++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/internal/domain/dnsrecords.go b/internal/domain/dnsrecords.go index 8fb44f7..b8185cc 100644 --- a/internal/domain/dnsrecords.go +++ b/internal/domain/dnsrecords.go @@ -145,6 +145,33 @@ func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { TTL: 3600, }) } + + // HTTPS RR (RFC 9460 type 65) at the agent FQDN — service + // binding for HTTP/2 (and Encrypted Client Hello when the + // AHP provides an ECH config out-of-band). Per §A.8.1 the + // RA generates the content; the AHP decides whether to + // publish based on whether their apex is aliased via CNAME + // (CNAME at the agent FQDN blocks HTTPS RR at the same name + // per RFC 1034 §3.6.2). + // + // Skipped for the consolidated form: the SVCB rows already + // carry alpn / port / ECH SvcParams, so an HTTPS RR + // alongside duplicates content (§A.8.2). Legacy keeps it + // because the `_ans` TXT family does not carry connection + // hints — clients without ANS-protocol awareness rely on + // HTTPS RR for ALPN signalling. + // + // Required=false: operators on CNAME-fronted apex zones + // cannot publish this record at the same name; the spec + // does not block them on its absence. + records = append(records, ExpectedDNSRecord{ + Name: fqdn, + Type: DNSRecordHTTPS, + Value: `1 . alpn=h2`, + Purpose: PurposeDiscovery, + Required: false, + TTL: 3600, + }) } // Consolidated Approach SVCB record at the bare FQDN — one per diff --git a/internal/domain/dnsrecords_test.go b/internal/domain/dnsrecords_test.go index f5ac7a6..7831b9a 100644 --- a/internal/domain/dnsrecords_test.go +++ b/internal/domain/dnsrecords_test.go @@ -25,9 +25,9 @@ func TestComputeRequiredDNSRecords_WithoutCert(t *testing.T) { records := ComputeRequiredDNSRecords(reg) require.NotEmpty(t, records) - // 2 endpoints → 2 _ans TXT + 2 Consolidated Approach SVCB + + // 2 endpoints → 2 _ans TXT + 1 HTTPS + 2 Consolidated Approach SVCB + // 1 badge TXT (no TLSA: no cert). - var ansTxtCount, svcbCount, badgeCount, tlsaCount int + var ansTxtCount, httpsCount, svcbCount, badgeCount, tlsaCount int for _, r := range records { switch r.Purpose { case PurposeDiscovery: @@ -53,6 +53,13 @@ func TestComputeRequiredDNSRecords_WithoutCert(t *testing.T) { // No agentCardContent submitted in this fixture, so // card-sha256 should be absent. assert.NotContains(t, r.Value, "card-sha256") + case DNSRecordHTTPS: + httpsCount++ + assert.Equal(t, "agent.example.com", r.Name, + "HTTPS RR at the bare FQDN per §A.8.1") + assert.False(t, r.Required, + "HTTPS RR is opt-in: blocked by CNAME at @ when AHP fronts the apex") + assert.Contains(t, r.Value, "alpn=h2") default: t.Errorf("unexpected discovery record type %q", r.Type) } @@ -69,11 +76,62 @@ func TestComputeRequiredDNSRecords_WithoutCert(t *testing.T) { } assert.Equal(t, 2, ansTxtCount) + assert.Equal(t, 1, httpsCount, "one HTTPS RR at the bare FQDN per §A.8.1") assert.Equal(t, 2, svcbCount, "one SVCB row per protocol at the bare FQDN") assert.Equal(t, 1, badgeCount) assert.Equal(t, 0, tlsaCount, "no cert → no TLSA record") } +// TestComputeRequiredDNSRecords_LegacyOnlyEmitsHTTPSRR pins the legacy +// shape: HTTPS RR is generated alongside the `_ans` TXT family, NOT +// alongside the consolidated SVCB rows (which would duplicate the +// alpn/port SvcParams). §A.8.1 lists the HTTPS RR as RA-generated +// content the AHP provisions when the apex isn't aliased via CNAME. +func TestComputeRequiredDNSRecords_LegacyOnlyEmitsHTTPSRR(t *testing.T) { + ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") + reg := &AgentRegistration{ + AnsName: ansName, + DNSRecordStyle: DNSRecordStyleLegacy, + Endpoints: []AgentEndpoint{ + {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com"}, + }, + } + records := ComputeRequiredDNSRecords(reg) + + var sawHTTPS, sawSVCB bool + for _, r := range records { + switch r.Type { + case DNSRecordHTTPS: + sawHTTPS = true + case DNSRecordSVCB: + sawSVCB = true + } + } + assert.True(t, sawHTTPS, "legacy style must include an HTTPS RR") + assert.False(t, sawSVCB, "legacy style must NOT include SVCB rows") +} + +// TestComputeRequiredDNSRecords_ConsolidatedOmitsHTTPSRR pins the +// consolidated form's lean shape: HTTPS RR is omitted because the +// SVCB rows already carry equivalent SvcParams (alpn, port, ECH). +// Publishing both would duplicate content and risk drift between +// the two records. §A.8.2 calls this out explicitly. +func TestComputeRequiredDNSRecords_ConsolidatedOmitsHTTPSRR(t *testing.T) { + ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") + reg := &AgentRegistration{ + AnsName: ansName, + DNSRecordStyle: DNSRecordStyleConsolidated, + Endpoints: []AgentEndpoint{ + {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com"}, + }, + } + records := ComputeRequiredDNSRecords(reg) + for _, r := range records { + assert.NotEqual(t, DNSRecordHTTPS, r.Type, + "consolidated style omits HTTPS RR (SVCB SvcParams subsume it)") + } +} + // TestComputeRequiredDNSRecords_SVCBWkPath pins the per-protocol `wk=` // SvcParam value the Consolidated Approach SVCB carries. A2A maps to // `agent-card.json` (IANA-registered); MCP maps to `mcp.json` (de-facto From cd12fd0e1e9fdd2f19ea3998967074da608c5a0b Mon Sep 17 00:00:00 2001 From: kperry Date: Wed, 20 May 2026 15:29:14 -0500 Subject: [PATCH 04/13] feat(dns): update DNS record style to use CONSTANT_CASE and enhance validation Signed-off-by: kperry --- internal/adapter/dns/dns_test.go | 145 +++++--- internal/adapter/dns/lookup.go | 8 + internal/adapter/docsui/openapi/ra.yaml | 33 +- .../migrations/007_agent_dns_record_style.sql | 33 +- internal/domain/dnsrecords.go | 67 +++- internal/domain/dnsrecords_test.go | 335 +++++++++++------- internal/port/dns.go | 11 +- internal/ra/service/helpers.go | 16 +- internal/ra/service/helpers_test.go | 106 ++++++ internal/ra/service/lifecycle.go | 39 +- spec/api-spec-v2.yaml | 33 +- 11 files changed, 571 insertions(+), 255 deletions(-) diff --git a/internal/adapter/dns/dns_test.go b/internal/adapter/dns/dns_test.go index 7193388..84f9741 100644 --- a/internal/adapter/dns/dns_test.go +++ b/internal/adapter/dns/dns_test.go @@ -252,83 +252,126 @@ func TestLookupVerifier_HTTPSMatch(t *testing.T) { } } -// TestLookupVerifier_SVCBMatch covers the Consolidated Approach SVCB -// record at the bare agent FQDN. The expected value is the same -// presentation form the RA's ComputeRequiredDNSRecords emits (see -// internal/domain/dnsrecords.go), and the verifier matches after -// whitespace normalization mirroring verifyHTTPS. +// TestLookupVerifier_SVCB exercises the Consolidated Approach SVCB +// verifier across match, missing, and shape-mismatch paths. The match +// case tests the same presentation form the RA's +// ComputeRequiredDNSRecords emits (see internal/domain/dnsrecords.go). // // Restricted to IANA-registered SvcParamKeys (alpn + port) because the // miekg/dns zone-file parser used by the test fixture rejects symbolic // names for the still-provisional Consolidated Approach SvcParams (`wk`, // `card-sha256`, `cap`, etc.). Until those keys are IANA-registered per -// RFC 9460 §6, the test exercises the verifier dispatch and matching -// path with registered keys; the unregistered keys are unit-tested at -// the domain layer (internal/domain/dnsrecords_test.go). -func TestLookupVerifier_SVCBMatch(t *testing.T) { - t.Parallel() - s := newTestServer(t) - s.add("agent.example.com.", "SVCB", - `agent.example.com. 3600 IN SVCB 1 . alpn=a2a port=443`) - - recs := []domain.ExpectedDNSRecord{{ - Name: "agent.example.com", - Type: domain.DNSRecordSVCB, - Value: `1 . alpn=a2a port=443`, - Required: false, - }} - got := s.verifyAgainst(t, recs) - if !got[0].found { - t.Errorf("SVCB should match; got=%+v", got[0]) +// RFC 9460 §6, the verifier-side test exercises the dispatch and +// matching path with registered keys; the unregistered keys are +// unit-tested at the domain layer (internal/domain/dnsrecords_test.go). +func TestLookupVerifier_SVCB(t *testing.T) { + tests := []struct { + name string + zoneName string // RR owner-name in zone fixture + zoneRR string // full RR as miekg/dns zone-file syntax + queryName string // ExpectedDNSRecord.Name + want string // ExpectedDNSRecord.Value + found bool + why string + }{ + { + name: "match", + zoneName: "agent.example.com.", + zoneRR: `agent.example.com. 3600 IN SVCB 1 . alpn=a2a port=443`, + queryName: "agent.example.com", + want: `1 . alpn=a2a port=443`, + found: true, + }, + { + name: "missing-different-name-in-zone", + zoneName: "other.example.com.", + zoneRR: `other.example.com. 3600 IN SVCB 1 . alpn=a2a`, + queryName: "agent.example.com", + want: `1 . alpn=a2a`, + found: false, + why: "SVCB must not be Found when the zone has no matching record", + }, + { + name: "alias-mode-vs-service-mode-mismatch", + zoneName: "agent.example.com.", + zoneRR: `agent.example.com. 3600 IN SVCB 0 host.provider.example.`, + queryName: "agent.example.com", + want: `1 . alpn=a2a`, + found: false, + why: "ServiceMode expectation should not match an AliasMode record", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + s := newTestServer(t) + s.add(tc.zoneName, "SVCB", tc.zoneRR) + + recs := []domain.ExpectedDNSRecord{{ + Name: tc.queryName, + Type: domain.DNSRecordSVCB, + Value: tc.want, + Required: false, + }} + got := s.verifyAgainst(t, recs) + if got[0].found != tc.found { + if tc.why != "" { + t.Error(tc.why) + } + t.Errorf("found=%v want %v; got=%+v", got[0].found, tc.found, got[0]) + } + }) } } -// TestLookupVerifier_SVCBMissing covers the absent-record path. The -// agent's zone never published the SVCB record (or it was removed). -// Verifier reports not-found without an error (NXDOMAIN-style empty -// answer). -func TestLookupVerifier_SVCBMissing(t *testing.T) { +// TestLookupVerifier_HTTPS_DNSSECFlagPropagates locks in that +// verifyHTTPS surfaces the AD bit so a DNSSEC-validated mismatch in a +// signed zone trips the lifecycle hard-fail rule (HTTPS_DNSSEC_MISMATCH) +// the same way TLSA_DNSSEC_MISMATCH does. Without this propagation the +// service layer would silently accept a rewritten HTTPS record. +func TestLookupVerifier_HTTPS_DNSSECFlagPropagates(t *testing.T) { t.Parallel() s := newTestServer(t) - // Different name in the zone — query for the agent's FQDN returns - // no SVCB answers. - s.add("other.example.com.", "SVCB", - `other.example.com. 3600 IN SVCB 1 . alpn=a2a`) + s.setAD(true) + s.add("agent.example.com.", "HTTPS", + `agent.example.com. 3600 IN HTTPS 1 . alpn="h2"`) recs := []domain.ExpectedDNSRecord{{ - Name: "agent.example.com", - Type: domain.DNSRecordSVCB, - Value: `1 . alpn=a2a`, + Name: "agent.example.com", Type: domain.DNSRecordHTTPS, + Value: `1 . alpn=h2`, Required: false, }} got := s.verifyAgainst(t, recs) - if got[0].found { - t.Error("SVCB must not be Found when the zone has no matching record") + if !got[0].found { + t.Errorf("HTTPS should match; got=%+v", got[0]) + } + if !got[0].dnssec { + t.Error("DNSSECVerified must surface true for HTTPS when the response carried AD=1") } } -// TestLookupVerifier_SVCBWrongTargetMissesMatch confirms that a record -// with the right alpn but a different SvcPriority/TargetName does not -// satisfy the expectation. Matching is on the full normalized -// presentation form, so a TargetName mismatch fails the comparison. -func TestLookupVerifier_SVCBWrongTargetMissesMatch(t *testing.T) { +// TestLookupVerifier_SVCB_DNSSECFlagPropagates is the SVCB-side +// counterpart to the HTTPS test above. SVCB carries the security- +// bearing card-sha256 SvcParam (when the RA committed one), so the AD +// bit is load-bearing for the lifecycle SVCB_DNSSEC_MISMATCH rule. +func TestLookupVerifier_SVCB_DNSSECFlagPropagates(t *testing.T) { t.Parallel() s := newTestServer(t) - // AliasMode (priority 0) at agent.example.com pointing at a - // hosting target — different shape than what the RA expects in - // ServiceMode (priority 1). + s.setAD(true) s.add("agent.example.com.", "SVCB", - `agent.example.com. 3600 IN SVCB 0 host.provider.example.`) + `agent.example.com. 3600 IN SVCB 1 . alpn=a2a port=443`) recs := []domain.ExpectedDNSRecord{{ - Name: "agent.example.com", - Type: domain.DNSRecordSVCB, - Value: `1 . alpn=a2a`, + Name: "agent.example.com", Type: domain.DNSRecordSVCB, + Value: `1 . alpn=a2a port=443`, Required: false, }} got := s.verifyAgainst(t, recs) - if got[0].found { - t.Error("ServiceMode expectation should not match an AliasMode record") + if !got[0].found { + t.Errorf("SVCB should match; got=%+v", got[0]) + } + if !got[0].dnssec { + t.Error("DNSSECVerified must surface true for SVCB when the response carried AD=1") } } diff --git a/internal/adapter/dns/lookup.go b/internal/adapter/dns/lookup.go index 2c695d7..9426ed1 100644 --- a/internal/adapter/dns/lookup.go +++ b/internal/adapter/dns/lookup.go @@ -213,6 +213,13 @@ func (v *LookupVerifier) verifyTLSA(ctx context.Context, server string, rec doma // verifyHTTPS checks for an HTTPS-type record (RFC 9460). Matching // compares the SvcPriority + TargetName + params text verbatim // against the expected value after whitespace normalization. +// +// Captures the DNSSEC AuthenticatedData bit on the response, mirroring +// verifyTLSA and verifySVCB. The service-layer post-verify rule +// (lifecycle.go verifyDNSRecords) treats a DNSSEC-authenticated HTTPS +// record whose value disagrees with the expected one as a hard fail +// — same threat shape as TLSA: an attacker rewrote a record in a +// signed zone. func (v *LookupVerifier) verifyHTTPS(ctx context.Context, server string, rec domain.ExpectedDNSRecord) port.RecordVerification { r := port.RecordVerification{Record: rec} resp, err := v.exchange(ctx, server, rec.Name, dns.TypeHTTPS) @@ -224,6 +231,7 @@ func (v *LookupVerifier) verifyHTTPS(ctx context.Context, server string, rec dom r.Error = fmt.Sprintf("rcode %s", dns.RcodeToString[resp.Rcode]) return r } + r.DNSSECVerified = resp.AuthenticatedData wantNorm := normalizeHTTPS(rec.Value) for _, rr := range resp.Answer { https, ok := rr.(*dns.HTTPS) diff --git a/internal/adapter/docsui/openapi/ra.yaml b/internal/adapter/docsui/openapi/ra.yaml index 2422c8c..5a41230 100644 --- a/internal/adapter/docsui/openapi/ra.yaml +++ b/internal/adapter/docsui/openapi/ra.yaml @@ -1067,26 +1067,25 @@ components: type: string dnsRecordStyle: type: string - enum: [consolidated, legacy, both] + enum: [CONSOLIDATED, LEGACY, BOTH] description: | - Selects which DNS record family the RA emits for this - registration. Surfaces on the 202 register response's - dnsRecords[], on GET /v2/ans/agents/{agentId}, and on the + Selects which DNS record family the RA emits in the 202 + register response's dnsRecords[] and in the AGENT_REGISTERED TL event's - attestations.dnsRecordsProvisioned[]. + attestations.dnsRecordsProvisioned[]. Not echoed on + GET /v2/ans/agents/{agentId}. - consolidated (default, recommended): Consolidated - Approach SVCB rows at the bare FQDN per ANS_SPEC.md - §4.4.2, plus shared `_ans-`-prefixed records and TLSA. - legacy: original `_ans` TXT shape, supported - indefinitely for operators on existing zone-edit - tooling that targets `_ans.{fqdn}`. - both: union; the §4.4.2 transition shape. + - CONSOLIDATED (default, recommended): Consolidated + Approach SVCB rows at the bare FQDN per RFC 9460, + plus shared `_ans-`-prefixed records and TLSA. + - LEGACY: original `_ans` TXT shape, supported + indefinitely for operators with existing zone-edit + tooling that targets `_ans.{fqdn}`. + - BOTH: union of CONSOLIDATED + LEGACY for the + transition window. - Empty/missing → consolidated. Default points new - integrations at the lean shape per §4.4.2 SHOULD. - default: "consolidated" - example: "consolidated" + Empty/missing normalizes to CONSOLIDATED server-side. + example: "CONSOLIDATED" required: - agentDisplayName - version @@ -1305,7 +1304,7 @@ components: type: string type: type: string - enum: [HTTPS, TLSA, TXT] + enum: [HTTPS, SVCB, TLSA, TXT] value: type: string priority: diff --git a/internal/adapter/store/sqlite/migrations/007_agent_dns_record_style.sql b/internal/adapter/store/sqlite/migrations/007_agent_dns_record_style.sql index 422b86c..44a2f57 100644 --- a/internal/adapter/store/sqlite/migrations/007_agent_dns_record_style.sql +++ b/internal/adapter/store/sqlite/migrations/007_agent_dns_record_style.sql @@ -3,19 +3,34 @@ -- row so the verify-acme/verify-dns flow and the badge response carry -- the same shape the operator chose at registration time. -- --- One of: --- "consolidated" — Consolidated Approach SVCB rows + shared records +-- One of (CONSTANT_CASE matching the V2 register schema enum): +-- "CONSOLIDATED" — Consolidated Approach SVCB rows + shared records -- (default; recommended; aligned with §4.4.2). --- "legacy" — original `_ans` TXT shape + shared records. +-- "LEGACY" — original `_ans` TXT shape + shared records. -- Backwards-compatible with operators registered -- before the Consolidated Approach landed. --- "both" — union; the §4.4.2 transition shape for operators +-- "BOTH" — union; the §4.4.2 transition shape for operators -- running both record families during migration. -- --- Nullable for backwards compatibility with agents registered before --- this migration. The domain helper ComputeRequiredDNSRecords treats --- empty value as the default ("consolidated") via DefaultDNSRecordStyle, --- so old agents do not lose attestation behavior. +-- Nullable to allow rows that pre-date this migration to load. The +-- backfill below sets every such row to LEGACY because every agent +-- registered before this PR shipped received the original `_ans` TXT +-- shape — defaulting them to CONSOLIDATED would silently demand SVCB +-- records they were never told to publish. CHECK matches the +-- precedent set by migrations 002 (csr_type) and 003 (schema_version) +-- so corrupt rows fail at the storage boundary instead of silently +-- coercing to default in the domain layer. ALTER TABLE agent_registrations - ADD COLUMN dns_record_style TEXT; + ADD COLUMN dns_record_style TEXT + CHECK (dns_record_style IS NULL + OR dns_record_style IN ('CONSOLIDATED', 'LEGACY', 'BOTH')); + +-- Backfill: every row registered before this migration shipped was +-- emitting the legacy `_ans` TXT shape (the only shape pre-PR-13). +-- Stamp them as LEGACY so post-deploy verify-dns calls demand the +-- record family the operator actually published. New rows get the +-- value written explicitly by applyDNSRecordStyle in the service. +UPDATE agent_registrations + SET dns_record_style = 'LEGACY' + WHERE dns_record_style IS NULL; diff --git a/internal/domain/dnsrecords.go b/internal/domain/dnsrecords.go index b8185cc..ee51271 100644 --- a/internal/domain/dnsrecords.go +++ b/internal/domain/dnsrecords.go @@ -4,24 +4,30 @@ import ( "encoding/base64" "encoding/hex" "fmt" + "net/url" + "strconv" ) // DNSRecordStyle selects which DNS record family the RA emits in its // dnsRecordsProvisioned attestation and in the records it tells the // operator to publish at registration time. // -// Default is "consolidated": one SVCB record per protocol at the +// Default is CONSOLIDATED: one SVCB record per protocol at the // agent's bare FQDN per the cross-draft Consolidated Approach (§4.4.2). // Operators on infrastructure that already publishes the legacy -// `_ans` TXT family pick "legacy". Migration operators pick "both" -// for a defined window, then flip back to "consolidated". +// `_ans` TXT family pick LEGACY. Migration operators pick BOTH +// for a defined window, then flip back to CONSOLIDATED. // -// Legacy MUST stay supported indefinitely. Operators picking "legacy" +// LEGACY MUST stay supported indefinitely. Operators picking LEGACY // will continue to receive the original `_ans` TXT shape this RA has // emitted since v0.1.x. The cross-channel hash consistency check -// (§4.4.2) only applies when the SVCB record is present, so "legacy" +// (§4.4.2) only applies when the SVCB record is present, so LEGACY // agents do not benefit from the card-sha256 ↔ capabilities_hash // guarantee — that is a property of the chosen style, not a defect. +// +// Wire values are CONSTANT_CASE, matching every other enum on the V2 +// register schema (Protocol, RevocationReason, AgentLifecycleStatus, +// NextStep.action, ChallengeInfo.type, DnsRecord.type, etc.). type DNSRecordStyle string const ( @@ -29,21 +35,21 @@ const ( // records (one per protocol, bare-FQDN owner) plus the // `_ans-prefixed` records that no SvcParam covers (badge, // identity DANE) plus the server-cert TLSA. The default. - DNSRecordStyleConsolidated DNSRecordStyle = "consolidated" + DNSRecordStyleConsolidated DNSRecordStyle = "CONSOLIDATED" // DNSRecordStyleLegacy emits the original `_ans` TXT family // (one per protocol) plus the same `_ans-`-prefixed records // plus the server-cert TLSA. No SVCB rows. - DNSRecordStyleLegacy DNSRecordStyle = "legacy" + DNSRecordStyleLegacy DNSRecordStyle = "LEGACY" // DNSRecordStyleBoth emits the union of Consolidated Approach // SVCB and legacy `_ans` TXT — the transition shape per §4.4.2 // where the two record families coexist on the same agent's zone. - DNSRecordStyleBoth DNSRecordStyle = "both" + DNSRecordStyleBoth DNSRecordStyle = "BOTH" ) // DefaultDNSRecordStyle is the style applied when the registration -// request omits dnsRecordStyle entirely. Pinned to "consolidated" so +// request omits dnsRecordStyle entirely. Pinned to CONSOLIDATED so // new integrations follow §4.4.2's "publish one SVCB record... rather // than parallel per-ecosystem record trees" SHOULD by default. const DefaultDNSRecordStyle = DNSRecordStyleConsolidated @@ -59,6 +65,18 @@ func (s DNSRecordStyle) IsValid() bool { return false } +// DNSRecordStyles returns the canonical valid set as strings — the +// single source of truth for enum membership. Used by error messages +// and (eventually) by spec generation tooling so adding a fourth +// style is a one-place change rather than a shotgun edit. +func DNSRecordStyles() []string { + return []string{ + string(DNSRecordStyleConsolidated), + string(DNSRecordStyleLegacy), + string(DNSRecordStyleBoth), + } +} + // DNSRecordType represents a DNS record type. type DNSRecordType string @@ -201,6 +219,7 @@ func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { for _, ep := range reg.Endpoints { alpn := protocolToANSValue(ep.Protocol) wk := wkPathFor(ep.Protocol) + port := svcbPortFor(ep.AgentURL) // RFC 9460 §2.1 presentation form: unquoted SvcParamValue when // the value has no characters special to the presentation // format. alpn tokens (a2a, mcp), port digits, well-known path @@ -208,7 +227,7 @@ func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { // The resolver-side formatter (formatHTTPSValue) also emits // unquoted, so the verifier's normalize+compare matches without // quote-stripping. - value := fmt.Sprintf(`1 . alpn=%s port=443`, alpn) + value := fmt.Sprintf(`1 . alpn=%s port=%d`, alpn, port) if wk != "" { value += fmt.Sprintf(` wk=%s`, wk) } @@ -308,6 +327,34 @@ func wkPathFor(p Protocol) string { } } +// svcbPortFor returns the TCP port to advertise in the SVCB SvcParam +// `port=`. Reads it from the endpoint URL's authority. Falls back to +// 443 (https) / 80 (http) when the URL omits a port. Empty input or +// unparseable URL returns 443 — the §4.4.2 default for agent endpoints. +// +// Without this, every endpoint emitted a hardcoded port=443 SvcParam, +// silently breaking verify-dns for agents on non-443 endpoints +// (operators would publish their actual port; the RA's expected +// record would say 443; the records would mismatch). +func svcbPortFor(agentURL string) int { + if agentURL == "" { + return 443 + } + u, err := url.Parse(agentURL) + if err != nil { + return 443 + } + if p := u.Port(); p != "" { + if n, err := strconv.Atoi(p); err == nil { + return n + } + } + if u.Scheme == "http" { + return 80 + } + return 443 +} + // capabilitiesHashBase64URL re-encodes a hex-lowercase SHA-256 digest // (the form `AgentRegistration.CapabilitiesHash` carries) into the // base64url form (RFC 4648 §5, no padding) the SVCB `card-sha256` diff --git a/internal/domain/dnsrecords_test.go b/internal/domain/dnsrecords_test.go index 7831b9a..7a44c1f 100644 --- a/internal/domain/dnsrecords_test.go +++ b/internal/domain/dnsrecords_test.go @@ -82,138 +82,172 @@ func TestComputeRequiredDNSRecords_WithoutCert(t *testing.T) { assert.Equal(t, 0, tlsaCount, "no cert → no TLSA record") } -// TestComputeRequiredDNSRecords_LegacyOnlyEmitsHTTPSRR pins the legacy -// shape: HTTPS RR is generated alongside the `_ans` TXT family, NOT -// alongside the consolidated SVCB rows (which would duplicate the -// alpn/port SvcParams). §A.8.1 lists the HTTPS RR as RA-generated -// content the AHP provisions when the apex isn't aliased via CNAME. -func TestComputeRequiredDNSRecords_LegacyOnlyEmitsHTTPSRR(t *testing.T) { - ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") - reg := &AgentRegistration{ - AnsName: ansName, - DNSRecordStyle: DNSRecordStyleLegacy, - Endpoints: []AgentEndpoint{ - {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com"}, - }, - } - records := ComputeRequiredDNSRecords(reg) +// TestComputeRequiredDNSRecords_StyleMatrix exercises every per-style +// emission rule in one table. Each row pins the per-record-type shape +// the operator is asked to publish given a (style, protocol, +// capabilitiesHash, agentURL) tuple. The matrix covers: +// +// - LEGACY emits _ans TXT + HTTPS RR; no SVCB. +// - CONSOLIDATED emits SVCB only (no HTTPS RR — duplicate signalling). +// - BOTH emits the union. +// - SVCB SvcParam composition: wk= (per-protocol), port= (from URL), +// card-sha256= (only when CapabilitiesHash is set). +// - svcbPortFor: explicit non-443 port flows through, default https +// URLs fall back to 443. +// - Invalid style coerces to default (CONSOLIDATED). +func TestComputeRequiredDNSRecords_StyleMatrix(t *testing.T) { + const cardHex = "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27" + const wantCardBase64 = "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc" - var sawHTTPS, sawSVCB bool - for _, r := range records { - switch r.Type { - case DNSRecordHTTPS: - sawHTTPS = true - case DNSRecordSVCB: - sawSVCB = true - } - } - assert.True(t, sawHTTPS, "legacy style must include an HTTPS RR") - assert.False(t, sawSVCB, "legacy style must NOT include SVCB rows") -} - -// TestComputeRequiredDNSRecords_ConsolidatedOmitsHTTPSRR pins the -// consolidated form's lean shape: HTTPS RR is omitted because the -// SVCB rows already carry equivalent SvcParams (alpn, port, ECH). -// Publishing both would duplicate content and risk drift between -// the two records. §A.8.2 calls this out explicitly. -func TestComputeRequiredDNSRecords_ConsolidatedOmitsHTTPSRR(t *testing.T) { - ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") - reg := &AgentRegistration{ - AnsName: ansName, - DNSRecordStyle: DNSRecordStyleConsolidated, - Endpoints: []AgentEndpoint{ - {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com"}, + tests := []struct { + name string + style DNSRecordStyle + protocol Protocol + agentURL string + capabilitiesHash string + wantHTTPS bool + wantSVCB bool + wantLegacyTXT bool + wantSVCBPort string // substring expected in SVCB value (e.g. "port=443") + wantSVCBWk string // "" means SVCB MUST NOT contain "wk=" + wantSVCBCard string // "" means SVCB MUST NOT contain "card-sha256" + }{ + { + name: "legacy-emits-https-rr-no-svcb", + style: DNSRecordStyleLegacy, + protocol: ProtocolA2A, + agentURL: "https://agent.example.com", + wantHTTPS: true, + wantLegacyTXT: true, }, - } - records := ComputeRequiredDNSRecords(reg) - for _, r := range records { - assert.NotEqual(t, DNSRecordHTTPS, r.Type, - "consolidated style omits HTTPS RR (SVCB SvcParams subsume it)") - } -} - -// TestComputeRequiredDNSRecords_SVCBWkPath pins the per-protocol `wk=` -// SvcParam value the Consolidated Approach SVCB carries. A2A maps to -// `agent-card.json` (IANA-registered); MCP maps to `mcp.json` (de-facto -// convention). Suffix-only — the consolidated draft's primary examples -// use the suffix and clients prepend `/.well-known/`. -func TestComputeRequiredDNSRecords_SVCBWkPath(t *testing.T) { - ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") - reg := &AgentRegistration{ - AnsName: ansName, - Endpoints: []AgentEndpoint{ - {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com"}, - {Protocol: ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, + { + name: "consolidated-omits-https-rr", + style: DNSRecordStyleConsolidated, + protocol: ProtocolA2A, + agentURL: "https://agent.example.com", + wantSVCB: true, + wantSVCBPort: "port=443", + wantSVCBWk: "wk=agent-card.json", + }, + { + name: "both-emits-union", + style: DNSRecordStyleBoth, + protocol: ProtocolA2A, + agentURL: "https://agent.example.com", + wantHTTPS: true, + wantLegacyTXT: true, + wantSVCB: true, + wantSVCBPort: "port=443", + wantSVCBWk: "wk=agent-card.json", + }, + { + name: "svcb-mcp-wk-mcp-json", + style: DNSRecordStyleConsolidated, + protocol: ProtocolMCP, + agentURL: "https://agent.example.com/mcp", + wantSVCB: true, + wantSVCBPort: "port=443", + wantSVCBWk: "wk=mcp.json", + }, + { + name: "svcb-http-api-omits-wk", + style: DNSRecordStyleConsolidated, + protocol: ProtocolHTTPAPI, + agentURL: "https://agent.example.com", + wantSVCB: true, + wantSVCBPort: "port=443", + // HTTP-API has no per-protocol metadata file convention. + }, + { + name: "svcb-card-sha256-present-when-set", + style: DNSRecordStyleConsolidated, + protocol: ProtocolA2A, + agentURL: "https://agent.example.com", + capabilitiesHash: cardHex, + wantSVCB: true, + wantSVCBPort: "port=443", + wantSVCBWk: "wk=agent-card.json", + wantSVCBCard: "card-sha256=" + wantCardBase64, + }, + { + name: "svcb-non-443-port-from-url", + style: DNSRecordStyleConsolidated, + protocol: ProtocolA2A, + agentURL: "https://agent.example.com:8443", + wantSVCB: true, + wantSVCBPort: "port=8443", + wantSVCBWk: "wk=agent-card.json", + }, + { + name: "svcb-http-scheme-defaults-port-80", + style: DNSRecordStyleConsolidated, + protocol: ProtocolA2A, + agentURL: "http://agent.example.com", + wantSVCB: true, + wantSVCBPort: "port=80", + wantSVCBWk: "wk=agent-card.json", + }, + { + name: "invalid-style-coerces-to-consolidated", + style: DNSRecordStyle("garbage"), + protocol: ProtocolA2A, + agentURL: "https://agent.example.com", + wantSVCB: true, + wantSVCBPort: "port=443", + wantSVCBWk: "wk=agent-card.json", }, } - records := ComputeRequiredDNSRecords(reg) - for _, r := range records { - if r.Type != DNSRecordSVCB { - continue - } - switch { - case strings.Contains(r.Value, `alpn=a2a`): - assert.Contains(t, r.Value, `wk=agent-card.json`) - case strings.Contains(r.Value, `alpn=mcp`): - assert.Contains(t, r.Value, `wk=mcp.json`) - default: - t.Errorf("SVCB row missing recognized alpn: %q", r.Value) - } - } -} + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") + reg := &AgentRegistration{ + AnsName: ansName, + DNSRecordStyle: tc.style, + CapabilitiesHash: tc.capabilitiesHash, + Endpoints: []AgentEndpoint{ + {Protocol: tc.protocol, AgentURL: tc.agentURL}, + }, + } + records := ComputeRequiredDNSRecords(reg) -// TestComputeRequiredDNSRecords_SVCBCardSHA256_PresentWhenSet verifies -// that an agent registered with agentCardContent emits SVCB rows whose -// card-sha256 SvcParam is the base64url form of reg.CapabilitiesHash. -// This is the DNS half of §4.4.2's three-way cross-check (the live -// Trust Card body, the TL-sealed capabilities_hash, and the SVCB -// card-sha256 all commit to the same SHA-256). -func TestComputeRequiredDNSRecords_SVCBCardSHA256_PresentWhenSet(t *testing.T) { - ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") - // Fixture digest used across the cross-check — the same hex appears - // in the TL event's attestations.metadataHashes.capabilitiesHash. - hexDigest := "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27" - wantBase64 := "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc" - reg := &AgentRegistration{ - AnsName: ansName, - CapabilitiesHash: hexDigest, - Endpoints: []AgentEndpoint{ - {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com"}, - }, - } - records := ComputeRequiredDNSRecords(reg) + var sawHTTPS, sawSVCB, sawLegacyTXT bool + var svcbValue string + for _, r := range records { + switch r.Type { + case DNSRecordHTTPS: + sawHTTPS = true + case DNSRecordSVCB: + sawSVCB = true + svcbValue = r.Value + case DNSRecordTXT: + if strings.HasPrefix(r.Name, "_ans.") { + sawLegacyTXT = true + } + } + } - var sawSVCB bool - for _, r := range records { - if r.Type != DNSRecordSVCB { - continue - } - sawSVCB = true - assert.Contains(t, r.Value, `card-sha256=`+wantBase64, - "SVCB card-sha256 must be base64url(decoded hex of reg.CapabilitiesHash)") - } - assert.True(t, sawSVCB, "expected at least one SVCB row") -} + assert.Equal(t, tc.wantHTTPS, sawHTTPS, "HTTPS RR presence") + assert.Equal(t, tc.wantSVCB, sawSVCB, "SVCB row presence") + assert.Equal(t, tc.wantLegacyTXT, sawLegacyTXT, "_ans TXT presence") -// TestComputeRequiredDNSRecords_SVCBCardSHA256_AbsentWhenUnset verifies -// the spec-conformant "no agentCardContent submitted" path: the SVCB -// row omits the card-sha256 SvcParam entirely. A verifier seeing no -// SvcParam falls back to TOFU on first Trust Card fetch (§4.4.2). -func TestComputeRequiredDNSRecords_SVCBCardSHA256_AbsentWhenUnset(t *testing.T) { - ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") - reg := &AgentRegistration{ - AnsName: ansName, - Endpoints: []AgentEndpoint{ - {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com"}, - }, - } - records := ComputeRequiredDNSRecords(reg) - for _, r := range records { - if r.Type == DNSRecordSVCB { - assert.NotContains(t, r.Value, "card-sha256", - "no agentCardContent → SVCB has no card-sha256 SvcParam") - } + if tc.wantSVCB { + assert.Contains(t, svcbValue, tc.wantSVCBPort, + "SVCB port SvcParam mismatch") + if tc.wantSVCBWk != "" { + assert.Contains(t, svcbValue, tc.wantSVCBWk, "SVCB wk SvcParam mismatch") + } else { + assert.NotContains(t, svcbValue, "wk=", + "SVCB MUST NOT carry wk= when protocol has no metadata convention") + } + if tc.wantSVCBCard != "" { + assert.Contains(t, svcbValue, tc.wantSVCBCard, "SVCB card-sha256 SvcParam mismatch") + } else { + assert.NotContains(t, svcbValue, "card-sha256", + "SVCB MUST NOT carry card-sha256 when CapabilitiesHash is empty") + } + } + }) } } @@ -255,11 +289,54 @@ func TestCapabilitiesHashBase64URL(t *testing.T) { // TestWkPathFor pins the per-protocol well-known suffix mapping. func TestWkPathFor(t *testing.T) { - assert.Equal(t, "agent-card.json", wkPathFor(ProtocolA2A)) - assert.Equal(t, "mcp.json", wkPathFor(ProtocolMCP)) - assert.Equal(t, "", wkPathFor(ProtocolHTTPAPI), - "HTTP-API has no per-protocol metadata file convention") - assert.Equal(t, "", wkPathFor(Protocol("UNKNOWN"))) + tests := []struct { + p Protocol + want string + }{ + {ProtocolA2A, "agent-card.json"}, + {ProtocolMCP, "mcp.json"}, + {ProtocolHTTPAPI, ""}, + {Protocol("UNKNOWN"), ""}, + } + for _, tc := range tests { + t.Run(string(tc.p), func(t *testing.T) { + assert.Equal(t, tc.want, wkPathFor(tc.p)) + }) + } +} + +// TestDNSRecordStyles pins the canonical valid set of DNSRecordStyle +// values returned by the helper used in the V2 INVALID_DNS_RECORD_STYLE +// error message. Order and contents are stable so an external client's +// error-message fixtures can match. +func TestDNSRecordStyles(t *testing.T) { + got := DNSRecordStyles() + want := []string{"CONSOLIDATED", "LEGACY", "BOTH"} + assert.Equal(t, want, got) +} + +// TestSVCBPortFor pins the agentURL → port resolution that drives the +// SVCB `port=` SvcParam. Covers https-default, http-default, explicit +// port, malformed URL, and empty input. +func TestSVCBPortFor(t *testing.T) { + tests := []struct { + name string + in string + want int + }{ + {name: "https_default_443", in: "https://agent.example.com", want: 443}, + {name: "http_default_80", in: "http://agent.example.com", want: 80}, + {name: "explicit_port_8443", in: "https://agent.example.com:8443", want: 8443}, + {name: "explicit_port_8080_http", in: "http://agent.example.com:8080", want: 8080}, + {name: "with_path_keeps_port", in: "https://agent.example.com:9443/a2a", want: 9443}, + {name: "empty_url_defaults_443", in: "", want: 443}, + {name: "malformed_url_defaults_443", in: "://not-a-url", want: 443}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, svcbPortFor(tc.in)) + }) + } } func TestComputeRequiredDNSRecords_WithCert(t *testing.T) { diff --git a/internal/port/dns.go b/internal/port/dns.go index 03d96f2..553612a 100644 --- a/internal/port/dns.go +++ b/internal/port/dns.go @@ -13,10 +13,13 @@ type RecordVerification struct { Actual string // What was actually returned by DNS (empty if not found). Error string // Lookup error, if any. // DNSSECVerified is true when the response carried an - // authenticated-data (AD) bit from a validating resolver. Only - // meaningful for TLSA records — surfacing this to the TL lets a - // downstream verifier trust the cert-binding assertion without - // re-querying DNS themselves. + // authenticated-data (AD) bit from a validating resolver. Set + // on TLSA, SVCB, and HTTPS responses; surfaced to the TL + // attestation so a downstream verifier can trust the cert / + // capability / service binding without re-querying DNS. The + // service layer enforces a hard-fail rule when AD=true and the + // record's value disagrees with the expected one (the threat + // shape: an attacker rewrote a record in a DNSSEC-signed zone). DNSSECVerified bool } diff --git a/internal/ra/service/helpers.go b/internal/ra/service/helpers.go index f33fe80..21df3cf 100644 --- a/internal/ra/service/helpers.go +++ b/internal/ra/service/helpers.go @@ -6,6 +6,7 @@ import ( "encoding/pem" "errors" "fmt" + "strings" "time" "github.com/godaddy/ans/internal/domain" @@ -14,23 +15,30 @@ import ( // applyDNSRecordStyle resolves the DNS-record-style for the new // registration and stores it on the aggregate. // -// V1 lane is pinned to "legacy" regardless of the request: V1 callers +// V1 lane is pinned to LEGACY regardless of the request: V1 callers // predate the Consolidated Approach and their tooling expects the // original `_ans` TXT shape. V1 has no dnsRecordStyle field on the // wire, so this branch is the only path V1 registrations take. // V2 callers honor req.DNSRecordStyle: empty normalizes to -// DefaultDNSRecordStyle (consolidated); invalid values surface as +// DefaultDNSRecordStyle (CONSOLIDATED); invalid values surface as // INVALID_DNS_RECORD_STYLE. +// +// V1 detection routes through isV1Lane (lifecycle.go) so a future +// schema-version evolution updates one site, not several. The error +// message lists valid values from domain.DNSRecordStyles() so adding +// a fourth style is a one-place change. func applyDNSRecordStyle(reg *domain.AgentRegistration, req RegisterRequest) error { switch { - case req.SchemaVersion == "V1": + case isV1Lane(req.SchemaVersion): reg.DNSRecordStyle = domain.DNSRecordStyleLegacy case req.DNSRecordStyle == "": reg.DNSRecordStyle = domain.DefaultDNSRecordStyle case !req.DNSRecordStyle.IsValid(): return domain.NewValidationError( "INVALID_DNS_RECORD_STYLE", - fmt.Sprintf("dnsRecordStyle %q is not one of consolidated, legacy, both", string(req.DNSRecordStyle)), + fmt.Sprintf("dnsRecordStyle %q is not one of %s", + string(req.DNSRecordStyle), + strings.Join(domain.DNSRecordStyles(), ", ")), ) default: reg.DNSRecordStyle = req.DNSRecordStyle diff --git a/internal/ra/service/helpers_test.go b/internal/ra/service/helpers_test.go index 7eb0204..e4650c0 100644 --- a/internal/ra/service/helpers_test.go +++ b/internal/ra/service/helpers_test.go @@ -14,6 +14,7 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/pem" + "errors" "math/big" "strings" "testing" @@ -201,3 +202,108 @@ func selfSignedCertPEM(t *testing.T) string { } return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) } + +// ----- applyDNSRecordStyle ----- + +// TestApplyDNSRecordStyle covers the V1-pin / V2-default / V2-validate +// branches, including the INVALID_DNS_RECORD_STYLE error path. The +// integration tests follow happy paths through RegisterAgent and don't +// reach the invalid-value branch directly. +func TestApplyDNSRecordStyle(t *testing.T) { + tests := []struct { + name string + req RegisterRequest + wantStyle domain.DNSRecordStyle + wantErrCode string + }{ + { + name: "v1_pins_to_legacy_ignoring_request_field", + req: RegisterRequest{ + SchemaVersion: "V1", + DNSRecordStyle: domain.DNSRecordStyleConsolidated, + }, + wantStyle: domain.DNSRecordStyleLegacy, + }, + { + name: "v2_empty_normalizes_to_default", + req: RegisterRequest{SchemaVersion: "V2", DNSRecordStyle: ""}, + wantStyle: domain.DefaultDNSRecordStyle, + }, + { + name: "unset_schema_treated_as_v2_default", + req: RegisterRequest{SchemaVersion: "", DNSRecordStyle: ""}, + wantStyle: domain.DefaultDNSRecordStyle, + }, + { + name: "v2_valid_consolidated", + req: RegisterRequest{SchemaVersion: "V2", DNSRecordStyle: domain.DNSRecordStyleConsolidated}, + wantStyle: domain.DNSRecordStyleConsolidated, + }, + { + name: "v2_valid_legacy", + req: RegisterRequest{SchemaVersion: "V2", DNSRecordStyle: domain.DNSRecordStyleLegacy}, + wantStyle: domain.DNSRecordStyleLegacy, + }, + { + name: "v2_valid_both", + req: RegisterRequest{SchemaVersion: "V2", DNSRecordStyle: domain.DNSRecordStyleBoth}, + wantStyle: domain.DNSRecordStyleBoth, + }, + { + name: "v2_invalid_value_rejected", + req: RegisterRequest{SchemaVersion: "V2", DNSRecordStyle: domain.DNSRecordStyle("garbage")}, + wantErrCode: "INVALID_DNS_RECORD_STYLE", + }, + { + // CONSTANT_CASE is the wire form. lowercase is rejected so the + // V2 enum stays consistent with every other enum on the spec. + name: "v2_lowercase_legacy_rejected_as_invalid", + req: RegisterRequest{SchemaVersion: "V2", DNSRecordStyle: domain.DNSRecordStyle("legacy")}, + wantErrCode: "INVALID_DNS_RECORD_STYLE", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + reg := &domain.AgentRegistration{} + err := applyDNSRecordStyle(reg, tc.req) + if tc.wantErrCode != "" { + if err == nil { + t.Fatalf("want error code %q, got nil", tc.wantErrCode) + } + var verr *domain.Error + if !errors.As(err, &verr) { + t.Fatalf("want *domain.Error, got %T: %v", err, err) + } + if verr.Code != tc.wantErrCode { + t.Errorf("code: got %q want %q", verr.Code, tc.wantErrCode) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if reg.DNSRecordStyle != tc.wantStyle { + t.Errorf("DNSRecordStyle: got %q want %q", reg.DNSRecordStyle, tc.wantStyle) + } + }) + } +} + +// TestApplyDNSRecordStyle_ErrorMessageListsValidValues confirms the +// error detail enumerates the canonical valid set so SDK authors get +// an actionable message. Sourced from domain.DNSRecordStyles(). +func TestApplyDNSRecordStyle_ErrorMessageListsValidValues(t *testing.T) { + reg := &domain.AgentRegistration{} + err := applyDNSRecordStyle(reg, RegisterRequest{ + SchemaVersion: "V2", DNSRecordStyle: domain.DNSRecordStyle("garbage"), + }) + if err == nil { + t.Fatal("expected error") + } + for _, want := range domain.DNSRecordStyles() { + if !strings.Contains(err.Error(), want) { + t.Errorf("error message must list %q; got %q", want, err.Error()) + } + } +} + diff --git a/internal/ra/service/lifecycle.go b/internal/ra/service/lifecycle.go index b6a9f6b..10ed8da 100644 --- a/internal/ra/service/lifecycle.go +++ b/internal/ra/service/lifecycle.go @@ -601,18 +601,29 @@ func (s *RegistrationService) verifyDNSRecords(ctx context.Context, fqdn string, } var out []DNSMismatch for _, r := range res.Results { - // DNSSEC-authenticated TLSA that doesn't match is a hard - // fail regardless of the Required flag. `r.Found` from the - // TLSA verifier is true only when the actual matched the - // expected value after case-insensitive hex normalization, - // so `DNSSECVerified && !Found` captures "response was - // signed, but its content disagreed with the cert we - // issued" — the exact attack we block. - if r.Record.Type == domain.DNSRecordTLSA && r.DNSSECVerified && !r.Found { - out = append(out, DNSMismatch{ - Expected: r.Record, Found: r.Actual, Code: "TLSA_DNSSEC_MISMATCH", - }) - continue + // DNSSEC-authenticated record whose committed value disagrees + // with the expected one is a hard fail regardless of the + // Required flag. `r.Found` is true only when the actual + // matched after type-specific normalization, so + // `DNSSECVerified && !Found` captures "response was signed, + // but its content disagreed with what we issued" — the exact + // attack we block (an attacker rewrote a record in a signed + // zone). Applies to TLSA (cert binding), SVCB (capability + // locator with card-sha256), and HTTPS (service binding). + if r.DNSSECVerified && !r.Found { + switch r.Record.Type { + case domain.DNSRecordTLSA, domain.DNSRecordSVCB, domain.DNSRecordHTTPS: + out = append(out, DNSMismatch{ + Expected: r.Record, Found: r.Actual, + Code: string(r.Record.Type) + "_DNSSEC_MISMATCH", + }) + continue + case domain.DNSRecordTXT: + // TXT records (discovery, badge) carry no + // cryptographic commitment, so a DNSSEC-validated + // mismatch isn't a hard fail — fall through to the + // Required check below. + } } if !r.Record.Required { continue @@ -651,8 +662,8 @@ func (s *RegistrationService) buildAgentRegisteredEvent( // // DNSSECVerified carries forward from the per-record verification // result (set true by the lookup verifier when a validating - // resolver marked the response with the AD bit). Only ever true - // for TLSA today — TXT and HTTPS records don't carry the flag. + // resolver marked the response with the AD bit). True on TLSA, + // SVCB, and HTTPS records; TXT records don't carry the flag. dnssecByKey := make(map[string]bool, len(perRecord)) for _, r := range perRecord { if r.DNSSECVerified { diff --git a/spec/api-spec-v2.yaml b/spec/api-spec-v2.yaml index 2422c8c..5a41230 100644 --- a/spec/api-spec-v2.yaml +++ b/spec/api-spec-v2.yaml @@ -1067,26 +1067,25 @@ components: type: string dnsRecordStyle: type: string - enum: [consolidated, legacy, both] + enum: [CONSOLIDATED, LEGACY, BOTH] description: | - Selects which DNS record family the RA emits for this - registration. Surfaces on the 202 register response's - dnsRecords[], on GET /v2/ans/agents/{agentId}, and on the + Selects which DNS record family the RA emits in the 202 + register response's dnsRecords[] and in the AGENT_REGISTERED TL event's - attestations.dnsRecordsProvisioned[]. + attestations.dnsRecordsProvisioned[]. Not echoed on + GET /v2/ans/agents/{agentId}. - consolidated (default, recommended): Consolidated - Approach SVCB rows at the bare FQDN per ANS_SPEC.md - §4.4.2, plus shared `_ans-`-prefixed records and TLSA. - legacy: original `_ans` TXT shape, supported - indefinitely for operators on existing zone-edit - tooling that targets `_ans.{fqdn}`. - both: union; the §4.4.2 transition shape. + - CONSOLIDATED (default, recommended): Consolidated + Approach SVCB rows at the bare FQDN per RFC 9460, + plus shared `_ans-`-prefixed records and TLSA. + - LEGACY: original `_ans` TXT shape, supported + indefinitely for operators with existing zone-edit + tooling that targets `_ans.{fqdn}`. + - BOTH: union of CONSOLIDATED + LEGACY for the + transition window. - Empty/missing → consolidated. Default points new - integrations at the lean shape per §4.4.2 SHOULD. - default: "consolidated" - example: "consolidated" + Empty/missing normalizes to CONSOLIDATED server-side. + example: "CONSOLIDATED" required: - agentDisplayName - version @@ -1305,7 +1304,7 @@ components: type: string type: type: string - enum: [HTTPS, TLSA, TXT] + enum: [HTTPS, SVCB, TLSA, TXT] value: type: string priority: From 6be5dccffff71d45e6d2a1fc16986c809eaba5d0 Mon Sep 17 00:00:00 2001 From: kperry Date: Thu, 21 May 2026 12:25:30 -0500 Subject: [PATCH 05/13] feat(dns): update DNS record style to support multiple families and enhance validation Signed-off-by: kperry --- internal/adapter/docsui/openapi/ra.yaml | 52 +++--- internal/adapter/store/sqlite/agent.go | 60 ++++++- .../migrations/007_agent_dns_record_style.sql | 56 ++++--- internal/domain/agent.go | 13 +- internal/domain/dnsrecords.go | 142 +++++++++-------- internal/domain/dnsrecords_test.go | 96 ++++++----- internal/ra/handler/registration.go | 31 +++- internal/ra/service/helpers.go | 64 +++++--- internal/ra/service/helpers_test.go | 149 +++++++++++++----- internal/ra/service/registration.go | 15 +- spec/api-spec-v2.yaml | 52 +++--- 11 files changed, 474 insertions(+), 256 deletions(-) diff --git a/internal/adapter/docsui/openapi/ra.yaml b/internal/adapter/docsui/openapi/ra.yaml index 5a41230..fc9d326 100644 --- a/internal/adapter/docsui/openapi/ra.yaml +++ b/internal/adapter/docsui/openapi/ra.yaml @@ -1023,6 +1023,23 @@ components: type: string enum: [A2A, MCP, HTTP-API] + DNSRecordStyle: + type: string + enum: [ANS_SVCB, ANS_TXT] + description: | + Names one DNS record family the RA can emit for an agent + registration. Used as the element type of dnsRecordStyles[]. + + - ANS_SVCB: Consolidated Approach SVCB rows at the bare FQDN + per RFC 9460. One row per protocol carrying alpn, port, and + capability-locator SvcParams (wk, card-sha256). The + recommended default for new integrations. + - ANS_TXT: original `_ans` TXT shape (one row per protocol), + supported indefinitely for operators with existing zone-edit + tooling that targets `_ans.{fqdn}`. Emits an HTTPS RR at + the bare FQDN alongside, since `_ans` TXT carries no + connection hints. + RevocationReason: type: string enum: @@ -1065,27 +1082,26 @@ components: type: string identityCsrPEM: type: string - dnsRecordStyle: - type: string - enum: [CONSOLIDATED, LEGACY, BOTH] + dnsRecordStyles: + type: array + items: + $ref: '#/components/schemas/DNSRecordStyle' + uniqueItems: true + minItems: 1 description: | - Selects which DNS record family the RA emits in the 202 - register response's dnsRecords[] and in the - AGENT_REGISTERED TL event's - attestations.dnsRecordsProvisioned[]. Not echoed on + Set of DNS record families the RA emits in the 202 register + response's dnsRecords[] and in the AGENT_REGISTERED TL + event's attestations.dnsRecordsProvisioned[]. Not echoed on GET /v2/ans/agents/{agentId}. - - CONSOLIDATED (default, recommended): Consolidated - Approach SVCB rows at the bare FQDN per RFC 9460, - plus shared `_ans-`-prefixed records and TLSA. - - LEGACY: original `_ans` TXT shape, supported - indefinitely for operators with existing zone-edit - tooling that targets `_ans.{fqdn}`. - - BOTH: union of CONSOLIDATED + LEGACY for the - transition window. - - Empty/missing normalizes to CONSOLIDATED server-side. - example: "CONSOLIDATED" + Each value names one record family; an operator publishing + the union (Consolidated Approach SVCB plus the original + `_ans` TXT shape) sends both. Order is not significant + and duplicates are rejected (`uniqueItems: true`). + + Omitted/missing normalizes to ["ANS_SVCB"] server-side + (the recommended default per RFC 9460). + example: ["ANS_SVCB"] required: - agentDisplayName - version diff --git a/internal/adapter/store/sqlite/agent.go b/internal/adapter/store/sqlite/agent.go index 6595b06..abd7bd3 100644 --- a/internal/adapter/store/sqlite/agent.go +++ b/internal/adapter/store/sqlite/agent.go @@ -39,7 +39,7 @@ type agentRow struct { SupersedesRegistrationID sql.NullInt64 `db:"supersedes_registration_id"` ACMEDNS01Token sql.NullString `db:"acme_dns01_token"` ACMEChallengeExpiresAtMs sql.NullInt64 `db:"acme_challenge_expires_at_ms"` - DNSRecordStyle sql.NullString `db:"dns_record_style"` + DNSRecordStyles sql.NullString `db:"dns_record_styles"` CreatedAtMs int64 `db:"created_at_ms"` UpdatedAtMs int64 `db:"updated_at_ms"` } @@ -74,12 +74,58 @@ func (r agentRow) toDomain() (*domain.AgentRegistration, error) { if r.ACMEChallengeExpiresAtMs.Valid { reg.ACMEChallenge.ExpiresAt = msToTime(r.ACMEChallengeExpiresAtMs.Int64) } - if r.DNSRecordStyle.Valid { - reg.DNSRecordStyle = domain.DNSRecordStyle(r.DNSRecordStyle.String) + if r.DNSRecordStyles.Valid && r.DNSRecordStyles.String != "" { + styles, err := decodeDNSRecordStyles(r.DNSRecordStyles.String) + if err != nil { + return nil, fmt.Errorf("sqlite: decode dns_record_styles: %w", err) + } + reg.DNSRecordStyles = styles } return reg, nil } +// decodeDNSRecordStyles parses the JSON-array string stored in +// agent_registrations.dns_record_styles into the typed domain slice. +// Empty array unmarshals to a nil slice (the domain layer treats +// empty as "use default") so post-load behavior matches a freshly +// registered agent that didn't set the field. +func decodeDNSRecordStyles(raw string) ([]domain.DNSRecordStyle, error) { + var strs []string + if err := json.Unmarshal([]byte(raw), &strs); err != nil { + return nil, err + } + if len(strs) == 0 { + return nil, nil + } + out := make([]domain.DNSRecordStyle, len(strs)) + for i, s := range strs { + out[i] = domain.DNSRecordStyle(s) + } + return out, nil +} + +// encodeDNSRecordStyles renders a typed style slice as the canonical +// JSON-array string the agent_registrations.dns_record_styles column +// stores. nil/empty input renders empty string so nullableString() +// stamps SQL NULL — domain treats NULL the same as the default set +// per ComputeRequiredDNSRecords. +func encodeDNSRecordStyles(styles []domain.DNSRecordStyle) string { + if len(styles) == 0 { + return "" + } + strs := make([]string, len(styles)) + for i, s := range styles { + strs[i] = string(s) + } + b, err := json.Marshal(strs) + if err != nil { + // Marshalling a []string never errors in practice; surface as + // empty so the column is NULL rather than corrupted JSON. + return "" + } + return string(b) +} + // Save inserts or updates an AgentRegistration. Endpoints, server cert, // and identity CSR are persisted via their dedicated tables — Save only // writes the root aggregate row. @@ -97,7 +143,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) registration_timestamp_ms, last_renewal_timestamp_ms, supersedes_registration_id, acme_dns01_token, acme_challenge_expires_at_ms, - dns_record_style, + dns_record_styles, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` res, err := s.db.extx(ctx).ExecContext(ctx, q, @@ -114,7 +160,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) nullableInt64(agent.SupersedesRegistrationID), nullableString(agent.ACMEChallenge.DNS01Token), nullableMs(agent.ACMEChallenge.ExpiresAt), - nullableString(string(agent.DNSRecordStyle)), + nullableString(encodeDNSRecordStyles(agent.DNSRecordStyles)), now, now, ) if err != nil { @@ -137,7 +183,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) supersedes_registration_id = ?, acme_dns01_token = ?, acme_challenge_expires_at_ms = ?, - dns_record_style = ?, + dns_record_styles = ?, updated_at_ms = ? WHERE id = ?` _, err := s.db.extx(ctx).ExecContext(ctx, q, @@ -148,7 +194,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) nullableInt64(agent.SupersedesRegistrationID), nullableString(agent.ACMEChallenge.DNS01Token), nullableMs(agent.ACMEChallenge.ExpiresAt), - nullableString(string(agent.DNSRecordStyle)), + nullableString(encodeDNSRecordStyles(agent.DNSRecordStyles)), now, agent.ID, ) diff --git a/internal/adapter/store/sqlite/migrations/007_agent_dns_record_style.sql b/internal/adapter/store/sqlite/migrations/007_agent_dns_record_style.sql index 44a2f57..5bc298f 100644 --- a/internal/adapter/store/sqlite/migrations/007_agent_dns_record_style.sql +++ b/internal/adapter/store/sqlite/migrations/007_agent_dns_record_style.sql @@ -1,36 +1,40 @@ -- 007_agent_dns_record_style.sql --- Persist the operator's chosen DNS-record-style on the registration --- row so the verify-acme/verify-dns flow and the badge response carry --- the same shape the operator chose at registration time. +-- Persist the operator's chosen set of DNS record families on the +-- registration row so verify-acme / verify-dns / badge responses +-- carry the same shape the operator chose at registration time. -- --- One of (CONSTANT_CASE matching the V2 register schema enum): --- "CONSOLIDATED" — Consolidated Approach SVCB rows + shared records --- (default; recommended; aligned with §4.4.2). --- "LEGACY" — original `_ans` TXT shape + shared records. --- Backwards-compatible with operators registered --- before the Consolidated Approach landed. --- "BOTH" — union; the §4.4.2 transition shape for operators --- running both record families during migration. +-- Stored as a JSON array of CONSTANT_CASE strings matching the V2 +-- register schema's DNSRecordStyle enum: +-- "ANS_SVCB" — Consolidated Approach SVCB rows + shared records +-- (RFC 9460; recommended default). +-- "ANS_TXT" — original `_ans` TXT shape + HTTPS RR + shared +-- records. Supported indefinitely for operators with +-- existing zone-edit tooling targeting `_ans.{fqdn}`. +-- +-- Examples: +-- '["ANS_SVCB"]' — default for new V2 registrations +-- '["ANS_TXT"]' — V1 lane + pre-PR rows +-- '["ANS_SVCB","ANS_TXT"]' — §4.4.2 transition union -- -- Nullable to allow rows that pre-date this migration to load. The --- backfill below sets every such row to LEGACY because every agent --- registered before this PR shipped received the original `_ans` TXT --- shape — defaulting them to CONSOLIDATED would silently demand SVCB --- records they were never told to publish. CHECK matches the --- precedent set by migrations 002 (csr_type) and 003 (schema_version) --- so corrupt rows fail at the storage boundary instead of silently --- coercing to default in the domain layer. +-- backfill below sets every such row to ["ANS_TXT"] because every +-- agent registered before this PR shipped received the original +-- `_ans` TXT shape — defaulting them to ["ANS_SVCB"] would silently +-- demand SVCB records they were never told to publish. CHECK uses +-- json_valid() (SQLite JSON1) so a malformed array fails at the +-- storage boundary instead of silently coercing in the domain. +-- Element-level validation lives in the service layer, where the +-- INVALID_DNS_RECORD_STYLE error is raised before the row is written. ALTER TABLE agent_registrations - ADD COLUMN dns_record_style TEXT - CHECK (dns_record_style IS NULL - OR dns_record_style IN ('CONSOLIDATED', 'LEGACY', 'BOTH')); + ADD COLUMN dns_record_styles TEXT + CHECK (dns_record_styles IS NULL OR json_valid(dns_record_styles)); -- Backfill: every row registered before this migration shipped was -- emitting the legacy `_ans` TXT shape (the only shape pre-PR-13). --- Stamp them as LEGACY so post-deploy verify-dns calls demand the --- record family the operator actually published. New rows get the --- value written explicitly by applyDNSRecordStyle in the service. +-- Stamp them as ["ANS_TXT"] so post-deploy verify-dns calls demand +-- the record family the operator actually published. New rows get +-- the value written explicitly by applyDNSRecordStyles in the service. UPDATE agent_registrations - SET dns_record_style = 'LEGACY' - WHERE dns_record_style IS NULL; + SET dns_record_styles = '["ANS_TXT"]' + WHERE dns_record_styles IS NULL; diff --git a/internal/domain/agent.go b/internal/domain/agent.go index aa3fde7..6149609 100644 --- a/internal/domain/agent.go +++ b/internal/domain/agent.go @@ -111,12 +111,13 @@ type AgentRegistration struct { // populator path is intentionally not wired in this PR. CapabilitiesHash string `json:"capabilitiesHash,omitempty"` - // DNSRecordStyle selects which DNS record family the RA emits - // for this registration: "consolidated" (Consolidated Approach - // SVCB rows, default), "legacy" (the original `_ans` TXT shape), - // or "both" (the transition union). Empty at the domain layer - // is treated as DefaultDNSRecordStyle by ComputeRequiredDNSRecords. - DNSRecordStyle DNSRecordStyle `json:"dnsRecordStyle,omitempty"` + // DNSRecordStyles is the set of DNS record families the RA emits + // for this registration. Each value names one family — typically + // {ANS_SVCB} (Consolidated Approach), {ANS_TXT} (original `_ans` + // TXT shape), or the {ANS_SVCB, ANS_TXT} transition union. Empty + // at the domain layer is treated as DefaultDNSRecordStyles() by + // ComputeRequiredDNSRecords. + DNSRecordStyles []DNSRecordStyle `json:"dnsRecordStyles,omitempty"` // PendingEvents holds domain events raised during this aggregate operation. // They are cleared after being published. diff --git a/internal/domain/dnsrecords.go b/internal/domain/dnsrecords.go index ee51271..f6e13ba 100644 --- a/internal/domain/dnsrecords.go +++ b/internal/domain/dnsrecords.go @@ -8,73 +8,93 @@ import ( "strconv" ) -// DNSRecordStyle selects which DNS record family the RA emits in its -// dnsRecordsProvisioned attestation and in the records it tells the -// operator to publish at registration time. -// -// Default is CONSOLIDATED: one SVCB record per protocol at the -// agent's bare FQDN per the cross-draft Consolidated Approach (§4.4.2). -// Operators on infrastructure that already publishes the legacy -// `_ans` TXT family pick LEGACY. Migration operators pick BOTH -// for a defined window, then flip back to CONSOLIDATED. -// -// LEGACY MUST stay supported indefinitely. Operators picking LEGACY -// will continue to receive the original `_ans` TXT shape this RA has -// emitted since v0.1.x. The cross-channel hash consistency check -// (§4.4.2) only applies when the SVCB record is present, so LEGACY -// agents do not benefit from the card-sha256 ↔ capabilities_hash -// guarantee — that is a property of the chosen style, not a defect. +// DNSRecordStyle names one DNS record family the RA can emit for an +// agent registration. A registration carries a *set* of styles +// (AgentRegistration.DNSRecordStyles); operators publishing the union +// during a Consolidated Approach transition include both ANS_SVCB and +// ANS_TXT in the same set. // // Wire values are CONSTANT_CASE, matching every other enum on the V2 // register schema (Protocol, RevocationReason, AgentLifecycleStatus, -// NextStep.action, ChallengeInfo.type, DnsRecord.type, etc.). +// NextStep.action, ChallengeInfo.type, DnsRecord.type, etc.). The +// `ANS_` prefix anchors the namespace so a future second agentic spec +// adding its own SVCB family doesn't collide. type DNSRecordStyle string const ( - // DNSRecordStyleConsolidated emits Consolidated Approach SVCB - // records (one per protocol, bare-FQDN owner) plus the - // `_ans-prefixed` records that no SvcParam covers (badge, - // identity DANE) plus the server-cert TLSA. The default. - DNSRecordStyleConsolidated DNSRecordStyle = "CONSOLIDATED" - - // DNSRecordStyleLegacy emits the original `_ans` TXT family - // (one per protocol) plus the same `_ans-`-prefixed records - // plus the server-cert TLSA. No SVCB rows. - DNSRecordStyleLegacy DNSRecordStyle = "LEGACY" + // DNSRecordStyleSVCB emits Consolidated Approach SVCB records per + // RFC 9460 — one row per protocol at the bare FQDN, carrying alpn, + // port, wk, and card-sha256 SvcParams. + DNSRecordStyleSVCB DNSRecordStyle = "ANS_SVCB" - // DNSRecordStyleBoth emits the union of Consolidated Approach - // SVCB and legacy `_ans` TXT — the transition shape per §4.4.2 - // where the two record families coexist on the same agent's zone. - DNSRecordStyleBoth DNSRecordStyle = "BOTH" + // DNSRecordStyleTXT emits the original `_ans` TXT shape — one row + // per protocol at `_ans.{fqdn}`. Supported indefinitely for + // operators with existing zone-edit tooling that targets `_ans.`. + // Includes an HTTPS RR at the bare FQDN since `_ans` TXT carries + // no connection hints. + DNSRecordStyleTXT DNSRecordStyle = "ANS_TXT" ) -// DefaultDNSRecordStyle is the style applied when the registration -// request omits dnsRecordStyle entirely. Pinned to CONSOLIDATED so -// new integrations follow §4.4.2's "publish one SVCB record... rather -// than parallel per-ecosystem record trees" SHOULD by default. -const DefaultDNSRecordStyle = DNSRecordStyleConsolidated +// DefaultDNSRecordStyles is the set applied when the registration +// request omits dnsRecordStyles entirely. Pinned to {ANS_SVCB} so new +// integrations follow §4.4.2's "publish one SVCB record... rather than +// parallel per-ecosystem record trees" SHOULD by default. Returned as a +// fresh slice so callers can mutate without affecting the canonical set. +func DefaultDNSRecordStyles() []DNSRecordStyle { + return []DNSRecordStyle{DNSRecordStyleSVCB} +} -// IsValid reports whether s is one of the three defined styles. -// Empty string is treated as invalid; callers normalize empty to -// DefaultDNSRecordStyle before validation. +// IsValid reports whether s is one of the defined styles. Empty +// string is treated as invalid; callers normalize empty/missing +// dnsRecordStyles to DefaultDNSRecordStyles() before validation. func (s DNSRecordStyle) IsValid() bool { switch s { - case DNSRecordStyleConsolidated, DNSRecordStyleLegacy, DNSRecordStyleBoth: + case DNSRecordStyleSVCB, DNSRecordStyleTXT: return true } return false } -// DNSRecordStyles returns the canonical valid set as strings — the -// single source of truth for enum membership. Used by error messages -// and (eventually) by spec generation tooling so adding a fourth -// style is a one-place change rather than a shotgun edit. -func DNSRecordStyles() []string { +// ValidDNSRecordStyles returns the canonical valid set as strings — +// the single source of truth for enum membership. Used by error +// messages and spec generation tooling so adding a third style is a +// one-place change rather than a shotgun edit. +func ValidDNSRecordStyles() []string { return []string{ - string(DNSRecordStyleConsolidated), - string(DNSRecordStyleLegacy), - string(DNSRecordStyleBoth), + string(DNSRecordStyleSVCB), + string(DNSRecordStyleTXT), + } +} + +// resolveEmissionFlags maps a set of styles onto the two orthogonal +// "emit this record family?" booleans the record builder uses. An +// empty/nil set normalizes to DefaultDNSRecordStyles(); invalid +// values in the set are silently ignored (the service layer rejects +// them at the boundary, so any value reaching here SHOULD already be +// valid — defensive ignore keeps the domain layer pure). +// +// Returns (emitTXT, emitSVCB) — order matters; the caller destructures +// positionally to two booleans guarding the legacy and consolidated +// branches of ComputeRequiredDNSRecords. +func resolveEmissionFlags(styles []DNSRecordStyle) (bool, bool) { + if len(styles) == 0 { + styles = DefaultDNSRecordStyles() + } + var emitTXT, emitSVCB bool + for _, s := range styles { + switch s { + case DNSRecordStyleSVCB: + emitSVCB = true + case DNSRecordStyleTXT: + emitTXT = true + } + } + if !emitTXT && !emitSVCB { + // Every element was invalid — fall back to the default set so + // the operator at least gets some records to publish. + emitSVCB = true } + return emitTXT, emitSVCB } // DNSRecordType represents a DNS record type. @@ -118,20 +138,21 @@ type ExpectedDNSRecord struct { // for a given agent registration. The RA does not create these records — the // operator manages their own DNS. The RA only verifies they exist. // -// The set of records emitted depends on reg.DNSRecordStyle: +// The set of records emitted is keyed off reg.DNSRecordStyles: // -// - "consolidated" (default, recommended): Consolidated Approach SVCB +// - {ANS_SVCB} (default, recommended): Consolidated Approach SVCB // rows (one per protocol) plus the shared `_ans-`-prefixed records // plus the server-cert TLSA. No legacy `_ans` TXT rows. -// - "legacy": the original `_ans` TXT shape (one row per protocol) +// - {ANS_TXT}: the original `_ans` TXT shape (one row per protocol) // plus the same shared records. No SVCB rows. Backwards-compatible // with operators who registered before the Consolidated Approach // landed and have existing zone-edit tooling for `_ans` TXT. -// - "both": union of consolidated + legacy. The §4.4.2 transition -// shape; operators run both record families on the same zone for -// a defined window, then flip back to "consolidated". +// - {ANS_SVCB, ANS_TXT}: the §4.4.2 transition shape; operators run +// both record families on the same zone for a defined window. // -// Empty reg.DNSRecordStyle is normalized to DefaultDNSRecordStyle. +// Empty/missing reg.DNSRecordStyles is normalized to +// DefaultDNSRecordStyles(); invalid elements are dropped (the +// service layer rejects bad inputs at the boundary). func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { fqdn := reg.FQDN() // Version is emitted as a bare semver string ("1.2.0"). The @@ -140,17 +161,12 @@ func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { // directly, matching the shape a client would parse with any // semver library. version := reg.AnsName.Version().String() - style := reg.DNSRecordStyle - if !style.IsValid() { - style = DefaultDNSRecordStyle - } var records []ExpectedDNSRecord - emitLegacy := style == DNSRecordStyleLegacy || style == DNSRecordStyleBoth - emitConsolidated := style == DNSRecordStyleConsolidated || style == DNSRecordStyleBoth + emitTXT, emitSVCB := resolveEmissionFlags(reg.DNSRecordStyles) // _ans TXT record for each protocol endpoint — legacy discovery. - if emitLegacy { + if emitTXT { for _, ep := range reg.Endpoints { value := fmt.Sprintf("v=ans1; version=%s; p=%s; mode=direct; url=%s", version, protocolToANSValue(ep.Protocol), ep.AgentURL) @@ -214,7 +230,7 @@ func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { // // Required=false: §4.4.2 marks the Consolidated Approach as MAY, // opt-in alongside the `_ans` TXT family during the transition. - if emitConsolidated { + if emitSVCB { cardSHA := capabilitiesHashBase64URL(reg.CapabilitiesHash) for _, ep := range reg.Endpoints { alpn := protocolToANSValue(ep.Protocol) diff --git a/internal/domain/dnsrecords_test.go b/internal/domain/dnsrecords_test.go index 7a44c1f..fd4c2c9 100644 --- a/internal/domain/dnsrecords_test.go +++ b/internal/domain/dnsrecords_test.go @@ -12,10 +12,10 @@ func TestComputeRequiredDNSRecords_WithoutCert(t *testing.T) { ansName, _ := NewAnsName(mustSemVer(1, 2, 3), "agent.example.com") reg := &AgentRegistration{ AnsName: ansName, - // Force "both" style so this fixture exercises the union path: - // _ans TXT + Consolidated Approach SVCB. Tests below cover the - // single-style emission paths. - DNSRecordStyle: DNSRecordStyleBoth, + // Force the union set so this fixture exercises both record + // families: _ans TXT + Consolidated Approach SVCB. Tests below + // cover the single-style emission paths. + DNSRecordStyles: []DNSRecordStyle{DNSRecordStyleSVCB, DNSRecordStyleTXT}, Endpoints: []AgentEndpoint{ {Protocol: ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com/a2a"}, @@ -82,26 +82,28 @@ func TestComputeRequiredDNSRecords_WithoutCert(t *testing.T) { assert.Equal(t, 0, tlsaCount, "no cert → no TLSA record") } -// TestComputeRequiredDNSRecords_StyleMatrix exercises every per-style -// emission rule in one table. Each row pins the per-record-type shape -// the operator is asked to publish given a (style, protocol, +// TestComputeRequiredDNSRecords_StyleMatrix exercises every emission +// rule in one table. Each row pins the per-record-type shape the +// operator is asked to publish given a (styles, protocol, // capabilitiesHash, agentURL) tuple. The matrix covers: // -// - LEGACY emits _ans TXT + HTTPS RR; no SVCB. -// - CONSOLIDATED emits SVCB only (no HTTPS RR — duplicate signalling). -// - BOTH emits the union. +// - {ANS_TXT} emits _ans TXT + HTTPS RR; no SVCB. +// - {ANS_SVCB} emits SVCB only (no HTTPS RR — duplicate signalling). +// - {ANS_SVCB, ANS_TXT} emits the union. // - SVCB SvcParam composition: wk= (per-protocol), port= (from URL), // card-sha256= (only when CapabilitiesHash is set). // - svcbPortFor: explicit non-443 port flows through, default https // URLs fall back to 443. -// - Invalid style coerces to default (CONSOLIDATED). +// - Empty styles (nil slice) coerces to the default ({ANS_SVCB}). +// - All-invalid styles set still produces records (defensive +// fallback in the domain layer; the service rejects bad inputs). func TestComputeRequiredDNSRecords_StyleMatrix(t *testing.T) { const cardHex = "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27" const wantCardBase64 = "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc" tests := []struct { name string - style DNSRecordStyle + styles []DNSRecordStyle protocol Protocol agentURL string capabilitiesHash string @@ -113,16 +115,16 @@ func TestComputeRequiredDNSRecords_StyleMatrix(t *testing.T) { wantSVCBCard string // "" means SVCB MUST NOT contain "card-sha256" }{ { - name: "legacy-emits-https-rr-no-svcb", - style: DNSRecordStyleLegacy, + name: "ans_txt_only_emits_https_rr_no_svcb", + styles: []DNSRecordStyle{DNSRecordStyleTXT}, protocol: ProtocolA2A, agentURL: "https://agent.example.com", wantHTTPS: true, wantLegacyTXT: true, }, { - name: "consolidated-omits-https-rr", - style: DNSRecordStyleConsolidated, + name: "ans_svcb_only_omits_https_rr", + styles: []DNSRecordStyle{DNSRecordStyleSVCB}, protocol: ProtocolA2A, agentURL: "https://agent.example.com", wantSVCB: true, @@ -130,8 +132,8 @@ func TestComputeRequiredDNSRecords_StyleMatrix(t *testing.T) { wantSVCBWk: "wk=agent-card.json", }, { - name: "both-emits-union", - style: DNSRecordStyleBoth, + name: "union_emits_both_families", + styles: []DNSRecordStyle{DNSRecordStyleSVCB, DNSRecordStyleTXT}, protocol: ProtocolA2A, agentURL: "https://agent.example.com", wantHTTPS: true, @@ -141,8 +143,8 @@ func TestComputeRequiredDNSRecords_StyleMatrix(t *testing.T) { wantSVCBWk: "wk=agent-card.json", }, { - name: "svcb-mcp-wk-mcp-json", - style: DNSRecordStyleConsolidated, + name: "svcb_mcp_wk_mcp_json", + styles: []DNSRecordStyle{DNSRecordStyleSVCB}, protocol: ProtocolMCP, agentURL: "https://agent.example.com/mcp", wantSVCB: true, @@ -150,8 +152,8 @@ func TestComputeRequiredDNSRecords_StyleMatrix(t *testing.T) { wantSVCBWk: "wk=mcp.json", }, { - name: "svcb-http-api-omits-wk", - style: DNSRecordStyleConsolidated, + name: "svcb_http_api_omits_wk", + styles: []DNSRecordStyle{DNSRecordStyleSVCB}, protocol: ProtocolHTTPAPI, agentURL: "https://agent.example.com", wantSVCB: true, @@ -159,8 +161,8 @@ func TestComputeRequiredDNSRecords_StyleMatrix(t *testing.T) { // HTTP-API has no per-protocol metadata file convention. }, { - name: "svcb-card-sha256-present-when-set", - style: DNSRecordStyleConsolidated, + name: "svcb_card_sha256_present_when_set", + styles: []DNSRecordStyle{DNSRecordStyleSVCB}, protocol: ProtocolA2A, agentURL: "https://agent.example.com", capabilitiesHash: cardHex, @@ -170,8 +172,8 @@ func TestComputeRequiredDNSRecords_StyleMatrix(t *testing.T) { wantSVCBCard: "card-sha256=" + wantCardBase64, }, { - name: "svcb-non-443-port-from-url", - style: DNSRecordStyleConsolidated, + name: "svcb_non_443_port_from_url", + styles: []DNSRecordStyle{DNSRecordStyleSVCB}, protocol: ProtocolA2A, agentURL: "https://agent.example.com:8443", wantSVCB: true, @@ -179,8 +181,8 @@ func TestComputeRequiredDNSRecords_StyleMatrix(t *testing.T) { wantSVCBWk: "wk=agent-card.json", }, { - name: "svcb-http-scheme-defaults-port-80", - style: DNSRecordStyleConsolidated, + name: "svcb_http_scheme_defaults_port_80", + styles: []DNSRecordStyle{DNSRecordStyleSVCB}, protocol: ProtocolA2A, agentURL: "http://agent.example.com", wantSVCB: true, @@ -188,8 +190,17 @@ func TestComputeRequiredDNSRecords_StyleMatrix(t *testing.T) { wantSVCBWk: "wk=agent-card.json", }, { - name: "invalid-style-coerces-to-consolidated", - style: DNSRecordStyle("garbage"), + name: "empty_styles_coerces_to_default", + styles: nil, + protocol: ProtocolA2A, + agentURL: "https://agent.example.com", + wantSVCB: true, + wantSVCBPort: "port=443", + wantSVCBWk: "wk=agent-card.json", + }, + { + name: "all_invalid_styles_falls_back_to_default", + styles: []DNSRecordStyle{DNSRecordStyle("garbage"), DNSRecordStyle("nonsense")}, protocol: ProtocolA2A, agentURL: "https://agent.example.com", wantSVCB: true, @@ -203,7 +214,7 @@ func TestComputeRequiredDNSRecords_StyleMatrix(t *testing.T) { ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") reg := &AgentRegistration{ AnsName: ansName, - DNSRecordStyle: tc.style, + DNSRecordStyles: tc.styles, CapabilitiesHash: tc.capabilitiesHash, Endpoints: []AgentEndpoint{ {Protocol: tc.protocol, AgentURL: tc.agentURL}, @@ -305,13 +316,22 @@ func TestWkPathFor(t *testing.T) { } } -// TestDNSRecordStyles pins the canonical valid set of DNSRecordStyle -// values returned by the helper used in the V2 INVALID_DNS_RECORD_STYLE -// error message. Order and contents are stable so an external client's -// error-message fixtures can match. -func TestDNSRecordStyles(t *testing.T) { - got := DNSRecordStyles() - want := []string{"CONSOLIDATED", "LEGACY", "BOTH"} +// TestValidDNSRecordStyles pins the canonical valid set of +// DNSRecordStyle values returned by the helper used in the V2 +// INVALID_DNS_RECORD_STYLE error message and (eventually) by spec +// generation tooling. Order and contents are stable so an external +// client's error-message fixtures can match. +func TestValidDNSRecordStyles(t *testing.T) { + got := ValidDNSRecordStyles() + want := []string{"ANS_SVCB", "ANS_TXT"} + assert.Equal(t, want, got) +} + +// TestDefaultDNSRecordStyles pins the default set applied when a V2 +// register request omits dnsRecordStyles. {ANS_SVCB} per §4.4.2. +func TestDefaultDNSRecordStyles(t *testing.T) { + got := DefaultDNSRecordStyles() + want := []DNSRecordStyle{DNSRecordStyleSVCB} assert.Equal(t, want, got) } diff --git a/internal/ra/handler/registration.go b/internal/ra/handler/registration.go index 7a85e1b..fd2050b 100644 --- a/internal/ra/handler/registration.go +++ b/internal/ra/handler/registration.go @@ -40,13 +40,13 @@ type registrationRequest struct { ServerCertificatePEM string `json:"serverCertificatePEM,omitempty"` ServerCertificateChainPEM string `json:"serverCertificateChainPEM,omitempty"` - // DNSRecordStyle selects which DNS record family the RA emits - // for this registration. One of "consolidated" (default, - // recommended), "legacy" (original `_ans` TXT shape), "both" - // (transition union). Empty/missing → consolidated. Invalid - // value rejected with 422 INVALID_DNS_RECORD_STYLE. See - // ANS_SPEC.md §4.4.2 for record-shape semantics. - DNSRecordStyle string `json:"dnsRecordStyle,omitempty"` + // DNSRecordStyles is the set of DNS record families the RA emits + // for this registration. Each element is one of "ANS_SVCB" or + // "ANS_TXT". Typical values: ["ANS_SVCB"] (default, recommended), + // ["ANS_TXT"], or ["ANS_SVCB", "ANS_TXT"] (transition union). + // Empty/missing → ["ANS_SVCB"]. Any invalid element rejected + // with 422 INVALID_DNS_RECORD_STYLE. See ANS_SPEC.md §4.4.2. + DNSRecordStyles []string `json:"dnsRecordStyles,omitempty"` } type endpointDTO struct { @@ -161,7 +161,7 @@ func (h *RegistrationHandler) Register(w http.ResponseWriter, r *http.Request) { ServerCsrPEM: req.ServerCsrPEM, ServerCertificatePEM: req.ServerCertificatePEM, ServerCertificateChainPEM: req.ServerCertificateChainPEM, - DNSRecordStyle: domain.DNSRecordStyle(req.DNSRecordStyle), + DNSRecordStyles: toDomainDNSRecordStyles(req.DNSRecordStyles), }) if err != nil { WriteError(w, err) @@ -171,6 +171,21 @@ func (h *RegistrationHandler) Register(w http.ResponseWriter, r *http.Request) { WriteJSON(w, http.StatusAccepted, mapRegistrationResponse(resp, r)) } +// toDomainDNSRecordStyles converts the wire []string into the typed +// domain slice. Empty/nil flows through as nil so the service layer +// can apply DefaultDNSRecordStyles(). Per-element validity is enforced +// downstream by applyDNSRecordStyles. +func toDomainDNSRecordStyles(raw []string) []domain.DNSRecordStyle { + if len(raw) == 0 { + return nil + } + out := make([]domain.DNSRecordStyle, len(raw)) + for i, s := range raw { + out[i] = domain.DNSRecordStyle(s) + } + return out +} + // mapEndpointsFromDTO converts the incoming JSON endpoints to the // domain types, returning a validation error on malformed input. func mapEndpointsFromDTO(dtos []endpointDTO) ([]domain.AgentEndpoint, error) { diff --git a/internal/ra/service/helpers.go b/internal/ra/service/helpers.go index 21df3cf..8543c1c 100644 --- a/internal/ra/service/helpers.go +++ b/internal/ra/service/helpers.go @@ -12,37 +12,49 @@ import ( "github.com/godaddy/ans/internal/domain" ) -// applyDNSRecordStyle resolves the DNS-record-style for the new -// registration and stores it on the aggregate. +// applyDNSRecordStyles resolves the set of DNS record families the +// registration emits and stores it on the aggregate. // -// V1 lane is pinned to LEGACY regardless of the request: V1 callers -// predate the Consolidated Approach and their tooling expects the -// original `_ans` TXT shape. V1 has no dnsRecordStyle field on the -// wire, so this branch is the only path V1 registrations take. -// V2 callers honor req.DNSRecordStyle: empty normalizes to -// DefaultDNSRecordStyle (CONSOLIDATED); invalid values surface as -// INVALID_DNS_RECORD_STYLE. +// V1 lane is pinned to {ANS_TXT} regardless of the request: V1 +// callers predate the Consolidated Approach and their tooling expects +// the original `_ans` TXT shape. V1 has no dnsRecordStyles field on +// the wire, so this branch is the only path V1 registrations take. +// V2 callers honor req.DNSRecordStyles: empty/nil normalizes to +// DefaultDNSRecordStyles() ({ANS_SVCB}); any invalid element surfaces +// as INVALID_DNS_RECORD_STYLE; duplicates are deduplicated to keep +// the persisted set canonical. // // V1 detection routes through isV1Lane (lifecycle.go) so a future // schema-version evolution updates one site, not several. The error -// message lists valid values from domain.DNSRecordStyles() so adding -// a fourth style is a one-place change. -func applyDNSRecordStyle(reg *domain.AgentRegistration, req RegisterRequest) error { - switch { - case isV1Lane(req.SchemaVersion): - reg.DNSRecordStyle = domain.DNSRecordStyleLegacy - case req.DNSRecordStyle == "": - reg.DNSRecordStyle = domain.DefaultDNSRecordStyle - case !req.DNSRecordStyle.IsValid(): - return domain.NewValidationError( - "INVALID_DNS_RECORD_STYLE", - fmt.Sprintf("dnsRecordStyle %q is not one of %s", - string(req.DNSRecordStyle), - strings.Join(domain.DNSRecordStyles(), ", ")), - ) - default: - reg.DNSRecordStyle = req.DNSRecordStyle +// message lists valid values from domain.ValidDNSRecordStyles() so +// adding a third style is a one-place change. +func applyDNSRecordStyles(reg *domain.AgentRegistration, req RegisterRequest) error { + if isV1Lane(req.SchemaVersion) { + reg.DNSRecordStyles = []domain.DNSRecordStyle{domain.DNSRecordStyleTXT} + return nil } + if len(req.DNSRecordStyles) == 0 { + reg.DNSRecordStyles = domain.DefaultDNSRecordStyles() + return nil + } + seen := make(map[domain.DNSRecordStyle]struct{}, len(req.DNSRecordStyles)) + out := make([]domain.DNSRecordStyle, 0, len(req.DNSRecordStyles)) + for _, s := range req.DNSRecordStyles { + if !s.IsValid() { + return domain.NewValidationError( + "INVALID_DNS_RECORD_STYLE", + fmt.Sprintf("dnsRecordStyles element %q is not one of %s", + string(s), + strings.Join(domain.ValidDNSRecordStyles(), ", ")), + ) + } + if _, dup := seen[s]; dup { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + reg.DNSRecordStyles = out return nil } diff --git a/internal/ra/service/helpers_test.go b/internal/ra/service/helpers_test.go index e4650c0..6b442a4 100644 --- a/internal/ra/service/helpers_test.go +++ b/internal/ra/service/helpers_test.go @@ -203,69 +203,124 @@ func selfSignedCertPEM(t *testing.T) string { return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) } -// ----- applyDNSRecordStyle ----- +// ----- applyDNSRecordStyles ----- -// TestApplyDNSRecordStyle covers the V1-pin / V2-default / V2-validate -// branches, including the INVALID_DNS_RECORD_STYLE error path. The -// integration tests follow happy paths through RegisterAgent and don't -// reach the invalid-value branch directly. -func TestApplyDNSRecordStyle(t *testing.T) { +// TestApplyDNSRecordStyles covers the V1-pin / V2-default / V2-validate +// branches, including the INVALID_DNS_RECORD_STYLE error path and +// duplicate-element deduplication. The integration tests follow happy +// paths through RegisterAgent and don't reach the invalid-element +// branch directly. +func TestApplyDNSRecordStyles(t *testing.T) { tests := []struct { name string req RegisterRequest - wantStyle domain.DNSRecordStyle + wantStyles []domain.DNSRecordStyle wantErrCode string }{ { - name: "v1_pins_to_legacy_ignoring_request_field", + name: "v1_pins_to_ans_txt_ignoring_request_field", req: RegisterRequest{ - SchemaVersion: "V1", - DNSRecordStyle: domain.DNSRecordStyleConsolidated, + SchemaVersion: "V1", + DNSRecordStyles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, }, - wantStyle: domain.DNSRecordStyleLegacy, + wantStyles: []domain.DNSRecordStyle{domain.DNSRecordStyleTXT}, }, { - name: "v2_empty_normalizes_to_default", - req: RegisterRequest{SchemaVersion: "V2", DNSRecordStyle: ""}, - wantStyle: domain.DefaultDNSRecordStyle, + name: "v2_nil_normalizes_to_default", + req: RegisterRequest{SchemaVersion: "V2"}, + wantStyles: domain.DefaultDNSRecordStyles(), }, { - name: "unset_schema_treated_as_v2_default", - req: RegisterRequest{SchemaVersion: "", DNSRecordStyle: ""}, - wantStyle: domain.DefaultDNSRecordStyle, + name: "v2_empty_slice_normalizes_to_default", + req: RegisterRequest{SchemaVersion: "V2", DNSRecordStyles: []domain.DNSRecordStyle{}}, + wantStyles: domain.DefaultDNSRecordStyles(), }, { - name: "v2_valid_consolidated", - req: RegisterRequest{SchemaVersion: "V2", DNSRecordStyle: domain.DNSRecordStyleConsolidated}, - wantStyle: domain.DNSRecordStyleConsolidated, + name: "unset_schema_treated_as_v2_default", + req: RegisterRequest{}, + wantStyles: domain.DefaultDNSRecordStyles(), }, { - name: "v2_valid_legacy", - req: RegisterRequest{SchemaVersion: "V2", DNSRecordStyle: domain.DNSRecordStyleLegacy}, - wantStyle: domain.DNSRecordStyleLegacy, + name: "v2_valid_ans_svcb_only", + req: RegisterRequest{ + SchemaVersion: "V2", + DNSRecordStyles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + }, + wantStyles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, }, { - name: "v2_valid_both", - req: RegisterRequest{SchemaVersion: "V2", DNSRecordStyle: domain.DNSRecordStyleBoth}, - wantStyle: domain.DNSRecordStyleBoth, + name: "v2_valid_ans_txt_only", + req: RegisterRequest{ + SchemaVersion: "V2", + DNSRecordStyles: []domain.DNSRecordStyle{domain.DNSRecordStyleTXT}, + }, + wantStyles: []domain.DNSRecordStyle{domain.DNSRecordStyleTXT}, + }, + { + name: "v2_valid_union_preserves_order", + req: RegisterRequest{ + SchemaVersion: "V2", + DNSRecordStyles: []domain.DNSRecordStyle{ + domain.DNSRecordStyleSVCB, + domain.DNSRecordStyleTXT, + }, + }, + wantStyles: []domain.DNSRecordStyle{ + domain.DNSRecordStyleSVCB, + domain.DNSRecordStyleTXT, + }, + }, + { + name: "v2_duplicate_elements_deduped", + req: RegisterRequest{ + SchemaVersion: "V2", + DNSRecordStyles: []domain.DNSRecordStyle{ + domain.DNSRecordStyleSVCB, + domain.DNSRecordStyleSVCB, + domain.DNSRecordStyleTXT, + }, + }, + wantStyles: []domain.DNSRecordStyle{ + domain.DNSRecordStyleSVCB, + domain.DNSRecordStyleTXT, + }, }, { - name: "v2_invalid_value_rejected", - req: RegisterRequest{SchemaVersion: "V2", DNSRecordStyle: domain.DNSRecordStyle("garbage")}, + name: "v2_invalid_element_rejected", + req: RegisterRequest{ + SchemaVersion: "V2", + DNSRecordStyles: []domain.DNSRecordStyle{domain.DNSRecordStyle("garbage")}, + }, wantErrCode: "INVALID_DNS_RECORD_STYLE", }, { // CONSTANT_CASE is the wire form. lowercase is rejected so the // V2 enum stays consistent with every other enum on the spec. - name: "v2_lowercase_legacy_rejected_as_invalid", - req: RegisterRequest{SchemaVersion: "V2", DNSRecordStyle: domain.DNSRecordStyle("legacy")}, + name: "v2_lowercase_element_rejected_as_invalid", + req: RegisterRequest{ + SchemaVersion: "V2", + DNSRecordStyles: []domain.DNSRecordStyle{domain.DNSRecordStyle("ans_svcb")}, + }, + wantErrCode: "INVALID_DNS_RECORD_STYLE", + }, + { + // First valid, second invalid — error surfaces at the + // invalid element, no partial state stamped on the aggregate. + name: "v2_mixed_valid_then_invalid_rejected", + req: RegisterRequest{ + SchemaVersion: "V2", + DNSRecordStyles: []domain.DNSRecordStyle{ + domain.DNSRecordStyleSVCB, + domain.DNSRecordStyle("garbage"), + }, + }, wantErrCode: "INVALID_DNS_RECORD_STYLE", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { reg := &domain.AgentRegistration{} - err := applyDNSRecordStyle(reg, tc.req) + err := applyDNSRecordStyles(reg, tc.req) if tc.wantErrCode != "" { if err == nil { t.Fatalf("want error code %q, got nil", tc.wantErrCode) @@ -282,28 +337,44 @@ func TestApplyDNSRecordStyle(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if reg.DNSRecordStyle != tc.wantStyle { - t.Errorf("DNSRecordStyle: got %q want %q", reg.DNSRecordStyle, tc.wantStyle) + if !sameStyles(reg.DNSRecordStyles, tc.wantStyles) { + t.Errorf("DNSRecordStyles: got %v want %v", reg.DNSRecordStyles, tc.wantStyles) } }) } } -// TestApplyDNSRecordStyle_ErrorMessageListsValidValues confirms the +// TestApplyDNSRecordStyles_ErrorMessageListsValidValues confirms the // error detail enumerates the canonical valid set so SDK authors get -// an actionable message. Sourced from domain.DNSRecordStyles(). -func TestApplyDNSRecordStyle_ErrorMessageListsValidValues(t *testing.T) { +// an actionable message. Sourced from domain.ValidDNSRecordStyles(). +func TestApplyDNSRecordStyles_ErrorMessageListsValidValues(t *testing.T) { reg := &domain.AgentRegistration{} - err := applyDNSRecordStyle(reg, RegisterRequest{ - SchemaVersion: "V2", DNSRecordStyle: domain.DNSRecordStyle("garbage"), + err := applyDNSRecordStyles(reg, RegisterRequest{ + SchemaVersion: "V2", + DNSRecordStyles: []domain.DNSRecordStyle{domain.DNSRecordStyle("garbage")}, }) if err == nil { t.Fatal("expected error") } - for _, want := range domain.DNSRecordStyles() { + for _, want := range domain.ValidDNSRecordStyles() { if !strings.Contains(err.Error(), want) { t.Errorf("error message must list %q; got %q", want, err.Error()) } } } +// sameStyles compares two style slices for set-equal-with-order. Used +// by TestApplyDNSRecordStyles to assert the expected ordering after +// dedup without pulling in reflect.DeepEqual semantics that distinguish +// nil from empty. +func sameStyles(a, b []domain.DNSRecordStyle) 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/ra/service/registration.go b/internal/ra/service/registration.go index 36bb067..429c6e3 100644 --- a/internal/ra/service/registration.go +++ b/internal/ra/service/registration.go @@ -73,13 +73,14 @@ type RegisterRequest struct { ServerCertificateChainPEM string SchemaVersion string - // DNSRecordStyle selects which DNS record family the RA emits + // DNSRecordStyles is the set of DNS record families the RA emits // in dnsRecordsProvisioned and tells the operator to publish. - // "consolidated" (default), "legacy", or "both". Empty value is - // normalized to domain.DefaultDNSRecordStyle. Invalid value - // surfaces as INVALID_DNS_RECORD_STYLE before the aggregate is - // created. - DNSRecordStyle domain.DNSRecordStyle + // Each element is one of domain.ValidDNSRecordStyles(); typical + // values are {ANS_SVCB} (default), {ANS_TXT}, or the + // {ANS_SVCB, ANS_TXT} transition union. Empty/nil normalizes to + // domain.DefaultDNSRecordStyles(); any invalid element surfaces + // as INVALID_DNS_RECORD_STYLE before the aggregate is created. + DNSRecordStyles []domain.DNSRecordStyle } // RegisterResponse is returned to the HTTP handler after a successful @@ -318,7 +319,7 @@ func (s *RegistrationService) RegisterAgent(ctx context.Context, req RegisterReq } reg.ServerCSR = pendingServerCSR - if err := applyDNSRecordStyle(reg, req); err != nil { + if err := applyDNSRecordStyles(reg, req); err != nil { return nil, err } diff --git a/spec/api-spec-v2.yaml b/spec/api-spec-v2.yaml index 5a41230..fc9d326 100644 --- a/spec/api-spec-v2.yaml +++ b/spec/api-spec-v2.yaml @@ -1023,6 +1023,23 @@ components: type: string enum: [A2A, MCP, HTTP-API] + DNSRecordStyle: + type: string + enum: [ANS_SVCB, ANS_TXT] + description: | + Names one DNS record family the RA can emit for an agent + registration. Used as the element type of dnsRecordStyles[]. + + - ANS_SVCB: Consolidated Approach SVCB rows at the bare FQDN + per RFC 9460. One row per protocol carrying alpn, port, and + capability-locator SvcParams (wk, card-sha256). The + recommended default for new integrations. + - ANS_TXT: original `_ans` TXT shape (one row per protocol), + supported indefinitely for operators with existing zone-edit + tooling that targets `_ans.{fqdn}`. Emits an HTTPS RR at + the bare FQDN alongside, since `_ans` TXT carries no + connection hints. + RevocationReason: type: string enum: @@ -1065,27 +1082,26 @@ components: type: string identityCsrPEM: type: string - dnsRecordStyle: - type: string - enum: [CONSOLIDATED, LEGACY, BOTH] + dnsRecordStyles: + type: array + items: + $ref: '#/components/schemas/DNSRecordStyle' + uniqueItems: true + minItems: 1 description: | - Selects which DNS record family the RA emits in the 202 - register response's dnsRecords[] and in the - AGENT_REGISTERED TL event's - attestations.dnsRecordsProvisioned[]. Not echoed on + Set of DNS record families the RA emits in the 202 register + response's dnsRecords[] and in the AGENT_REGISTERED TL + event's attestations.dnsRecordsProvisioned[]. Not echoed on GET /v2/ans/agents/{agentId}. - - CONSOLIDATED (default, recommended): Consolidated - Approach SVCB rows at the bare FQDN per RFC 9460, - plus shared `_ans-`-prefixed records and TLSA. - - LEGACY: original `_ans` TXT shape, supported - indefinitely for operators with existing zone-edit - tooling that targets `_ans.{fqdn}`. - - BOTH: union of CONSOLIDATED + LEGACY for the - transition window. - - Empty/missing normalizes to CONSOLIDATED server-side. - example: "CONSOLIDATED" + Each value names one record family; an operator publishing + the union (Consolidated Approach SVCB plus the original + `_ans` TXT shape) sends both. Order is not significant + and duplicates are rejected (`uniqueItems: true`). + + Omitted/missing normalizes to ["ANS_SVCB"] server-side + (the recommended default per RFC 9460). + example: ["ANS_SVCB"] required: - agentDisplayName - version From 9cc50daa2c4863685a3cd4ab99b946bb152421f2 Mon Sep 17 00:00:00 2001 From: kperry Date: Fri, 22 May 2026 11:37:46 -0500 Subject: [PATCH 06/13] =?UTF-8?q?chore(sqlite):=20rename=20migration=20007?= =?UTF-8?q?=20=E2=86=92=20006=20(PR12=20capabilities=5Fhash=20slot=20recla?= =?UTF-8?q?imed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR12 (capabilities_hash) shipped migration 006 in the upstream stack that this branch was originally based on. Now that PR12 is dropped, the 006 slot is open and this branch's only migration moves into it to keep the migration sequence dense (001 → 006, no gap). Filename also pluralized to dns_record_styles.sql to match the column this PR ships (a JSON array of CONSTANT_CASE values, not a singleton string). Signed-off-by: kperry --- ...ent_dns_record_style.sql => 006_agent_dns_record_styles.sql} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename internal/adapter/store/sqlite/migrations/{007_agent_dns_record_style.sql => 006_agent_dns_record_styles.sql} (98%) diff --git a/internal/adapter/store/sqlite/migrations/007_agent_dns_record_style.sql b/internal/adapter/store/sqlite/migrations/006_agent_dns_record_styles.sql similarity index 98% rename from internal/adapter/store/sqlite/migrations/007_agent_dns_record_style.sql rename to internal/adapter/store/sqlite/migrations/006_agent_dns_record_styles.sql index 5bc298f..8ec2f1f 100644 --- a/internal/adapter/store/sqlite/migrations/007_agent_dns_record_style.sql +++ b/internal/adapter/store/sqlite/migrations/006_agent_dns_record_styles.sql @@ -1,4 +1,4 @@ --- 007_agent_dns_record_style.sql +-- 006_agent_dns_record_styles.sql -- Persist the operator's chosen set of DNS record families on the -- registration row so verify-acme / verify-dns / badge responses -- carry the same shape the operator chose at registration time. From 385d4e7954dfad4e1929cb2eb698939360bb67f7 Mon Sep 17 00:00:00 2001 From: kperry Date: Fri, 22 May 2026 14:57:39 -0500 Subject: [PATCH 07/13] fix(ra): enforce dnsRecordStyles validation per spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenAPI schemas declare minItems: 1 and uniqueItems: true, and their description says duplicates are rejected. The server previously did neither — it normalized empty arrays to the default and silently deduped duplicates — so the spec was making promises the service layer didn't keep. Tighten the service layer to match the contract: - Field omitted (nil) still defaults to ["ANS_SVCB"]; the field isn't in `required`, so omission is legal. - Field present but empty (`"dnsRecordStyles": []`) now returns 422 INVALID_DNS_RECORD_STYLE. Matches `minItems: 1`. A caller who explicitly sends an empty list is signalling intent the schema doesn't permit; defaulting silently would mask a likely client bug. - Duplicates now return 422 INVALID_DNS_RECORD_STYLE. Matches `uniqueItems: true`. Silent dedup would persist a state the caller didn't quite request. - Invalid element behavior unchanged: 422 with the canonical valid set in the message. The handler-side toDomainDNSRecordStyles now preserves the nil-vs- empty distinction so applyDNSRecordStyles can tell field omission (JSON null or missing) from explicit empty array (`[]`). JSON unmarshal of null/missing yields a nil slice; explicit `[]` yields a non-nil zero-length slice; the conversion preserves both shapes unchanged for the service layer to discriminate on. Tests updated: - v2_explicit_empty_slice_rejected (replaces v2_empty_slice_normalizes_to_default). - v2_duplicate_elements_rejected (replaces v2_duplicate_elements_deduped). - Existing v2_nil_normalizes_to_default and unset_schema cases still pass — they cover the field-omitted path. Addresses Copilot review feedback C1+C2 on PR #25 by aligning the server with the spec rather than relaxing the spec. --- internal/adapter/docsui/openapi/ra.yaml | 7 ++-- internal/ra/handler/registration.go | 13 +++++--- internal/ra/service/helpers.go | 44 ++++++++++++++++++++----- internal/ra/service/helpers_test.go | 38 ++++++++++++--------- spec/api-spec-v2.yaml | 7 ++-- 5 files changed, 74 insertions(+), 35 deletions(-) diff --git a/internal/adapter/docsui/openapi/ra.yaml b/internal/adapter/docsui/openapi/ra.yaml index fc9d326..8b80e52 100644 --- a/internal/adapter/docsui/openapi/ra.yaml +++ b/internal/adapter/docsui/openapi/ra.yaml @@ -1096,11 +1096,12 @@ components: Each value names one record family; an operator publishing the union (Consolidated Approach SVCB plus the original - `_ans` TXT shape) sends both. Order is not significant - and duplicates are rejected (`uniqueItems: true`). + `_ans` TXT shape) sends both. Order is not significant and + duplicates are rejected (`uniqueItems: true`). Omitted/missing normalizes to ["ANS_SVCB"] server-side - (the recommended default per RFC 9460). + (the recommended default per RFC 9460). An explicit empty + array is rejected (`minItems: 1`). example: ["ANS_SVCB"] required: - agentDisplayName diff --git a/internal/ra/handler/registration.go b/internal/ra/handler/registration.go index fd2050b..375497f 100644 --- a/internal/ra/handler/registration.go +++ b/internal/ra/handler/registration.go @@ -172,11 +172,16 @@ func (h *RegistrationHandler) Register(w http.ResponseWriter, r *http.Request) { } // toDomainDNSRecordStyles converts the wire []string into the typed -// domain slice. Empty/nil flows through as nil so the service layer -// can apply DefaultDNSRecordStyles(). Per-element validity is enforced -// downstream by applyDNSRecordStyles. +// domain slice while preserving the nil-vs-empty distinction. nil +// (field omitted in the JSON request) flows through as nil so the +// service layer applies DefaultDNSRecordStyles(); a non-nil empty +// slice (explicit `"dnsRecordStyles": []`) flows through as an +// empty non-nil []DNSRecordStyle so the service layer can reject it +// per the spec's `minItems: 1`. Per-element validity, duplicate +// rejection, and empty-array rejection all live in +// applyDNSRecordStyles. func toDomainDNSRecordStyles(raw []string) []domain.DNSRecordStyle { - if len(raw) == 0 { + if raw == nil { return nil } out := make([]domain.DNSRecordStyle, len(raw)) diff --git a/internal/ra/service/helpers.go b/internal/ra/service/helpers.go index 8543c1c..b20abd2 100644 --- a/internal/ra/service/helpers.go +++ b/internal/ra/service/helpers.go @@ -13,30 +13,53 @@ import ( ) // applyDNSRecordStyles resolves the set of DNS record families the -// registration emits and stores it on the aggregate. +// registration emits and stores it on the aggregate, enforcing the +// V2 spec's dnsRecordStyles validation rules at the API boundary. // // V1 lane is pinned to {ANS_TXT} regardless of the request: V1 // callers predate the Consolidated Approach and their tooling expects // the original `_ans` TXT shape. V1 has no dnsRecordStyles field on // the wire, so this branch is the only path V1 registrations take. -// V2 callers honor req.DNSRecordStyles: empty/nil normalizes to -// DefaultDNSRecordStyles() ({ANS_SVCB}); any invalid element surfaces -// as INVALID_DNS_RECORD_STYLE; duplicates are deduplicated to keep -// the persisted set canonical. +// +// V2 validation enforces the OpenAPI contract: +// - Field absent (nil slice) → defaults to DefaultDNSRecordStyles() +// ({ANS_SVCB}). The spec doesn't list dnsRecordStyles in +// `required`, so omission is legal and the server picks the +// recommended Consolidated Approach. +// - Field present but empty (`"dnsRecordStyles": []`) → 422 +// INVALID_DNS_RECORD_STYLE. Matches `minItems: 1` in the spec — +// a caller who explicitly sends an empty list is signalling +// intent that the schema doesn't permit. +// - Duplicate elements → 422 INVALID_DNS_RECORD_STYLE. Matches +// `uniqueItems: true`. Silent dedup would let a malformed +// client request persist as state the caller didn't intend. +// - Invalid element (not in ValidDNSRecordStyles()) → 422 +// INVALID_DNS_RECORD_STYLE. +// +// The handler-side conversion (toDomainDNSRecordStyles) preserves +// the nil-vs-empty distinction so this function can tell field +// omission from explicit empty. // // V1 detection routes through isV1Lane (lifecycle.go) so a future // schema-version evolution updates one site, not several. The error -// message lists valid values from domain.ValidDNSRecordStyles() so -// adding a third style is a one-place change. +// messages reference ValidDNSRecordStyles() so adding a third style +// is a one-place change. func applyDNSRecordStyles(reg *domain.AgentRegistration, req RegisterRequest) error { if isV1Lane(req.SchemaVersion) { reg.DNSRecordStyles = []domain.DNSRecordStyle{domain.DNSRecordStyleTXT} return nil } - if len(req.DNSRecordStyles) == 0 { + if req.DNSRecordStyles == nil { reg.DNSRecordStyles = domain.DefaultDNSRecordStyles() return nil } + if len(req.DNSRecordStyles) == 0 { + return domain.NewValidationError( + "INVALID_DNS_RECORD_STYLE", + "dnsRecordStyles must contain at least one element when present (omit the field to default to ["+ + string(domain.DNSRecordStyleSVCB)+"])", + ) + } seen := make(map[domain.DNSRecordStyle]struct{}, len(req.DNSRecordStyles)) out := make([]domain.DNSRecordStyle, 0, len(req.DNSRecordStyles)) for _, s := range req.DNSRecordStyles { @@ -49,7 +72,10 @@ func applyDNSRecordStyles(reg *domain.AgentRegistration, req RegisterRequest) er ) } if _, dup := seen[s]; dup { - continue + return domain.NewValidationError( + "INVALID_DNS_RECORD_STYLE", + fmt.Sprintf("dnsRecordStyles must not contain duplicates (saw %q twice)", string(s)), + ) } seen[s] = struct{}{} out = append(out, s) diff --git a/internal/ra/service/helpers_test.go b/internal/ra/service/helpers_test.go index 6b442a4..cd0c28c 100644 --- a/internal/ra/service/helpers_test.go +++ b/internal/ra/service/helpers_test.go @@ -206,10 +206,10 @@ func selfSignedCertPEM(t *testing.T) string { // ----- applyDNSRecordStyles ----- // TestApplyDNSRecordStyles covers the V1-pin / V2-default / V2-validate -// branches, including the INVALID_DNS_RECORD_STYLE error path and -// duplicate-element deduplication. The integration tests follow happy -// paths through RegisterAgent and don't reach the invalid-element -// branch directly. +// branches, including every INVALID_DNS_RECORD_STYLE rejection path +// the API guards: invalid element, duplicate, and explicit empty +// array. The integration tests follow happy paths through +// RegisterAgent and don't reach the rejection branches directly. func TestApplyDNSRecordStyles(t *testing.T) { tests := []struct { name string @@ -231,9 +231,12 @@ func TestApplyDNSRecordStyles(t *testing.T) { wantStyles: domain.DefaultDNSRecordStyles(), }, { - name: "v2_empty_slice_normalizes_to_default", - req: RegisterRequest{SchemaVersion: "V2", DNSRecordStyles: []domain.DNSRecordStyle{}}, - wantStyles: domain.DefaultDNSRecordStyles(), + // minItems: 1 in the spec — an explicit empty array is a + // signal of intent the schema doesn't allow. Distinct from + // "field omitted", which still defaults. + name: "v2_explicit_empty_slice_rejected", + req: RegisterRequest{SchemaVersion: "V2", DNSRecordStyles: []domain.DNSRecordStyle{}}, + wantErrCode: "INVALID_DNS_RECORD_STYLE", }, { name: "unset_schema_treated_as_v2_default", @@ -271,7 +274,13 @@ func TestApplyDNSRecordStyles(t *testing.T) { }, }, { - name: "v2_duplicate_elements_deduped", + // uniqueItems: true in the spec — duplicates are rejected + // rather than silently deduped. A caller sending the same + // style twice has either a client bug or an unclear + // intention; surfacing 422 forces the issue out into the + // open instead of letting a request the caller didn't + // quite mean to send become persisted state. + name: "v2_duplicate_elements_rejected", req: RegisterRequest{ SchemaVersion: "V2", DNSRecordStyles: []domain.DNSRecordStyle{ @@ -280,10 +289,7 @@ func TestApplyDNSRecordStyles(t *testing.T) { domain.DNSRecordStyleTXT, }, }, - wantStyles: []domain.DNSRecordStyle{ - domain.DNSRecordStyleSVCB, - domain.DNSRecordStyleTXT, - }, + wantErrCode: "INVALID_DNS_RECORD_STYLE", }, { name: "v2_invalid_element_rejected", @@ -363,10 +369,10 @@ func TestApplyDNSRecordStyles_ErrorMessageListsValidValues(t *testing.T) { } } -// sameStyles compares two style slices for set-equal-with-order. Used -// by TestApplyDNSRecordStyles to assert the expected ordering after -// dedup without pulling in reflect.DeepEqual semantics that distinguish -// nil from empty. +// sameStyles compares two style slices for set-equal-with-order. +// Used by TestApplyDNSRecordStyles to assert ordering on the happy +// paths without pulling in reflect.DeepEqual semantics that +// distinguish nil from empty. func sameStyles(a, b []domain.DNSRecordStyle) bool { if len(a) != len(b) { return false diff --git a/spec/api-spec-v2.yaml b/spec/api-spec-v2.yaml index fc9d326..8b80e52 100644 --- a/spec/api-spec-v2.yaml +++ b/spec/api-spec-v2.yaml @@ -1096,11 +1096,12 @@ components: Each value names one record family; an operator publishing the union (Consolidated Approach SVCB plus the original - `_ans` TXT shape) sends both. Order is not significant - and duplicates are rejected (`uniqueItems: true`). + `_ans` TXT shape) sends both. Order is not significant and + duplicates are rejected (`uniqueItems: true`). Omitted/missing normalizes to ["ANS_SVCB"] server-side - (the recommended default per RFC 9460). + (the recommended default per RFC 9460). An explicit empty + array is rejected (`minItems: 1`). example: ["ANS_SVCB"] required: - agentDisplayName From e083b3b1f4edf2b3d50675e85a554202e98795de Mon Sep 17 00:00:00 2001 From: kperry Date: Fri, 22 May 2026 15:01:08 -0500 Subject: [PATCH 08/13] fix(domain): require SVCB when ANS_SVCB is the sole style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ComputeRequiredDNSRecords previously emitted SVCB rows with Required=false unconditionally, with the comment justifying it as "§4.4.2 marks the Consolidated Approach as MAY, opt-in alongside the `_ans` TXT family during the transition." That assumption only holds in union mode. The default style is ["ANS_SVCB"] (SVCB-sole), so the default registration was emitting zero Required=true PurposeDiscovery records. The badge TXT (PurposeBadge) keeps verify-dns from passing on an empty zone, but a SVCB-sole agent could publish only the badge, skip SVCB entirely, and verify-dns would still pass — a registered agent that is undiscoverable via the discovery family the operator opted into. Fix: when ANS_SVCB is the only selected style, mark SVCB Required=true. When emitted alongside ANS_TXT (the union/transition mode), keep Required=false because the legacy `_ans` TXT family above carries the required signal — that preserves the §4.4.2 MAY-during-transition framing for operators running both families. The TestComputeRequiredDNSRecords_StyleMatrix matrix gains a wantSVCBRequired column covering both paths: SVCB-sole, default (empty styles → ["ANS_SVCB"]), and the all-invalid fallback (also ["ANS_SVCB"]) all assert Required=true; the union case asserts Required=false. The original WithoutCert test fixture uses union mode and keeps Required=false with an updated comment pointing readers at the matrix for the SVCB-sole path. Addresses Copilot review feedback C3 on PR #25. Signed-off-by: kperry --- internal/domain/dnsrecords.go | 13 ++- internal/domain/dnsrecords_test.go | 123 +++++++++++++++++------------ 2 files changed, 82 insertions(+), 54 deletions(-) diff --git a/internal/domain/dnsrecords.go b/internal/domain/dnsrecords.go index f6e13ba..48cd73a 100644 --- a/internal/domain/dnsrecords.go +++ b/internal/domain/dnsrecords.go @@ -228,8 +228,15 @@ func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { // emits keyNNNNN form will see a mismatch the RA reports as a // non-blocking integrity finding (Required=false below). // - // Required=false: §4.4.2 marks the Consolidated Approach as MAY, - // opt-in alongside the `_ans` TXT family during the transition. + // Required: SVCB rows carry the registration's only + // PurposeDiscovery signal when ANS_SVCB is the sole style; in + // that mode verify-dns must require them, otherwise the agent + // could "register" without publishing any discovery record. When + // the operator opted into the union ({ANS_SVCB, ANS_TXT}), the + // legacy `_ans` TXT family above carries Required=true and the + // SVCB row stays optional alongside it (§4.4.2 marks the + // Consolidated Approach as MAY during the transition). + svcbRequired := emitSVCB && !emitTXT if emitSVCB { cardSHA := capabilitiesHashBase64URL(reg.CapabilitiesHash) for _, ep := range reg.Endpoints { @@ -255,7 +262,7 @@ func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { Type: DNSRecordSVCB, Value: value, Purpose: PurposeDiscovery, - Required: false, + Required: svcbRequired, TTL: 3600, }) } diff --git a/internal/domain/dnsrecords_test.go b/internal/domain/dnsrecords_test.go index fd4c2c9..8306e96 100644 --- a/internal/domain/dnsrecords_test.go +++ b/internal/domain/dnsrecords_test.go @@ -46,7 +46,12 @@ func TestComputeRequiredDNSRecords_WithoutCert(t *testing.T) { svcbCount++ assert.Equal(t, "agent.example.com", r.Name, "Consolidated Approach SVCB at the bare FQDN, not at _ans.{fqdn}") - assert.False(t, r.Required, "Consolidated Approach SVCB is MAY per §4.4.2") + // Union fixture ({ANS_SVCB, ANS_TXT}): legacy TXT + // carries Required=true, SVCB rides along as optional + // per §4.4.2's MAY-during-transition framing. The + // SVCB-sole path flips this to Required=true; covered + // in TestComputeRequiredDNSRecords_StyleMatrix. + assert.False(t, r.Required, "SVCB optional when emitted alongside legacy TXT") assert.Contains(t, r.Value, `1 . `, "ServiceMode (priority 1) with TargetName .") assert.Contains(t, r.Value, "alpn=", "alpn distinguishes protocols within the RRset") assert.Contains(t, r.Value, "port=443") @@ -109,6 +114,7 @@ func TestComputeRequiredDNSRecords_StyleMatrix(t *testing.T) { capabilitiesHash string wantHTTPS bool wantSVCB bool + wantSVCBRequired bool // applies only when wantSVCB is true wantLegacyTXT bool wantSVCBPort string // substring expected in SVCB value (e.g. "port=443") wantSVCBWk string // "" means SVCB MUST NOT contain "wk=" @@ -123,13 +129,14 @@ func TestComputeRequiredDNSRecords_StyleMatrix(t *testing.T) { wantLegacyTXT: true, }, { - name: "ans_svcb_only_omits_https_rr", - styles: []DNSRecordStyle{DNSRecordStyleSVCB}, - protocol: ProtocolA2A, - agentURL: "https://agent.example.com", - wantSVCB: true, - wantSVCBPort: "port=443", - wantSVCBWk: "wk=agent-card.json", + name: "ans_svcb_only_omits_https_rr", + styles: []DNSRecordStyle{DNSRecordStyleSVCB}, + protocol: ProtocolA2A, + agentURL: "https://agent.example.com", + wantSVCB: true, + wantSVCBRequired: true, // SVCB-sole: only PurposeDiscovery record, must be required + wantSVCBPort: "port=443", + wantSVCBWk: "wk=agent-card.json", }, { name: "union_emits_both_families", @@ -139,25 +146,30 @@ func TestComputeRequiredDNSRecords_StyleMatrix(t *testing.T) { wantHTTPS: true, wantLegacyTXT: true, wantSVCB: true, - wantSVCBPort: "port=443", - wantSVCBWk: "wk=agent-card.json", + // wantSVCBRequired: false — legacy `_ans` TXT carries the + // Required signal during the §4.4.2 transition; SVCB rides + // along as optional. + wantSVCBPort: "port=443", + wantSVCBWk: "wk=agent-card.json", }, { - name: "svcb_mcp_wk_mcp_json", - styles: []DNSRecordStyle{DNSRecordStyleSVCB}, - protocol: ProtocolMCP, - agentURL: "https://agent.example.com/mcp", - wantSVCB: true, - wantSVCBPort: "port=443", - wantSVCBWk: "wk=mcp.json", + name: "svcb_mcp_wk_mcp_json", + styles: []DNSRecordStyle{DNSRecordStyleSVCB}, + protocol: ProtocolMCP, + agentURL: "https://agent.example.com/mcp", + wantSVCB: true, + wantSVCBRequired: true, + wantSVCBPort: "port=443", + wantSVCBWk: "wk=mcp.json", }, { - name: "svcb_http_api_omits_wk", - styles: []DNSRecordStyle{DNSRecordStyleSVCB}, - protocol: ProtocolHTTPAPI, - agentURL: "https://agent.example.com", - wantSVCB: true, - wantSVCBPort: "port=443", + name: "svcb_http_api_omits_wk", + styles: []DNSRecordStyle{DNSRecordStyleSVCB}, + protocol: ProtocolHTTPAPI, + agentURL: "https://agent.example.com", + wantSVCB: true, + wantSVCBRequired: true, + wantSVCBPort: "port=443", // HTTP-API has no per-protocol metadata file convention. }, { @@ -167,45 +179,50 @@ func TestComputeRequiredDNSRecords_StyleMatrix(t *testing.T) { agentURL: "https://agent.example.com", capabilitiesHash: cardHex, wantSVCB: true, + wantSVCBRequired: true, wantSVCBPort: "port=443", wantSVCBWk: "wk=agent-card.json", wantSVCBCard: "card-sha256=" + wantCardBase64, }, { - name: "svcb_non_443_port_from_url", - styles: []DNSRecordStyle{DNSRecordStyleSVCB}, - protocol: ProtocolA2A, - agentURL: "https://agent.example.com:8443", - wantSVCB: true, - wantSVCBPort: "port=8443", - wantSVCBWk: "wk=agent-card.json", + name: "svcb_non_443_port_from_url", + styles: []DNSRecordStyle{DNSRecordStyleSVCB}, + protocol: ProtocolA2A, + agentURL: "https://agent.example.com:8443", + wantSVCB: true, + wantSVCBRequired: true, + wantSVCBPort: "port=8443", + wantSVCBWk: "wk=agent-card.json", }, { - name: "svcb_http_scheme_defaults_port_80", - styles: []DNSRecordStyle{DNSRecordStyleSVCB}, - protocol: ProtocolA2A, - agentURL: "http://agent.example.com", - wantSVCB: true, - wantSVCBPort: "port=80", - wantSVCBWk: "wk=agent-card.json", + name: "svcb_http_scheme_defaults_port_80", + styles: []DNSRecordStyle{DNSRecordStyleSVCB}, + protocol: ProtocolA2A, + agentURL: "http://agent.example.com", + wantSVCB: true, + wantSVCBRequired: true, + wantSVCBPort: "port=80", + wantSVCBWk: "wk=agent-card.json", }, { - name: "empty_styles_coerces_to_default", - styles: nil, - protocol: ProtocolA2A, - agentURL: "https://agent.example.com", - wantSVCB: true, - wantSVCBPort: "port=443", - wantSVCBWk: "wk=agent-card.json", + name: "empty_styles_coerces_to_default", + styles: nil, + protocol: ProtocolA2A, + agentURL: "https://agent.example.com", + wantSVCB: true, + wantSVCBRequired: true, // default ({ANS_SVCB}) is SVCB-sole + wantSVCBPort: "port=443", + wantSVCBWk: "wk=agent-card.json", }, { - name: "all_invalid_styles_falls_back_to_default", - styles: []DNSRecordStyle{DNSRecordStyle("garbage"), DNSRecordStyle("nonsense")}, - protocol: ProtocolA2A, - agentURL: "https://agent.example.com", - wantSVCB: true, - wantSVCBPort: "port=443", - wantSVCBWk: "wk=agent-card.json", + name: "all_invalid_styles_falls_back_to_default", + styles: []DNSRecordStyle{DNSRecordStyle("garbage"), DNSRecordStyle("nonsense")}, + protocol: ProtocolA2A, + agentURL: "https://agent.example.com", + wantSVCB: true, + wantSVCBRequired: true, // fallback default ({ANS_SVCB}) is SVCB-sole + wantSVCBPort: "port=443", + wantSVCBWk: "wk=agent-card.json", }, } @@ -224,6 +241,7 @@ func TestComputeRequiredDNSRecords_StyleMatrix(t *testing.T) { var sawHTTPS, sawSVCB, sawLegacyTXT bool var svcbValue string + var svcbRequired bool for _, r := range records { switch r.Type { case DNSRecordHTTPS: @@ -231,6 +249,7 @@ func TestComputeRequiredDNSRecords_StyleMatrix(t *testing.T) { case DNSRecordSVCB: sawSVCB = true svcbValue = r.Value + svcbRequired = r.Required case DNSRecordTXT: if strings.HasPrefix(r.Name, "_ans.") { sawLegacyTXT = true @@ -243,6 +262,8 @@ func TestComputeRequiredDNSRecords_StyleMatrix(t *testing.T) { assert.Equal(t, tc.wantLegacyTXT, sawLegacyTXT, "_ans TXT presence") if tc.wantSVCB { + assert.Equal(t, tc.wantSVCBRequired, svcbRequired, + "SVCB Required flag mismatch (true iff ANS_SVCB is the sole style)") assert.Contains(t, svcbValue, tc.wantSVCBPort, "SVCB port SvcParam mismatch") if tc.wantSVCBWk != "" { From e543cec3ebc840fa15511de326a0bd4d976a2688 Mon Sep 17 00:00:00 2001 From: kperry Date: Fri, 22 May 2026 15:04:27 -0500 Subject: [PATCH 09/13] =?UTF-8?q?fix(dns):=20subset-match=20SVCB=20SvcPara?= =?UTF-8?q?ms=20per=20RFC=209460=20=C2=A78?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verifySVCB previously did full normalized-string equality (`normalizeHTTPS(got) == wantNorm`) while its docstring claimed RFC 9460 §8 unknown-key ignore semantics. The two diverged: a live record carrying extra SvcParams from a coexisting agentic spec (DNS-AID, etc.) would not match, and under DNSSEC AD=true the mismatch trips the SVCB_DNSSEC_MISMATCH hard fail in the lifecycle layer — defeating the entire point of the Consolidated Approach (multi-spec coexistence in a single SVCB row). Switch to subset matching: - parseSVCBValue parses " [k=v]..." into a structured (priority, target, params-map) form. Used by both the expected and actual sides. - matchesSVCBSubset returns true iff priority and target are equal and every expected SvcParam is present in the live record with an equal value. Additional SvcParams in the live record are ignored. - verifySVCB calls parseSVCBValue on rec.Value once, then parseSVCBValue+matchesSVCBSubset on each candidate SVCB rrset. Tests added to TestLookupVerifier_SVCB: - extra-svcparams-tolerated-rfc9460-section-8: live record carries `mandatory=alpn` not in the expected; still matches. - missing-expected-param-fails-subset-match: live record omits expected `port=443`; does not match. verifyHTTPS keeps strict-equality matching (its docstring is honest about that): HTTPS RR is a companion to legacy `_ans` TXT, not the multi-spec coexistence target. SVCB is where the §8 tolerance matters. Addresses Copilot review feedback C5 on PR #25. Signed-off-by: kperry --- internal/adapter/dns/dns_test.go | 28 ++++++++ internal/adapter/dns/lookup.go | 108 ++++++++++++++++++++++++++----- 2 files changed, 120 insertions(+), 16 deletions(-) diff --git a/internal/adapter/dns/dns_test.go b/internal/adapter/dns/dns_test.go index 84f9741..24f289e 100644 --- a/internal/adapter/dns/dns_test.go +++ b/internal/adapter/dns/dns_test.go @@ -300,6 +300,34 @@ func TestLookupVerifier_SVCB(t *testing.T) { found: false, why: "ServiceMode expectation should not match an AliasMode record", }, + { + // RFC 9460 §8 unknown-key ignore: a live record with extra + // SvcParams (e.g. another agentic spec adding its own keys to + // the same SVCB row) must still match when our committed + // SvcParams are present with equal values. A strict-equality + // matcher would fail this and — under DNSSEC AD=true — trip + // the SVCB_DNSSEC_MISMATCH hard fail. + name: "extra-svcparams-tolerated-rfc9460-section-8", + zoneName: "agent.example.com.", + zoneRR: `agent.example.com. 3600 IN SVCB 1 . alpn=a2a port=443 mandatory=alpn`, + queryName: "agent.example.com", + want: `1 . alpn=a2a port=443`, + found: true, + why: "subset match: live record carries extra `mandatory` param, expected params still satisfied", + }, + { + // Mirror of the tolerance case to pin the missing-required- + // param failure: if the live record drops one of our + // committed SvcParams, the match must fail even though it + // shares priority+target with the expected value. + name: "missing-expected-param-fails-subset-match", + zoneName: "agent.example.com.", + zoneRR: `agent.example.com. 3600 IN SVCB 1 . alpn=a2a`, + queryName: "agent.example.com", + want: `1 . alpn=a2a port=443`, + found: false, + why: "subset match requires every expected SvcParam present in the live record", + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/adapter/dns/lookup.go b/internal/adapter/dns/lookup.go index 9426ed1..d4f537d 100644 --- a/internal/adapter/dns/lookup.go +++ b/internal/adapter/dns/lookup.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net" + "strconv" "strings" "time" @@ -265,18 +266,18 @@ func formatHTTPSValue(s *dns.SVCB) string { // verifySVCB checks for a Consolidated Approach SVCB record (RFC 9460) // at the agent's bare FQDN. Multiple SVCB records can share one RRset -// name distinguished by alpn, so verification iterates the answer -// section, normalizes each record's wire form, and matches against -// the expected SvcParams. The matching strategy mirrors verifyHTTPS: -// the expected value carries every SvcParam the RA computed (alpn, -// port, wk, card-sha256), and the live record MUST carry the same -// SvcParams in the same alpn-keyed form. +// name distinguished by alpn, and the Consolidated Approach explicitly +// designs for multi-spec coexistence in a single record (DNS-AID, ANS, +// and other agentic specs sharing one SVCB row, distinguished by their +// own SvcParamKeys). Verification therefore implements RFC 9460 §8 +// unknown-key ignore semantics as a *subset* match: priority and +// target must equal the expected value exactly, every expected +// SvcParam must be present in the live record with an equal value, +// and additional SvcParams in the live record are tolerated. // -// SvcParam unknown-key ignore semantics (RFC 9460 §8) apply at the -// client, not at this verifier — we only check that the SvcParams -// the RA committed are present, not that the live record is free of -// extra SvcParams from other ecosystems. Other agentic specs adding -// their own SvcParams alongside ours is the entire point of the +// A strict-equality matcher would mark a multi-spec record not-found +// and (in a DNSSEC-signed zone) trip the SVCB_DNSSEC_MISMATCH hard +// fail in the lifecycle layer — defeating the entire point of the // Consolidated Approach. func (v *LookupVerifier) verifySVCB(ctx context.Context, server string, rec domain.ExpectedDNSRecord) port.RecordVerification { r := port.RecordVerification{Record: rec} @@ -290,25 +291,100 @@ func (v *LookupVerifier) verifySVCB(ctx context.Context, server string, rec doma return r } r.DNSSECVerified = resp.AuthenticatedData - wantNorm := normalizeHTTPS(rec.Value) + + expected, err := parseSVCBValue(rec.Value) + if err != nil { + r.Error = fmt.Sprintf("expected SVCB value: %v", err) + return r + } for _, rr := range resp.Answer { svcb, ok := rr.(*dns.SVCB) if !ok { continue } - got := formatHTTPSValue(svcb) + gotStr := formatHTTPSValue(svcb) if r.Actual == "" { - r.Actual = got + r.Actual = gotStr } - if normalizeHTTPS(got) == wantNorm { + actual, err := parseSVCBValue(gotStr) + if err != nil { + // Skip records we can't parse — they'll surface as + // not-found if no other answer matches. + continue + } + if matchesSVCBSubset(expected, actual) { r.Found = true - r.Actual = got + r.Actual = gotStr return r } } return r } +// parsedSVCB is the structured form of an SVCB or HTTPS record's +// presentation value: priority, target, and a SvcParam map. Used for +// RFC 9460 §8-compliant subset matching in verifySVCB so that live +// records carrying extra SvcParams from coexisting specs aren't +// treated as mismatches. +type parsedSVCB struct { + priority int + target string + params map[string]string +} + +// parseSVCBValue parses the presentation form +// " [k=v] [k=v] ..." that formatHTTPSValue emits +// (and that ComputeRequiredDNSRecords stores in +// ExpectedDNSRecord.Value). Whitespace inside SvcParam values is not +// supported because neither side emits it. +func parseSVCBValue(s string) (parsedSVCB, error) { + fields := strings.Fields(s) + if len(fields) < 2 { + return parsedSVCB{}, fmt.Errorf("svcb: too few fields in %q", s) + } + priority, err := strconv.Atoi(fields[0]) + if err != nil { + return parsedSVCB{}, fmt.Errorf("svcb: priority %q: %w", fields[0], err) + } + out := parsedSVCB{ + priority: priority, + target: fields[1], + params: make(map[string]string, len(fields)-2), + } + for _, f := range fields[2:] { + eq := strings.IndexByte(f, '=') + if eq < 0 { + // Valueless SvcParamKey (e.g. `no-default-alpn`); store + // with empty value so an expected entry can still match. + out.params[f] = "" + continue + } + out.params[f[:eq]] = f[eq+1:] + } + return out, nil +} + +// matchesSVCBSubset reports whether `actual` carries all SvcParams +// in `expected` (with equal values), tolerating any additional +// SvcParams in `actual`. Priority and target must match exactly. +// +// This is the verifier-side embodiment of RFC 9460 §8 unknown-key +// ignore semantics: the RA only verifies the SvcParams it committed +// to write; SvcParams from other agentic specs sharing the same +// SVCB row pass through unexamined. +func matchesSVCBSubset(expected, actual parsedSVCB) bool { + if expected.priority != actual.priority || expected.target != actual.target { + return false + } + for k, want := range expected.params { + got, ok := actual.params[k] + if !ok || got != want { + return false + } + } + return true +} + // normalizeTLSA collapses whitespace and lowercases the hex so // "3 1 1 abcd..." matches "3 1 1 ABCD...". func normalizeTLSA(s string) string { From e00928aa994ad5df65c3c6db6415609f5834fd12 Mon Sep 17 00:00:00 2001 From: kperry Date: Tue, 26 May 2026 08:51:26 -0500 Subject: [PATCH 10/13] feat(discovery): integrate default discovery registry and update DNS record computation Signed-off-by: kperry --- cmd/ans-ra/main.go | 50 +- internal/adapter/discovery/ans/ansbadge.go | 37 ++ .../adapter/discovery/ans/ansbadge_test.go | 69 +++ internal/adapter/discovery/ans/protocol.go | 55 ++ .../adapter/discovery/ans/protocol_test.go | 46 ++ internal/adapter/discovery/ans/svcb.go | 132 +++++ internal/adapter/discovery/ans/svcb_test.go | 252 +++++++++ internal/adapter/discovery/ans/tlsa.go | 38 ++ internal/adapter/discovery/ans/tlsa_test.go | 60 +++ internal/adapter/discovery/ans/txt.go | 66 +++ internal/adapter/discovery/ans/txt_test.go | 149 ++++++ .../adapter/discovery/registry/registry.go | 73 +++ .../discovery/registry/registry_test.go | 158 ++++++ internal/adapter/dns/lookup.go | 6 +- internal/domain/dnsrecords.go | 322 +----------- internal/domain/dnsrecords_test.go | 401 +-------------- internal/port/discovery.go | 51 ++ internal/ra/handler/dto.go | 10 +- internal/ra/handler/lifecycle.go | 2 +- internal/ra/handler/lifecycle_test.go | 6 +- internal/ra/handler/v1registration.go | 12 +- internal/ra/service/discovery_default.go | 31 ++ internal/ra/service/dnsrecords.go | 160 ++++++ internal/ra/service/dnsrecords_test.go | 480 ++++++++++++++++++ internal/ra/service/lifecycle.go | 8 +- internal/ra/service/registration.go | 64 ++- internal/ra/service/registration_test.go | 12 +- internal/ra/service/v1event.go | 2 +- 28 files changed, 2006 insertions(+), 746 deletions(-) create mode 100644 internal/adapter/discovery/ans/ansbadge.go create mode 100644 internal/adapter/discovery/ans/ansbadge_test.go create mode 100644 internal/adapter/discovery/ans/protocol.go create mode 100644 internal/adapter/discovery/ans/protocol_test.go create mode 100644 internal/adapter/discovery/ans/svcb.go create mode 100644 internal/adapter/discovery/ans/svcb_test.go create mode 100644 internal/adapter/discovery/ans/tlsa.go create mode 100644 internal/adapter/discovery/ans/tlsa_test.go create mode 100644 internal/adapter/discovery/ans/txt.go create mode 100644 internal/adapter/discovery/ans/txt_test.go create mode 100644 internal/adapter/discovery/registry/registry.go create mode 100644 internal/adapter/discovery/registry/registry_test.go create mode 100644 internal/port/discovery.go create mode 100644 internal/ra/service/discovery_default.go create mode 100644 internal/ra/service/dnsrecords.go create mode 100644 internal/ra/service/dnsrecords_test.go diff --git a/cmd/ans-ra/main.go b/cmd/ans-ra/main.go index 510cb02..386c39c 100644 --- a/cmd/ans-ra/main.go +++ b/cmd/ans-ra/main.go @@ -34,6 +34,7 @@ import ( "github.com/godaddy/ans/internal/adapter/store/sqlite" "github.com/godaddy/ans/internal/adapter/tlclient" "github.com/godaddy/ans/internal/config" + "github.com/godaddy/ans/internal/domain" "github.com/godaddy/ans/internal/port" "github.com/godaddy/ans/internal/ra/handler" ramiddleware "github.com/godaddy/ans/internal/ra/middleware" @@ -158,9 +159,22 @@ func run(cfgPath string) error { // Event bus. bus := eventbus.NewInMemoryBus(logger) + // Discovery registry: composes the bundled ANS-family port.DiscoveryStyle + // adapters (ANS_TXT, ANS_SVCB) the V2 register / verify-dns paths walk + // to compute `dnsRecordsProvisioned[]`. Insertion order here pins the + // canonical-bytes emission order for the §4.4.2 union case + // (`[TXT×N, HTTPS, SVCB×N, badge, TLSA]`). + discoveryReg, err := service.NewDefaultDiscoveryRegistry() + if err != nil { + return fmt.Errorf("init discovery registry: %w", err) + } + if err := assertRegistryDomainCoherence(discoveryReg); err != nil { + return fmt.Errorf("init discovery registry: %w", err) + } + // Services. regSvc := service.NewRegistrationService( - agents, endpoints, certsStore, byoc, renewals, validator, identityCA, bus, outbox, db, + agents, endpoints, certsStore, byoc, renewals, validator, identityCA, bus, outbox, db, discoveryReg, ).WithSigner(service.EventSigner{ KeyManager: km, KeyID: signerKeyID, @@ -394,6 +408,40 @@ type providerWithAnonymous interface { Middleware() func(http.Handler) http.Handler } +// assertRegistryDomainCoherence verifies the discovery registry's +// wired styles are exactly the set domain advertises as valid via +// domain.ValidDNSRecordStyles(). Drift in either direction is a +// startup misconfig: registry-only style means request-side validation +// rejects it (operator error noise); domain-only style means +// applyDNSRecordStyles accepts a value verify-dns can never satisfy +// (silent broken-by-omission). Both fail server start. +func assertRegistryDomainCoherence(reg port.DiscoveryRegistry) error { + registryIDs := make(map[string]bool) + for _, id := range reg.IDs() { + registryIDs[string(id)] = true + } + domainIDs := make(map[string]bool) + for _, s := range domain.ValidDNSRecordStyles() { + domainIDs[s] = true + } + var registryOnly, domainOnly []string + for id := range registryIDs { + if !domainIDs[id] { + registryOnly = append(registryOnly, id) + } + } + for id := range domainIDs { + if !registryIDs[id] { + domainOnly = append(domainOnly, id) + } + } + if len(registryOnly) > 0 || len(domainOnly) > 0 { + return fmt.Errorf("drift between registry.IDs() and domain.ValidDNSRecordStyles(): registry-only=%v, domain-only=%v", + registryOnly, domainOnly) + } + return nil +} + // selectDNSVerifier returns the configured DNS adapter. Returns a // port.DNSVerifier so the service layer can wire it directly. // diff --git a/internal/adapter/discovery/ans/ansbadge.go b/internal/adapter/discovery/ans/ansbadge.go new file mode 100644 index 0000000..b37d7ff --- /dev/null +++ b/internal/adapter/discovery/ans/ansbadge.go @@ -0,0 +1,37 @@ +package ans + +import ( + "fmt" + + "github.com/godaddy/ans/internal/domain" +) + +// BadgeRecord returns the `_ans-badge.` TXT record every ANS-family +// style emits as the trust attestation hook. Returns a one-element slice +// when reg has at least one endpoint (the badge value points at the +// first endpoint's URL); returns an empty slice otherwise so callers +// can `append(records, BadgeRecord(reg)...)` unconditionally. +// +// Both ANS_SVCB and ANS_TXT styles call BadgeRecord. When both are in +// the resolved set the service walker dedupes on (Name, Type, Value), +// so the badge lands once per registration regardless of style count. +// +// Required=true: badge-verifying clients won't trust an agent that +// publishes its discovery records without a paired badge — every +// non-badge ANS record on the wire is meaningless without it. +func BadgeRecord(reg *domain.AgentRegistration) []domain.ExpectedDNSRecord { + if len(reg.Endpoints) == 0 { + return nil + } + version := reg.AnsName.Version().String() + value := fmt.Sprintf("v=ans-badge1; version=%s; url=%s", + version, reg.Endpoints[0].AgentURL) + return []domain.ExpectedDNSRecord{{ + Name: fmt.Sprintf("_ans-badge.%s", reg.FQDN()), + Type: domain.DNSRecordTXT, + Value: value, + Purpose: domain.PurposeBadge, + Required: true, + TTL: 3600, + }} +} diff --git a/internal/adapter/discovery/ans/ansbadge_test.go b/internal/adapter/discovery/ans/ansbadge_test.go new file mode 100644 index 0000000..bc5c801 --- /dev/null +++ b/internal/adapter/discovery/ans/ansbadge_test.go @@ -0,0 +1,69 @@ +package ans + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/godaddy/ans/internal/domain" +) + +func TestBadgeRecord(t *testing.T) { + mustReg := func(t *testing.T, version string, host string, eps []domain.AgentEndpoint) *domain.AgentRegistration { + t.Helper() + v, err := domain.ParseSemVer(version) + require.NoError(t, err) + ansName, err := domain.NewAnsName(v, host) + require.NoError(t, err) + return &domain.AgentRegistration{AnsName: ansName, Endpoints: eps} + } + + tests := []struct { + name string + reg *domain.AgentRegistration + wantEmpty bool + wantName string + wantValue string + }{ + { + name: "no_endpoints_emits_no_badge", + reg: mustReg(t, "1.0.0", "agent.example.com", nil), + wantEmpty: true, + }, + { + name: "single_endpoint_emits_one_badge", + reg: mustReg(t, "1.2.3", "agent.example.com", []domain.AgentEndpoint{ + {Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com/a2a"}, + }), + wantName: "_ans-badge.agent.example.com", + wantValue: "v=ans-badge1; version=1.2.3; url=https://agent.example.com/a2a", + }, + { + name: "multiple_endpoints_badge_uses_first_url", + reg: mustReg(t, "2.0.0", "agent.example.com", []domain.AgentEndpoint{ + {Protocol: domain.ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, + {Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com/a2a"}, + }), + wantName: "_ans-badge.agent.example.com", + wantValue: "v=ans-badge1; version=2.0.0; url=https://agent.example.com/mcp", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := BadgeRecord(tc.reg) + if tc.wantEmpty { + assert.Empty(t, got) + return + } + require.Len(t, got, 1) + r := got[0] + assert.Equal(t, tc.wantName, r.Name) + assert.Equal(t, domain.DNSRecordTXT, r.Type) + assert.Equal(t, tc.wantValue, r.Value) + assert.Equal(t, domain.PurposeBadge, r.Purpose) + assert.True(t, r.Required, "_ans-badge is always Required=true alongside discovery records") + assert.Equal(t, 3600, r.TTL) + }) + } +} diff --git a/internal/adapter/discovery/ans/protocol.go b/internal/adapter/discovery/ans/protocol.go new file mode 100644 index 0000000..5788319 --- /dev/null +++ b/internal/adapter/discovery/ans/protocol.go @@ -0,0 +1,55 @@ +// Package ans implements the bundled ANS-family port.DiscoveryStyle +// adapters: SVCBStyle (the Consolidated Approach SVCB shape per RFC 9460 +// plus the `_ans-badge` TXT extension) and TXTStyle (the original `_ans` +// TXT shape, supported indefinitely for operators with existing zone-edit +// tooling). Both styles share two family-level trust records — the +// `_ans-badge` TXT and the server-cert TLSA — emitted by every ANS-family +// style and deduped at the service walker. +// +// Helpers private to this package handle the per-protocol bits both +// styles need: protocol.go for the human-friendly protocol token and +// well-known suffix mappings, svcb.go for the SVCB-specific port and +// card-sha256 helpers (kept next to their only consumer). +package ans + +import ( + "github.com/godaddy/ans/internal/domain" +) + +// protocolToANSValue maps a protocol enum to the wire token used inside +// `_ans` TXT payloads (`p=`) and SVCB `alpn=` SvcParams. +// Unknown protocols pass through unchanged so a future protocol added +// to the domain layer surfaces in records without a parallel edit here. +func protocolToANSValue(p domain.Protocol) string { + switch p { + case domain.ProtocolA2A: + return "a2a" + case domain.ProtocolMCP: + return "mcp" + case domain.ProtocolHTTPAPI: + return "http-api" + default: + return string(p) + } +} + +// wkPathFor returns the suffix-only well-known path published in the +// Consolidated Approach SVCB record's `wk=` SvcParam. Suffix-only +// matches the consolidated-draft examples (§4 line 134); clients +// prepend `/.well-known/` to construct the full path. Empty result +// means the caller SHOULD omit `wk=` entirely (e.g. direct-mode agents +// that expose no canonical metadata file). +// +// A2A: `agent-card.json` (IANA-registered well-known per A2A spec). +// MCP: `mcp.json` (de-facto convention; see SEP-1649 progress). +// HTTP-API: empty (no per-protocol metadata file convention). +func wkPathFor(p domain.Protocol) string { + switch p { + case domain.ProtocolA2A: + return "agent-card.json" + case domain.ProtocolMCP: + return "mcp.json" + default: + return "" + } +} diff --git a/internal/adapter/discovery/ans/protocol_test.go b/internal/adapter/discovery/ans/protocol_test.go new file mode 100644 index 0000000..b27d374 --- /dev/null +++ b/internal/adapter/discovery/ans/protocol_test.go @@ -0,0 +1,46 @@ +package ans + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/godaddy/ans/internal/domain" +) + +func TestProtocolToANSValue(t *testing.T) { + tests := []struct { + name string + in domain.Protocol + want string + }{ + {name: "a2a", in: domain.ProtocolA2A, want: "a2a"}, + {name: "mcp", in: domain.ProtocolMCP, want: "mcp"}, + {name: "http_api", in: domain.ProtocolHTTPAPI, want: "http-api"}, + {name: "unknown_protocol_passes_through_unchanged", in: domain.Protocol("UNKNOWN"), want: "UNKNOWN"}, + {name: "empty_protocol_passes_through_as_empty", in: domain.Protocol(""), want: ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, protocolToANSValue(tc.in)) + }) + } +} + +func TestWkPathFor(t *testing.T) { + tests := []struct { + name string + in domain.Protocol + want string + }{ + {name: "a2a_returns_agent_card_json", in: domain.ProtocolA2A, want: "agent-card.json"}, + {name: "mcp_returns_mcp_json", in: domain.ProtocolMCP, want: "mcp.json"}, + {name: "http_api_returns_empty", in: domain.ProtocolHTTPAPI, want: ""}, + {name: "unknown_protocol_returns_empty", in: domain.Protocol("UNKNOWN"), want: ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, wkPathFor(tc.in)) + }) + } +} diff --git a/internal/adapter/discovery/ans/svcb.go b/internal/adapter/discovery/ans/svcb.go new file mode 100644 index 0000000..dc64e72 --- /dev/null +++ b/internal/adapter/discovery/ans/svcb.go @@ -0,0 +1,132 @@ +package ans + +import ( + "encoding/base64" + "encoding/hex" + "fmt" + "net/url" + "strconv" + + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/port" +) + +// SVCBStyle implements port.DiscoveryStyle for the Consolidated +// Approach SVCB shape (ANS_SVCB). It emits one SVCB row per protocol +// endpoint at the agent's bare FQDN, plus the ANS-family trust +// records (`_ans-badge` TXT and TLSA) so the style is self-contained +// when registered alone. +// +// Records always returns SVCB rows with Required=true. The service +// walker post-processes the slice to flip Required=false on these +// rows when ANS_TXT is also in the resolved set (during the §4.4.2 +// transition the legacy `_ans` TXT family carries the operator's +// required signal and SVCB rides along as optional). Keeping the +// post-process at the service layer keeps SVCBStyle style-local — +// it does not need to know which other styles are in play. +// +// SvcParam composition per RFC 9460: +// - alpn: protocol token (a2a / mcp / http-api), distinguishes +// protocols within the same RRset. +// - port: from endpoint URL authority; defaults to 443 (https) / +// 80 (http) when the URL omits a port. +// - wk: well-known suffix per protocol (agent-card.json for A2A, +// mcp.json for MCP, omitted for HTTP-API). +// - card-sha256: base64url(reg.CapabilitiesHash) when set; absent +// otherwise, in which case verifiers fall back to TOFU on first +// Trust Card fetch. +// +// `wk` and `card-sha256` are not yet IANA-registered SvcParamKeys; +// see the consolidated-draft §6 note for the keyNNNNN-form fallback +// strict-RFC parsers may need. +type SVCBStyle struct{} + +// ID returns ANS_SVCB. +func (SVCBStyle) ID() domain.DNSRecordStyle { return domain.DNSRecordStyleSVCB } + +// Records returns the SVCB rows + family trust records the SVCB style +// needs an operator to publish. +func (s SVCBStyle) Records(reg *domain.AgentRegistration) []domain.ExpectedDNSRecord { + fqdn := reg.FQDN() + cardSHA := capabilitiesHashBase64URL(reg.CapabilitiesHash) + records := make([]domain.ExpectedDNSRecord, 0, len(reg.Endpoints)+2) + for _, ep := range reg.Endpoints { + alpn := protocolToANSValue(ep.Protocol) + wk := wkPathFor(ep.Protocol) + port := svcbPortFor(ep.AgentURL) + // RFC 9460 §2.1 presentation form: unquoted SvcParamValue when the + // value has no characters special to the presentation format. + // alpn tokens, port digits, well-known path suffixes, and base64url + // digests all qualify. + value := fmt.Sprintf(`1 . alpn=%s port=%d`, alpn, port) + if wk != "" { + value += fmt.Sprintf(` wk=%s`, wk) + } + if cardSHA != "" { + value += fmt.Sprintf(` card-sha256=%s`, cardSHA) + } + records = append(records, domain.ExpectedDNSRecord{ + Name: fqdn, + Type: domain.DNSRecordSVCB, + Value: value, + Purpose: domain.PurposeDiscovery, + Required: true, + TTL: 3600, + }) + } + records = append(records, BadgeRecord(reg)...) + records = append(records, TLSARecord(reg)...) + return records +} + +// Compile-time interface satisfaction check. Catches accidental +// signature drift on port.DiscoveryStyle without needing a runtime +// assertion in cmd/main. +var _ port.DiscoveryStyle = SVCBStyle{} + +// svcbPortFor returns the TCP port to advertise in the SVCB SvcParam +// `port=`. Reads it from the endpoint URL's authority. Falls back to +// 443 (https) / 80 (http) when the URL omits a port. Empty input or +// unparseable URL returns 443 — the §4.4.2 default for agent endpoints. +// +// Without this, every endpoint would emit a hardcoded port=443 and +// silently break verify-dns for agents on non-443 endpoints (operator +// publishes their actual port; expected says 443; mismatch). +func svcbPortFor(agentURL string) int { + if agentURL == "" { + return 443 + } + u, err := url.Parse(agentURL) + if err != nil { + return 443 + } + if p := u.Port(); p != "" { + if n, perr := strconv.Atoi(p); perr == nil { + return n + } + } + if u.Scheme == "http" { + return 80 + } + return 443 +} + +// capabilitiesHashBase64URL re-encodes a hex-lowercase SHA-256 digest +// (the form `AgentRegistration.CapabilitiesHash` carries) into the +// base64url form (RFC 4648 §5, no padding) the SVCB `card-sha256` +// SvcParam expects. Empty input returns empty output, which the caller +// treats as "omit the SvcParam entirely" — agents registered without +// `agentCardContent` have no committed value to publish. +func capabilitiesHashBase64URL(hexDigest string) string { + if hexDigest == "" { + return "" + } + raw, err := hex.DecodeString(hexDigest) + if err != nil || len(raw) == 0 { + // Malformed hex is logically equivalent to absence; the RA + // stores well-formed hex by construction (helpers.go: + // hashAgentCardContent), but defensive on the boundary. + return "" + } + return base64.RawURLEncoding.EncodeToString(raw) +} diff --git a/internal/adapter/discovery/ans/svcb_test.go b/internal/adapter/discovery/ans/svcb_test.go new file mode 100644 index 0000000..aebfc9a --- /dev/null +++ b/internal/adapter/discovery/ans/svcb_test.go @@ -0,0 +1,252 @@ +package ans + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/godaddy/ans/internal/domain" +) + +func mustReg(t *testing.T, host string, eps []domain.AgentEndpoint, capHash string, cert *domain.ByocServerCertificate) *domain.AgentRegistration { + t.Helper() + v, err := domain.NewSemVer(1, 0, 0) + require.NoError(t, err) + ansName, err := domain.NewAnsName(v, host) + require.NoError(t, err) + return &domain.AgentRegistration{ + AnsName: ansName, + Endpoints: eps, + CapabilitiesHash: capHash, + ServerCert: cert, + } +} + +func TestSVCBStyle_ID(t *testing.T) { + assert.Equal(t, domain.DNSRecordStyleSVCB, SVCBStyle{}.ID()) +} + +// TestSVCBStyle_Records walks the SvcParam composition rules (alpn / +// port / wk / card-sha256) the consolidated-draft fixes, plus the +// always-Required default the service walker post-processes. +func TestSVCBStyle_Records(t *testing.T) { + const cardHex = "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27" + const wantCardBase64 = "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc" + + tests := []struct { + name string + eps []domain.AgentEndpoint + capHash string + wantCount int // svcb rows expected + wantPort string + wantAlpn string + wantWk string // empty means MUST NOT appear + wantCard string // empty means MUST NOT appear + wantNotPort string // value MUST NOT contain this string (e.g. wrong default) + }{ + { + name: "a2a_https_default_port", + eps: []domain.AgentEndpoint{ + {Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}, + }, + wantCount: 1, + wantPort: "port=443", + wantAlpn: "alpn=a2a", + wantWk: "wk=agent-card.json", + }, + { + name: "mcp_emits_mcp_json_well_known", + eps: []domain.AgentEndpoint{ + {Protocol: domain.ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, + }, + wantCount: 1, + wantPort: "port=443", + wantAlpn: "alpn=mcp", + wantWk: "wk=mcp.json", + }, + { + name: "http_api_omits_wk", + eps: []domain.AgentEndpoint{ + {Protocol: domain.ProtocolHTTPAPI, AgentURL: "https://agent.example.com"}, + }, + wantCount: 1, + wantPort: "port=443", + wantAlpn: "alpn=http-api", + wantWk: "", // HTTP-API has no per-protocol metadata file + }, + { + name: "card_sha256_present_when_capabilities_hash_set", + eps: []domain.AgentEndpoint{ + {Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}, + }, + capHash: cardHex, + wantCount: 1, + wantPort: "port=443", + wantAlpn: "alpn=a2a", + wantWk: "wk=agent-card.json", + wantCard: "card-sha256=" + wantCardBase64, + }, + { + name: "non_443_port_from_url_authority", + eps: []domain.AgentEndpoint{ + {Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com:8443"}, + }, + wantCount: 1, + wantPort: "port=8443", + wantAlpn: "alpn=a2a", + wantWk: "wk=agent-card.json", + wantNotPort: "port=443", + }, + { + name: "http_scheme_defaults_port_80", + eps: []domain.AgentEndpoint{ + {Protocol: domain.ProtocolA2A, AgentURL: "http://agent.example.com"}, + }, + wantCount: 1, + wantPort: "port=80", + wantAlpn: "alpn=a2a", + wantWk: "wk=agent-card.json", + }, + { + // First row asserted below; assertions on the A2A protocol's + // SvcParam composition (port, alpn, wk). The MCP row's wk=mcp.json + // is covered by the dedicated mcp test case above; here we only + // pin that the count is right and the row order tracks endpoint + // order. + name: "two_endpoints_emits_two_svcb_rows", + eps: []domain.AgentEndpoint{ + {Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com/a2a"}, + {Protocol: domain.ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, + }, + wantCount: 2, + wantPort: "port=443", + wantAlpn: "alpn=a2a", + wantWk: "wk=agent-card.json", + }, + { + name: "zero_endpoints_emits_no_svcb_rows", + eps: nil, + wantCount: 0, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + reg := mustReg(t, "agent.example.com", tc.eps, tc.capHash, nil) + records := SVCBStyle{}.Records(reg) + + var svcbRows []domain.ExpectedDNSRecord + for _, r := range records { + if r.Type == domain.DNSRecordSVCB { + svcbRows = append(svcbRows, r) + } + } + require.Len(t, svcbRows, tc.wantCount, "SVCB row count") + + if tc.wantCount == 0 { + return + } + + r := svcbRows[0] + assert.Equal(t, "agent.example.com", r.Name, "SVCB lives at the bare FQDN") + assert.Equal(t, domain.PurposeDiscovery, r.Purpose) + assert.Equal(t, 3600, r.TTL) + assert.True(t, r.Required, "SVCB always returns Required=true; service post-processes for the union case") + assert.Contains(t, r.Value, `1 . `, "ServiceMode (priority 1) with TargetName .") + if tc.wantAlpn != "" { + assert.Contains(t, r.Value, tc.wantAlpn) + } + if tc.wantPort != "" { + assert.Contains(t, r.Value, tc.wantPort) + } + if tc.wantNotPort != "" { + assert.NotContains(t, r.Value, tc.wantNotPort) + } + if tc.wantWk != "" { + assert.Contains(t, r.Value, tc.wantWk) + } else { + assert.NotContains(t, r.Value, "wk=", "wk= MUST be absent for protocols with no metadata file convention") + } + if tc.wantCard != "" { + assert.Contains(t, r.Value, tc.wantCard) + } else { + assert.NotContains(t, r.Value, "card-sha256", "card-sha256 MUST be absent when CapabilitiesHash is empty") + } + }) + } +} + +// TestSVCBStyle_RecordsIncludesFamilyTrustRecords pins that SVCBStyle +// is self-contained — it emits the family's badge and TLSA records too, +// so registering ANS_SVCB alone produces a complete set without any +// service-layer trust-record plumbing. +func TestSVCBStyle_RecordsIncludesFamilyTrustRecords(t *testing.T) { + reg := mustReg(t, "agent.example.com", + []domain.AgentEndpoint{{Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}}, + "", &domain.ByocServerCertificate{Fingerprint: "deadbeef"}) + + records := SVCBStyle{}.Records(reg) + + var sawBadge, sawTLSA bool + for _, r := range records { + if r.Purpose == domain.PurposeBadge { + sawBadge = true + assert.True(t, strings.HasPrefix(r.Name, "_ans-badge.")) + } + if r.Purpose == domain.PurposeCertificateBinding { + sawTLSA = true + assert.True(t, strings.HasPrefix(r.Name, "_443._tcp.")) + } + } + assert.True(t, sawBadge, "SVCB style must include the family `_ans-badge` record") + assert.True(t, sawTLSA, "SVCB style must include the TLSA record when ServerCert is set") +} + +func TestSVCBPortFor(t *testing.T) { + tests := []struct { + name string + in string + want int + }{ + {name: "https_default_443", in: "https://agent.example.com", want: 443}, + {name: "http_default_80", in: "http://agent.example.com", want: 80}, + {name: "explicit_port_8443", in: "https://agent.example.com:8443", want: 8443}, + {name: "explicit_port_8080_http", in: "http://agent.example.com:8080", want: 8080}, + {name: "with_path_keeps_port", in: "https://agent.example.com:9443/a2a", want: 9443}, + {name: "empty_url_defaults_443", in: "", want: 443}, + {name: "malformed_url_defaults_443", in: "://not-a-url", want: 443}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, svcbPortFor(tc.in)) + }) + } +} + +func TestCapabilitiesHashBase64URL(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "live_webmesh_trust_card_digest", + in: "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27", + want: "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc", + }, + { + name: "all_zeros", + in: "0000000000000000000000000000000000000000000000000000000000000000", + want: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }, + {name: "empty_input_empty_output", in: "", want: ""}, + {name: "malformed_hex_returns_empty", in: "not hex", want: ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, capabilitiesHashBase64URL(tc.in)) + }) + } +} diff --git a/internal/adapter/discovery/ans/tlsa.go b/internal/adapter/discovery/ans/tlsa.go new file mode 100644 index 0000000..417f5eb --- /dev/null +++ b/internal/adapter/discovery/ans/tlsa.go @@ -0,0 +1,38 @@ +package ans + +import ( + "fmt" + + "github.com/godaddy/ans/internal/domain" +) + +// TLSARecord returns the TLSA cert-binding record every ANS-family +// style emits for the agent's server cert. Returns a one-element slice +// when reg.ServerCert is set; returns an empty slice otherwise. +// +// Both ANS_SVCB and ANS_TXT styles call TLSARecord. When both are in +// the resolved set the service walker dedupes on (Name, Type, Value) +// so the TLSA lands once. +// +// Required=false: TLSA is only meaningful when the operator's zone is +// DNSSEC-signed, which is a runtime property the domain layer cannot +// know. The verify layer enforces a stricter rule at query time: when +// a TLSA response IS DNSSEC-validated, its value MUST match the +// expected fingerprint (otherwise an attacker rewrote the record in +// a signed zone — the worst failure mode). That post-verify check +// lives alongside the verifier (lifecycle.go), not in the record set. +// +// `3 1 1 ` = DANE-EE + SubjectPublicKeyInfo + SHA-256 (RFC 6698). +func TLSARecord(reg *domain.AgentRegistration) []domain.ExpectedDNSRecord { + if reg.ServerCert == nil { + return nil + } + return []domain.ExpectedDNSRecord{{ + Name: fmt.Sprintf("_443._tcp.%s", reg.FQDN()), + Type: domain.DNSRecordTLSA, + Value: fmt.Sprintf("3 1 1 %s", reg.ServerCert.Fingerprint), + Purpose: domain.PurposeCertificateBinding, + Required: false, + TTL: 3600, + }} +} diff --git a/internal/adapter/discovery/ans/tlsa_test.go b/internal/adapter/discovery/ans/tlsa_test.go new file mode 100644 index 0000000..be07f6b --- /dev/null +++ b/internal/adapter/discovery/ans/tlsa_test.go @@ -0,0 +1,60 @@ +package ans + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/godaddy/ans/internal/domain" +) + +func TestTLSARecord(t *testing.T) { + mustReg := func(t *testing.T, host string, cert *domain.ByocServerCertificate) *domain.AgentRegistration { + t.Helper() + v, err := domain.NewSemVer(1, 0, 0) + require.NoError(t, err) + ansName, err := domain.NewAnsName(v, host) + require.NoError(t, err) + return &domain.AgentRegistration{AnsName: ansName, ServerCert: cert} + } + + tests := []struct { + name string + reg *domain.AgentRegistration + wantEmpty bool + wantName string + wantValue string + }{ + { + name: "no_server_cert_emits_no_tlsa", + reg: mustReg(t, "agent.example.com", nil), + wantEmpty: true, + }, + { + name: "with_cert_emits_dane_ee_spki_sha256_record", + reg: mustReg(t, "agent.example.com", &domain.ByocServerCertificate{ + Fingerprint: "abcdef0123456789", + }), + wantName: "_443._tcp.agent.example.com", + wantValue: "3 1 1 abcdef0123456789", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := TLSARecord(tc.reg) + if tc.wantEmpty { + assert.Empty(t, got) + return + } + require.Len(t, got, 1) + r := got[0] + assert.Equal(t, tc.wantName, r.Name) + assert.Equal(t, domain.DNSRecordTLSA, r.Type) + assert.Equal(t, tc.wantValue, r.Value) + assert.Equal(t, domain.PurposeCertificateBinding, r.Purpose) + assert.False(t, r.Required, "TLSA is non-required because operator zones may not be DNSSEC-signed") + assert.Equal(t, 3600, r.TTL) + }) + } +} diff --git a/internal/adapter/discovery/ans/txt.go b/internal/adapter/discovery/ans/txt.go new file mode 100644 index 0000000..5575297 --- /dev/null +++ b/internal/adapter/discovery/ans/txt.go @@ -0,0 +1,66 @@ +package ans + +import ( + "fmt" + + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/port" +) + +// TXTStyle implements port.DiscoveryStyle for the original `_ans` TXT +// shape (ANS_TXT). It emits one TXT row per protocol endpoint at +// `_ans.` plus an HTTPS RR at the bare FQDN (when at least one +// endpoint is present), plus the ANS-family trust records. +// +// Behavior change vs. the pre-refactor domain function: the HTTPS RR +// is now gated on `len(reg.Endpoints) > 0`. The pre-refactor code +// emitted an HTTPS RR unconditionally inside the `if emitTXT` branch, +// producing a degenerate `[HTTPS-RR-only]` record set when an +// operator selected ANS_TXT with zero endpoints — a service binding +// for a non-existent agent. The refactor folds this fix in; the PR +// description calls it out. +// +// The HTTPS RR carries `1 . alpn=h2` — service binding for HTTP/2. +// Required=false because operators on CNAME-fronted apex zones cannot +// publish this record at the same name (CNAME at @ blocks HTTPS RR +// per RFC 1034 §3.6.2); the spec does not block them on its absence. +type TXTStyle struct{} + +// ID returns ANS_TXT. +func (TXTStyle) ID() domain.DNSRecordStyle { return domain.DNSRecordStyleTXT } + +// Records returns the `_ans` TXT rows (one per endpoint) plus the +// HTTPS RR (when at least one endpoint exists) plus the family trust +// records. +func (s TXTStyle) Records(reg *domain.AgentRegistration) []domain.ExpectedDNSRecord { + fqdn := reg.FQDN() + version := reg.AnsName.Version().String() + var records []domain.ExpectedDNSRecord + for _, ep := range reg.Endpoints { + value := fmt.Sprintf("v=ans1; version=%s; p=%s; mode=direct; url=%s", + version, protocolToANSValue(ep.Protocol), ep.AgentURL) + records = append(records, domain.ExpectedDNSRecord{ + Name: fmt.Sprintf("_ans.%s", fqdn), + Type: domain.DNSRecordTXT, + Value: value, + Purpose: domain.PurposeDiscovery, + Required: true, + TTL: 3600, + }) + } + if len(reg.Endpoints) > 0 { + records = append(records, domain.ExpectedDNSRecord{ + Name: fqdn, + Type: domain.DNSRecordHTTPS, + Value: `1 . alpn=h2`, + Purpose: domain.PurposeDiscovery, + Required: false, + TTL: 3600, + }) + } + records = append(records, BadgeRecord(reg)...) + records = append(records, TLSARecord(reg)...) + return records +} + +var _ port.DiscoveryStyle = TXTStyle{} diff --git a/internal/adapter/discovery/ans/txt_test.go b/internal/adapter/discovery/ans/txt_test.go new file mode 100644 index 0000000..8c63820 --- /dev/null +++ b/internal/adapter/discovery/ans/txt_test.go @@ -0,0 +1,149 @@ +package ans + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/godaddy/ans/internal/domain" +) + +func TestTXTStyle_ID(t *testing.T) { + assert.Equal(t, domain.DNSRecordStyleTXT, TXTStyle{}.ID()) +} + +func TestTXTStyle_Records(t *testing.T) { + tests := []struct { + name string + eps []domain.AgentEndpoint + wantTXTRows int + wantHTTPS bool + // when wantTXTRows > 0, assertions on the first TXT row's value: + wantInValue []string + wantNotIn []string + }{ + { + name: "one_a2a_endpoint_emits_one_ans_txt_and_one_https_rr", + eps: []domain.AgentEndpoint{ + {Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com/a2a"}, + }, + wantTXTRows: 1, + wantHTTPS: true, + wantInValue: []string{ + "v=ans1", + "version=1.0.0", + "p=a2a", + "mode=direct", + "url=https://agent.example.com/a2a", + }, + wantNotIn: []string{"v1.0.0", "1-0-0"}, + }, + { + name: "two_endpoints_emit_two_ans_txt_rows_one_https_rr", + eps: []domain.AgentEndpoint{ + {Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com/a2a"}, + {Protocol: domain.ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, + }, + wantTXTRows: 2, + wantHTTPS: true, + }, + { + // Behavioral correction bundled in this refactor: zero + // endpoints with ANS_TXT previously emitted an HTTPS RR + // with no `_ans` TXT companions — a service binding for a + // non-existent agent. The gate on len(endpoints) > 0 + // closes that degenerate output. + name: "zero_endpoints_emits_no_records_no_https_rr", + eps: nil, + wantTXTRows: 0, + wantHTTPS: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + reg := mustReg(t, "agent.example.com", tc.eps, "", nil) + records := TXTStyle{}.Records(reg) + + var txtRows int + var httpsRows int + var firstTXTValue string + for _, r := range records { + if r.Type == domain.DNSRecordTXT && strings.HasPrefix(r.Name, "_ans.") { + if txtRows == 0 { + firstTXTValue = r.Value + } + txtRows++ + assert.True(t, r.Required, "_ans TXT must be Required=true") + assert.Equal(t, domain.PurposeDiscovery, r.Purpose) + assert.Equal(t, 3600, r.TTL) + } + if r.Type == domain.DNSRecordHTTPS { + httpsRows++ + assert.Equal(t, "agent.example.com", r.Name, "HTTPS RR lives at the bare FQDN") + assert.False(t, r.Required, "HTTPS RR is non-required (CNAME-at-apex precludes publishing)") + assert.Contains(t, r.Value, "alpn=h2") + } + } + assert.Equal(t, tc.wantTXTRows, txtRows, "_ans TXT row count") + if tc.wantHTTPS { + assert.Equal(t, 1, httpsRows, "exactly one HTTPS RR per registration") + } else { + assert.Zero(t, httpsRows, "zero endpoints must emit zero HTTPS RRs") + } + + for _, want := range tc.wantInValue { + assert.Contains(t, firstTXTValue, want) + } + for _, notWant := range tc.wantNotIn { + assert.NotContains(t, firstTXTValue, notWant) + } + }) + } +} + +// TestTXTStyle_RecordsIncludesFamilyTrustRecords pins that TXTStyle is +// self-contained in the same way as SVCBStyle: it emits the family +// badge and TLSA records. +func TestTXTStyle_RecordsIncludesFamilyTrustRecords(t *testing.T) { + reg := mustReg(t, "agent.example.com", + []domain.AgentEndpoint{{Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}}, + "", &domain.ByocServerCertificate{Fingerprint: "deadbeef"}) + + records := TXTStyle{}.Records(reg) + + var sawBadge, sawTLSA bool + for _, r := range records { + if r.Purpose == domain.PurposeBadge { + sawBadge = true + } + if r.Purpose == domain.PurposeCertificateBinding { + sawTLSA = true + } + } + assert.True(t, sawBadge) + assert.True(t, sawTLSA) +} + +// TestTXTStyle_NoEndpointsSkipsAllFamilyAndDiscoveryRecords pins the +// existing-behavior contract that an empty endpoint list produces an +// empty record set when ServerCert is also nil. Zero endpoints + nil +// cert means there's nothing meaningful to publish. +func TestTXTStyle_NoEndpointsSkipsAllFamilyAndDiscoveryRecords(t *testing.T) { + reg := mustReg(t, "agent.example.com", nil, "", nil) + records := TXTStyle{}.Records(reg) + require.Empty(t, records) +} + +// TestTXTStyle_ZeroEndpointsWithCertOnlyEmitsTLSA pins that even with +// zero endpoints, a registration that has a server cert still gets the +// TLSA record. (The badge requires endpoints; TLSA does not.) +func TestTXTStyle_ZeroEndpointsWithCertOnlyEmitsTLSA(t *testing.T) { + reg := mustReg(t, "agent.example.com", nil, "", + &domain.ByocServerCertificate{Fingerprint: "abcd"}) + records := TXTStyle{}.Records(reg) + require.Len(t, records, 1) + assert.Equal(t, domain.DNSRecordTLSA, records[0].Type) +} diff --git a/internal/adapter/discovery/registry/registry.go b/internal/adapter/discovery/registry/registry.go new file mode 100644 index 0000000..556b096 --- /dev/null +++ b/internal/adapter/discovery/registry/registry.go @@ -0,0 +1,73 @@ +// Package registry holds the immutable composition facade for +// port.DiscoveryStyle implementations. It is the bundled +// port.DiscoveryRegistry: cmd/ans-ra/main.go calls registry.New(...) +// with the styles the binary should serve, and the service consumes the +// returned *Registry through the port interface. +// +// The registry itself is intentionally small (no global state, no +// init-time registration, no plug-in loading). Adding a new style is a +// matter of registering an additional port.DiscoveryStyle in +// cmd/ans-ra/main.go — the contributor walk-through lives in +// docs/contributing-discovery-profiles.md. +package registry + +import ( + "fmt" + + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/port" +) + +// Registry composes port.DiscoveryStyle implementations by ID. Immutable +// post-construction: New stores both a lookup map (for O(1) Get) and an +// insertion-order slice (for stable IDs() iteration). Reads are safe for +// concurrent use without locking; there is no Add/Remove API. +type Registry struct { + styles map[domain.DNSRecordStyle]port.DiscoveryStyle + order []domain.DNSRecordStyle +} + +// New constructs a Registry from the given styles in argument order. +// Returns an error when any style's ID is invalid (per +// domain.DNSRecordStyle.IsValid) or when two styles share the same ID — +// both are deterministic startup misconfigurations the wiring code in +// cmd/ans-ra/main.go must surface as a fail-loud server-start error, +// rather than degrading silently at the first registration. +// +// Iteration order matches argument order (stable across process restarts +// for a given wiring) so the service walker's emission order on the wire +// is determined here, not by request input. +func New(styles ...port.DiscoveryStyle) (*Registry, error) { + r := &Registry{ + styles: make(map[domain.DNSRecordStyle]port.DiscoveryStyle, len(styles)), + order: make([]domain.DNSRecordStyle, 0, len(styles)), + } + for _, s := range styles { + id := s.ID() + if !id.IsValid() { + return nil, fmt.Errorf("registry: style ID %q is not a valid DNSRecordStyle", id) + } + if _, dup := r.styles[id]; dup { + return nil, fmt.Errorf("registry: duplicate style ID %q", id) + } + r.styles[id] = s + r.order = append(r.order, id) + } + return r, nil +} + +// Get returns the style registered under id, or (nil, false) when no such +// style is wired. Implements port.DiscoveryRegistry. +func (r *Registry) Get(id domain.DNSRecordStyle) (port.DiscoveryStyle, bool) { + s, ok := r.styles[id] + return s, ok +} + +// IDs returns the registered style IDs in insertion order. The returned +// slice is a fresh copy; callers may mutate it without affecting the +// registry. Implements port.DiscoveryRegistry. +func (r *Registry) IDs() []domain.DNSRecordStyle { + out := make([]domain.DNSRecordStyle, len(r.order)) + copy(out, r.order) + return out +} diff --git a/internal/adapter/discovery/registry/registry_test.go b/internal/adapter/discovery/registry/registry_test.go new file mode 100644 index 0000000..e8b62b5 --- /dev/null +++ b/internal/adapter/discovery/registry/registry_test.go @@ -0,0 +1,158 @@ +package registry + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/port" +) + +// fakeStyle is a minimal port.DiscoveryStyle test double. ID() is the +// only behavior the registry inspects; Records() returns nil so the +// fake stays cheap to instantiate in tables. +type fakeStyle struct{ id domain.DNSRecordStyle } + +func (f fakeStyle) ID() domain.DNSRecordStyle { return f.id } +func (f fakeStyle) Records(*domain.AgentRegistration) []domain.ExpectedDNSRecord { + return nil +} + +func TestNew(t *testing.T) { + tests := []struct { + name string + styles []port.DiscoveryStyle + wantErr string // substring; empty means success expected + wantOrder []domain.DNSRecordStyle + }{ + { + name: "empty_registry_constructs", + styles: nil, + wantOrder: []domain.DNSRecordStyle{}, + }, + { + name: "single_valid_style", + styles: []port.DiscoveryStyle{fakeStyle{id: domain.DNSRecordStyleSVCB}}, + wantOrder: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + }, + { + name: "two_valid_styles_preserve_argument_order", + styles: []port.DiscoveryStyle{ + fakeStyle{id: domain.DNSRecordStyleTXT}, + fakeStyle{id: domain.DNSRecordStyleSVCB}, + }, + wantOrder: []domain.DNSRecordStyle{ + domain.DNSRecordStyleTXT, + domain.DNSRecordStyleSVCB, + }, + }, + { + name: "duplicate_id_rejected", + styles: []port.DiscoveryStyle{ + fakeStyle{id: domain.DNSRecordStyleSVCB}, + fakeStyle{id: domain.DNSRecordStyleSVCB}, + }, + wantErr: "duplicate style ID", + }, + { + name: "invalid_id_rejected", + styles: []port.DiscoveryStyle{ + fakeStyle{id: domain.DNSRecordStyle("NOT_A_STYLE")}, + }, + wantErr: "is not a valid DNSRecordStyle", + }, + { + name: "invalid_id_rejected_after_valid_one", + styles: []port.DiscoveryStyle{ + fakeStyle{id: domain.DNSRecordStyleSVCB}, + fakeStyle{id: domain.DNSRecordStyle("")}, + }, + wantErr: "is not a valid DNSRecordStyle", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r, err := New(tc.styles...) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + assert.Nil(t, r) + return + } + require.NoError(t, err) + require.NotNil(t, r) + assert.Equal(t, tc.wantOrder, r.IDs()) + }) + } +} + +func TestGet(t *testing.T) { + svcb := fakeStyle{id: domain.DNSRecordStyleSVCB} + txt := fakeStyle{id: domain.DNSRecordStyleTXT} + r, err := New(svcb, txt) + require.NoError(t, err) + + tests := []struct { + name string + id domain.DNSRecordStyle + wantHit bool + wantID domain.DNSRecordStyle + }{ + {name: "hit_svcb", id: domain.DNSRecordStyleSVCB, wantHit: true, wantID: domain.DNSRecordStyleSVCB}, + {name: "hit_txt", id: domain.DNSRecordStyleTXT, wantHit: true, wantID: domain.DNSRecordStyleTXT}, + {name: "miss_unknown_style", id: domain.DNSRecordStyle("UNKNOWN_FAMILY"), wantHit: false}, + {name: "miss_empty_id", id: domain.DNSRecordStyle(""), wantHit: false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, ok := r.Get(tc.id) + assert.Equal(t, tc.wantHit, ok) + if tc.wantHit { + require.NotNil(t, got) + assert.Equal(t, tc.wantID, got.ID()) + } else { + assert.Nil(t, got) + } + }) + } +} + +// TestIDs_ReturnsCopy pins that the slice IDs() returns is a fresh copy — +// callers mutating it must not affect the registry's internal order, so +// concurrent readers can safely iterate without coordination. +func TestIDs_ReturnsCopy(t *testing.T) { + r, err := New( + fakeStyle{id: domain.DNSRecordStyleTXT}, + fakeStyle{id: domain.DNSRecordStyleSVCB}, + ) + require.NoError(t, err) + + first := r.IDs() + first[0] = domain.DNSRecordStyle("MUTATED") + + second := r.IDs() + assert.Equal(t, []domain.DNSRecordStyle{ + domain.DNSRecordStyleTXT, + domain.DNSRecordStyleSVCB, + }, second, "mutating one IDs() result must not affect a subsequent call") +} + +// TestIDs_StableAcrossCalls pins that two consecutive IDs() calls return +// the same insertion order. Map iteration is non-deterministic in Go; +// the registry must materialize order from the order slice, not the map. +func TestIDs_StableAcrossCalls(t *testing.T) { + r, err := New( + fakeStyle{id: domain.DNSRecordStyleTXT}, + fakeStyle{id: domain.DNSRecordStyleSVCB}, + ) + require.NoError(t, err) + + for range 100 { + assert.Equal(t, []domain.DNSRecordStyle{ + domain.DNSRecordStyleTXT, + domain.DNSRecordStyleSVCB, + }, r.IDs()) + } +} diff --git a/internal/adapter/dns/lookup.go b/internal/adapter/dns/lookup.go index d4f537d..fd39a38 100644 --- a/internal/adapter/dns/lookup.go +++ b/internal/adapter/dns/lookup.go @@ -267,9 +267,9 @@ func formatHTTPSValue(s *dns.SVCB) string { // verifySVCB checks for a Consolidated Approach SVCB record (RFC 9460) // at the agent's bare FQDN. Multiple SVCB records can share one RRset // name distinguished by alpn, and the Consolidated Approach explicitly -// designs for multi-spec coexistence in a single record (DNS-AID, ANS, -// and other agentic specs sharing one SVCB row, distinguished by their -// own SvcParamKeys). Verification therefore implements RFC 9460 §8 +// designs for multi-family coexistence in a single record — sibling +// families can share one SVCB row, distinguished by their own +// SvcParamKeys. Verification therefore implements RFC 9460 §8 // unknown-key ignore semantics as a *subset* match: priority and // target must equal the expected value exactly, every expected // SvcParam must be present in the live record with an equal value, diff --git a/internal/domain/dnsrecords.go b/internal/domain/dnsrecords.go index 48cd73a..47bc211 100644 --- a/internal/domain/dnsrecords.go +++ b/internal/domain/dnsrecords.go @@ -1,13 +1,5 @@ package domain -import ( - "encoding/base64" - "encoding/hex" - "fmt" - "net/url" - "strconv" -) - // DNSRecordStyle names one DNS record family the RA can emit for an // agent registration. A registration carries a *set* of styles // (AgentRegistration.DNSRecordStyles); operators publishing the union @@ -47,6 +39,12 @@ func DefaultDNSRecordStyles() []DNSRecordStyle { // IsValid reports whether s is one of the defined styles. Empty // string is treated as invalid; callers normalize empty/missing // dnsRecordStyles to DefaultDNSRecordStyles() before validation. +// +// Coherence with the discovery registry is enforced at server start: +// cmd/ans-ra/main.go asserts that every style in +// ValidDNSRecordStyles() has a registered port.DiscoveryStyle adapter +// and vice versa. Drift fails server start, not the first verify-dns +// call. func (s DNSRecordStyle) IsValid() bool { switch s { case DNSRecordStyleSVCB, DNSRecordStyleTXT: @@ -66,37 +64,6 @@ func ValidDNSRecordStyles() []string { } } -// resolveEmissionFlags maps a set of styles onto the two orthogonal -// "emit this record family?" booleans the record builder uses. An -// empty/nil set normalizes to DefaultDNSRecordStyles(); invalid -// values in the set are silently ignored (the service layer rejects -// them at the boundary, so any value reaching here SHOULD already be -// valid — defensive ignore keeps the domain layer pure). -// -// Returns (emitTXT, emitSVCB) — order matters; the caller destructures -// positionally to two booleans guarding the legacy and consolidated -// branches of ComputeRequiredDNSRecords. -func resolveEmissionFlags(styles []DNSRecordStyle) (bool, bool) { - if len(styles) == 0 { - styles = DefaultDNSRecordStyles() - } - var emitTXT, emitSVCB bool - for _, s := range styles { - switch s { - case DNSRecordStyleSVCB: - emitSVCB = true - case DNSRecordStyleTXT: - emitTXT = true - } - } - if !emitTXT && !emitSVCB { - // Every element was invalid — fall back to the default set so - // the operator at least gets some records to publish. - emitSVCB = true - } - return emitTXT, emitSVCB -} - // DNSRecordType represents a DNS record type. type DNSRecordType string @@ -104,13 +71,12 @@ const ( DNSRecordTXT DNSRecordType = "TXT" DNSRecordTLSA DNSRecordType = "TLSA" DNSRecordHTTPS DNSRecordType = "HTTPS" - // DNSRecordSVCB is the cross-draft "Consolidated Approach" service - // binding record (RFC 9460) emitted at the agent's bare FQDN. One - // SVCB record per protocol carries that protocol's connection hints - // and capability locators in a single DNS lookup. SvcParams from - // DNS-AID, ANS, and other agentic specs coexist in the same record - // per RFC 9460 §8 unknown-key ignore semantics. See §4.4.2 of - // https://github.com/godaddy/ans-registry/blob/main/DESIGN.md. + // DNSRecordSVCB is the "Consolidated Approach" service binding + // record (RFC 9460) emitted at the agent's bare FQDN. One SVCB + // record per protocol carries that protocol's connection hints and + // capability locators in a single DNS lookup. SvcParams from + // sibling families coexist in the same record per RFC 9460 §8 + // unknown-key ignore semantics. DNSRecordSVCB DNSRecordType = "SVCB" ) @@ -133,267 +99,3 @@ type ExpectedDNSRecord struct { Required bool `json:"required"` TTL int `json:"ttl"` } - -// ComputeRequiredDNSRecords generates the DNS records an operator must create -// for a given agent registration. The RA does not create these records — the -// operator manages their own DNS. The RA only verifies they exist. -// -// The set of records emitted is keyed off reg.DNSRecordStyles: -// -// - {ANS_SVCB} (default, recommended): Consolidated Approach SVCB -// rows (one per protocol) plus the shared `_ans-`-prefixed records -// plus the server-cert TLSA. No legacy `_ans` TXT rows. -// - {ANS_TXT}: the original `_ans` TXT shape (one row per protocol) -// plus the same shared records. No SVCB rows. Backwards-compatible -// with operators who registered before the Consolidated Approach -// landed and have existing zone-edit tooling for `_ans` TXT. -// - {ANS_SVCB, ANS_TXT}: the §4.4.2 transition shape; operators run -// both record families on the same zone for a defined window. -// -// Empty/missing reg.DNSRecordStyles is normalized to -// DefaultDNSRecordStyles(); invalid elements are dropped (the -// service layer rejects bad inputs at the boundary). -func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { - fqdn := reg.FQDN() - // Version is emitted as a bare semver string ("1.2.0"). The - // `v`-prefixed form only appears inside the ANS name's hostname - // label — TXT record payloads carry the machine-readable semver - // directly, matching the shape a client would parse with any - // semver library. - version := reg.AnsName.Version().String() - var records []ExpectedDNSRecord - - emitTXT, emitSVCB := resolveEmissionFlags(reg.DNSRecordStyles) - - // _ans TXT record for each protocol endpoint — legacy discovery. - if emitTXT { - for _, ep := range reg.Endpoints { - value := fmt.Sprintf("v=ans1; version=%s; p=%s; mode=direct; url=%s", - version, protocolToANSValue(ep.Protocol), ep.AgentURL) - records = append(records, ExpectedDNSRecord{ - Name: fmt.Sprintf("_ans.%s", fqdn), - Type: DNSRecordTXT, - Value: value, - Purpose: PurposeDiscovery, - Required: true, - TTL: 3600, - }) - } - - // HTTPS RR (RFC 9460 type 65) at the agent FQDN — service - // binding for HTTP/2 (and Encrypted Client Hello when the - // AHP provides an ECH config out-of-band). Per §A.8.1 the - // RA generates the content; the AHP decides whether to - // publish based on whether their apex is aliased via CNAME - // (CNAME at the agent FQDN blocks HTTPS RR at the same name - // per RFC 1034 §3.6.2). - // - // Skipped for the consolidated form: the SVCB rows already - // carry alpn / port / ECH SvcParams, so an HTTPS RR - // alongside duplicates content (§A.8.2). Legacy keeps it - // because the `_ans` TXT family does not carry connection - // hints — clients without ANS-protocol awareness rely on - // HTTPS RR for ALPN signalling. - // - // Required=false: operators on CNAME-fronted apex zones - // cannot publish this record at the same name; the spec - // does not block them on its absence. - records = append(records, ExpectedDNSRecord{ - Name: fqdn, - Type: DNSRecordHTTPS, - Value: `1 . alpn=h2`, - Purpose: PurposeDiscovery, - Required: false, - TTL: 3600, - }) - } - - // Consolidated Approach SVCB record at the bare FQDN — one per - // protocol endpoint. RFC 9460 ServiceMode (SvcPriority 1) with - // TargetName "." (same name) so address resolution stays at the - // agent's FQDN. SvcParams from DNS-AID, ANS, and other agentic - // specs coexist via RFC 9460 §8 unknown-key ignore. card-sha256 - // carries base64url(reg.CapabilitiesHash) when the operator - // submitted agentCardContent; otherwise the SvcParam is absent - // and a verifier falls back to TOFU on first Trust Card fetch. - // - // Provisional-key note: `wk` and `card-sha256` are not yet - // IANA-registered SvcParamKeys per RFC 9460 §6. The Consolidated - // Approach draft emits them by symbolic name; production - // deployments using strict-RFC parsers MAY need to publish them - // in keyNNNNN form until registration completes. The expected - // value the RA writes here uses the symbolic form to match the - // draft's worked examples; the verifier compares post- - // normalization, and operators whose authoritative DNS only - // emits keyNNNNN form will see a mismatch the RA reports as a - // non-blocking integrity finding (Required=false below). - // - // Required: SVCB rows carry the registration's only - // PurposeDiscovery signal when ANS_SVCB is the sole style; in - // that mode verify-dns must require them, otherwise the agent - // could "register" without publishing any discovery record. When - // the operator opted into the union ({ANS_SVCB, ANS_TXT}), the - // legacy `_ans` TXT family above carries Required=true and the - // SVCB row stays optional alongside it (§4.4.2 marks the - // Consolidated Approach as MAY during the transition). - svcbRequired := emitSVCB && !emitTXT - if emitSVCB { - cardSHA := capabilitiesHashBase64URL(reg.CapabilitiesHash) - for _, ep := range reg.Endpoints { - alpn := protocolToANSValue(ep.Protocol) - wk := wkPathFor(ep.Protocol) - port := svcbPortFor(ep.AgentURL) - // RFC 9460 §2.1 presentation form: unquoted SvcParamValue when - // the value has no characters special to the presentation - // format. alpn tokens (a2a, mcp), port digits, well-known path - // suffixes (agent-card.json), and base64url digests all qualify. - // The resolver-side formatter (formatHTTPSValue) also emits - // unquoted, so the verifier's normalize+compare matches without - // quote-stripping. - value := fmt.Sprintf(`1 . alpn=%s port=%d`, alpn, port) - if wk != "" { - value += fmt.Sprintf(` wk=%s`, wk) - } - if cardSHA != "" { - value += fmt.Sprintf(` card-sha256=%s`, cardSHA) - } - records = append(records, ExpectedDNSRecord{ - Name: fqdn, - Type: DNSRecordSVCB, - Value: value, - Purpose: PurposeDiscovery, - Required: svcbRequired, - TTL: 3600, - }) - } - } - - // _ans-badge TXT record — trust badge. Required alongside _ans: - // resolvers and badge-verifying clients expect to find both, and - // publishing _ans without _ans-badge would advertise an agent - // that fails the public discovery handshake. - if len(reg.Endpoints) > 0 { - badgeValue := fmt.Sprintf("v=ans-badge1; version=%s; url=%s", - version, reg.Endpoints[0].AgentURL) - records = append(records, ExpectedDNSRecord{ - Name: fmt.Sprintf("_ans-badge.%s", fqdn), - Type: DNSRecordTXT, - Value: badgeValue, - Purpose: PurposeBadge, - Required: true, - TTL: 3600, - }) - } - - // TLSA record for certificate binding. Every registration has a - // server cert — either BYOC (operator-submitted) or CSR-signed - // (RA issues via its configured `ServerCertificateAuthority`). - // Both paths land through the same ByocServerCertificate struct, - // so `reg.ServerCert` is set for any registration that's reached - // verify-dns. - // - // `3 1 1 ` = DANE-EE + SubjectPublicKeyInfo + SHA-256 - // (RFC 6698). Required=false: operators whose zones aren't - // DNSSEC-signed can't produce a trustworthy TLSA record, so the - // RA doesn't block verify-dns on its presence. The verify layer - // enforces a stricter rule at query time: when a TLSA response - // IS DNSSEC-validated, its value must match the expected - // fingerprint (otherwise an attacker rewrote the record in a - // signed zone — the worst failure mode). That post-verify - // check lives alongside the verifier, not in the record set. - if reg.ServerCert == nil { - return records - } - records = append(records, ExpectedDNSRecord{ - Name: fmt.Sprintf("_443._tcp.%s", fqdn), - Type: DNSRecordTLSA, - Value: fmt.Sprintf("3 1 1 %s", reg.ServerCert.Fingerprint), - Purpose: PurposeCertificateBinding, - Required: false, - TTL: 3600, - }) - - return records -} - -func protocolToANSValue(p Protocol) string { - switch p { - case ProtocolA2A: - return "a2a" - case ProtocolMCP: - return "mcp" - case ProtocolHTTPAPI: - return "http-api" - default: - return string(p) - } -} - -// wkPathFor returns the suffix-only well-known path published in the -// Consolidated Approach SVCB record's `wk=` SvcParam. Suffix-only matches -// the consolidated-draft examples (§4 line 134); clients prepend -// `/.well-known/` to construct the full path. Empty result means the -// caller SHOULD omit `wk=` entirely (e.g., direct-mode agents that -// expose no canonical metadata file). -// -// A2A: `agent-card.json` (IANA-registered well-known per A2A spec). -// MCP: `mcp.json` (de-facto convention; see SEP-1649 progress). -// HTTP-API: empty (no per-protocol metadata file convention). -func wkPathFor(p Protocol) string { - switch p { - case ProtocolA2A: - return "agent-card.json" - case ProtocolMCP: - return "mcp.json" - default: - return "" - } -} - -// svcbPortFor returns the TCP port to advertise in the SVCB SvcParam -// `port=`. Reads it from the endpoint URL's authority. Falls back to -// 443 (https) / 80 (http) when the URL omits a port. Empty input or -// unparseable URL returns 443 — the §4.4.2 default for agent endpoints. -// -// Without this, every endpoint emitted a hardcoded port=443 SvcParam, -// silently breaking verify-dns for agents on non-443 endpoints -// (operators would publish their actual port; the RA's expected -// record would say 443; the records would mismatch). -func svcbPortFor(agentURL string) int { - if agentURL == "" { - return 443 - } - u, err := url.Parse(agentURL) - if err != nil { - return 443 - } - if p := u.Port(); p != "" { - if n, err := strconv.Atoi(p); err == nil { - return n - } - } - if u.Scheme == "http" { - return 80 - } - return 443 -} - -// capabilitiesHashBase64URL re-encodes a hex-lowercase SHA-256 digest -// (the form `AgentRegistration.CapabilitiesHash` carries) into the -// base64url form (RFC 4648 §5, no padding) the SVCB `card-sha256` -// SvcParam expects. Empty input returns empty output, which the caller -// SHOULD treat as "omit the SvcParam entirely" — agents registered -// without `agentCardContent` have no committed value to publish. -func capabilitiesHashBase64URL(hexDigest string) string { - if hexDigest == "" { - return "" - } - raw, err := hex.DecodeString(hexDigest) - if err != nil || len(raw) == 0 { - // Malformed input is logically equivalent to absence; the RA - // stores well-formed hex by construction (helpers.go: - // hashAgentCardContent), but defensive on the boundary. - return "" - } - return base64.RawURLEncoding.EncodeToString(raw) -} diff --git a/internal/domain/dnsrecords_test.go b/internal/domain/dnsrecords_test.go index 8306e96..2ebc77d 100644 --- a/internal/domain/dnsrecords_test.go +++ b/internal/domain/dnsrecords_test.go @@ -1,342 +1,11 @@ package domain import ( - "strings" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestComputeRequiredDNSRecords_WithoutCert(t *testing.T) { - ansName, _ := NewAnsName(mustSemVer(1, 2, 3), "agent.example.com") - reg := &AgentRegistration{ - AnsName: ansName, - // Force the union set so this fixture exercises both record - // families: _ans TXT + Consolidated Approach SVCB. Tests below - // cover the single-style emission paths. - DNSRecordStyles: []DNSRecordStyle{DNSRecordStyleSVCB, DNSRecordStyleTXT}, - Endpoints: []AgentEndpoint{ - {Protocol: ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, - {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com/a2a"}, - }, - } - - records := ComputeRequiredDNSRecords(reg) - require.NotEmpty(t, records) - - // 2 endpoints → 2 _ans TXT + 1 HTTPS + 2 Consolidated Approach SVCB + - // 1 badge TXT (no TLSA: no cert). - var ansTxtCount, httpsCount, svcbCount, badgeCount, tlsaCount int - for _, r := range records { - switch r.Purpose { - case PurposeDiscovery: - switch r.Type { - case DNSRecordTXT: - ansTxtCount++ - assert.True(t, strings.HasPrefix(r.Name, "_ans.")) - assert.True(t, r.Required) - assert.Contains(t, r.Value, "v=ans1") - // Version is bare semver, not DNS-label form — TXT - // payloads carry the machine-parseable semver directly. - assert.Contains(t, r.Value, "version=1.2.3") - assert.NotContains(t, r.Value, "v1.2.3", "no v-prefix in TXT payload") - assert.NotContains(t, r.Value, "1-2-3", "no dash form anywhere") - case DNSRecordSVCB: - svcbCount++ - assert.Equal(t, "agent.example.com", r.Name, - "Consolidated Approach SVCB at the bare FQDN, not at _ans.{fqdn}") - // Union fixture ({ANS_SVCB, ANS_TXT}): legacy TXT - // carries Required=true, SVCB rides along as optional - // per §4.4.2's MAY-during-transition framing. The - // SVCB-sole path flips this to Required=true; covered - // in TestComputeRequiredDNSRecords_StyleMatrix. - assert.False(t, r.Required, "SVCB optional when emitted alongside legacy TXT") - assert.Contains(t, r.Value, `1 . `, "ServiceMode (priority 1) with TargetName .") - assert.Contains(t, r.Value, "alpn=", "alpn distinguishes protocols within the RRset") - assert.Contains(t, r.Value, "port=443") - // No agentCardContent submitted in this fixture, so - // card-sha256 should be absent. - assert.NotContains(t, r.Value, "card-sha256") - case DNSRecordHTTPS: - httpsCount++ - assert.Equal(t, "agent.example.com", r.Name, - "HTTPS RR at the bare FQDN per §A.8.1") - assert.False(t, r.Required, - "HTTPS RR is opt-in: blocked by CNAME at @ when AHP fronts the apex") - assert.Contains(t, r.Value, "alpn=h2") - default: - t.Errorf("unexpected discovery record type %q", r.Type) - } - case PurposeBadge: - badgeCount++ - assert.Equal(t, DNSRecordTXT, r.Type) - assert.True(t, strings.HasPrefix(r.Name, "_ans-badge.")) - // Both _ans and _ans-badge are required: badge-verifying - // clients won't trust an agent that publishes _ans alone. - assert.True(t, r.Required) - case PurposeCertificateBinding: - tlsaCount++ - } - } - - assert.Equal(t, 2, ansTxtCount) - assert.Equal(t, 1, httpsCount, "one HTTPS RR at the bare FQDN per §A.8.1") - assert.Equal(t, 2, svcbCount, "one SVCB row per protocol at the bare FQDN") - assert.Equal(t, 1, badgeCount) - assert.Equal(t, 0, tlsaCount, "no cert → no TLSA record") -} - -// TestComputeRequiredDNSRecords_StyleMatrix exercises every emission -// rule in one table. Each row pins the per-record-type shape the -// operator is asked to publish given a (styles, protocol, -// capabilitiesHash, agentURL) tuple. The matrix covers: -// -// - {ANS_TXT} emits _ans TXT + HTTPS RR; no SVCB. -// - {ANS_SVCB} emits SVCB only (no HTTPS RR — duplicate signalling). -// - {ANS_SVCB, ANS_TXT} emits the union. -// - SVCB SvcParam composition: wk= (per-protocol), port= (from URL), -// card-sha256= (only when CapabilitiesHash is set). -// - svcbPortFor: explicit non-443 port flows through, default https -// URLs fall back to 443. -// - Empty styles (nil slice) coerces to the default ({ANS_SVCB}). -// - All-invalid styles set still produces records (defensive -// fallback in the domain layer; the service rejects bad inputs). -func TestComputeRequiredDNSRecords_StyleMatrix(t *testing.T) { - const cardHex = "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27" - const wantCardBase64 = "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc" - - tests := []struct { - name string - styles []DNSRecordStyle - protocol Protocol - agentURL string - capabilitiesHash string - wantHTTPS bool - wantSVCB bool - wantSVCBRequired bool // applies only when wantSVCB is true - wantLegacyTXT bool - wantSVCBPort string // substring expected in SVCB value (e.g. "port=443") - wantSVCBWk string // "" means SVCB MUST NOT contain "wk=" - wantSVCBCard string // "" means SVCB MUST NOT contain "card-sha256" - }{ - { - name: "ans_txt_only_emits_https_rr_no_svcb", - styles: []DNSRecordStyle{DNSRecordStyleTXT}, - protocol: ProtocolA2A, - agentURL: "https://agent.example.com", - wantHTTPS: true, - wantLegacyTXT: true, - }, - { - name: "ans_svcb_only_omits_https_rr", - styles: []DNSRecordStyle{DNSRecordStyleSVCB}, - protocol: ProtocolA2A, - agentURL: "https://agent.example.com", - wantSVCB: true, - wantSVCBRequired: true, // SVCB-sole: only PurposeDiscovery record, must be required - wantSVCBPort: "port=443", - wantSVCBWk: "wk=agent-card.json", - }, - { - name: "union_emits_both_families", - styles: []DNSRecordStyle{DNSRecordStyleSVCB, DNSRecordStyleTXT}, - protocol: ProtocolA2A, - agentURL: "https://agent.example.com", - wantHTTPS: true, - wantLegacyTXT: true, - wantSVCB: true, - // wantSVCBRequired: false — legacy `_ans` TXT carries the - // Required signal during the §4.4.2 transition; SVCB rides - // along as optional. - wantSVCBPort: "port=443", - wantSVCBWk: "wk=agent-card.json", - }, - { - name: "svcb_mcp_wk_mcp_json", - styles: []DNSRecordStyle{DNSRecordStyleSVCB}, - protocol: ProtocolMCP, - agentURL: "https://agent.example.com/mcp", - wantSVCB: true, - wantSVCBRequired: true, - wantSVCBPort: "port=443", - wantSVCBWk: "wk=mcp.json", - }, - { - name: "svcb_http_api_omits_wk", - styles: []DNSRecordStyle{DNSRecordStyleSVCB}, - protocol: ProtocolHTTPAPI, - agentURL: "https://agent.example.com", - wantSVCB: true, - wantSVCBRequired: true, - wantSVCBPort: "port=443", - // HTTP-API has no per-protocol metadata file convention. - }, - { - name: "svcb_card_sha256_present_when_set", - styles: []DNSRecordStyle{DNSRecordStyleSVCB}, - protocol: ProtocolA2A, - agentURL: "https://agent.example.com", - capabilitiesHash: cardHex, - wantSVCB: true, - wantSVCBRequired: true, - wantSVCBPort: "port=443", - wantSVCBWk: "wk=agent-card.json", - wantSVCBCard: "card-sha256=" + wantCardBase64, - }, - { - name: "svcb_non_443_port_from_url", - styles: []DNSRecordStyle{DNSRecordStyleSVCB}, - protocol: ProtocolA2A, - agentURL: "https://agent.example.com:8443", - wantSVCB: true, - wantSVCBRequired: true, - wantSVCBPort: "port=8443", - wantSVCBWk: "wk=agent-card.json", - }, - { - name: "svcb_http_scheme_defaults_port_80", - styles: []DNSRecordStyle{DNSRecordStyleSVCB}, - protocol: ProtocolA2A, - agentURL: "http://agent.example.com", - wantSVCB: true, - wantSVCBRequired: true, - wantSVCBPort: "port=80", - wantSVCBWk: "wk=agent-card.json", - }, - { - name: "empty_styles_coerces_to_default", - styles: nil, - protocol: ProtocolA2A, - agentURL: "https://agent.example.com", - wantSVCB: true, - wantSVCBRequired: true, // default ({ANS_SVCB}) is SVCB-sole - wantSVCBPort: "port=443", - wantSVCBWk: "wk=agent-card.json", - }, - { - name: "all_invalid_styles_falls_back_to_default", - styles: []DNSRecordStyle{DNSRecordStyle("garbage"), DNSRecordStyle("nonsense")}, - protocol: ProtocolA2A, - agentURL: "https://agent.example.com", - wantSVCB: true, - wantSVCBRequired: true, // fallback default ({ANS_SVCB}) is SVCB-sole - wantSVCBPort: "port=443", - wantSVCBWk: "wk=agent-card.json", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") - reg := &AgentRegistration{ - AnsName: ansName, - DNSRecordStyles: tc.styles, - CapabilitiesHash: tc.capabilitiesHash, - Endpoints: []AgentEndpoint{ - {Protocol: tc.protocol, AgentURL: tc.agentURL}, - }, - } - records := ComputeRequiredDNSRecords(reg) - - var sawHTTPS, sawSVCB, sawLegacyTXT bool - var svcbValue string - var svcbRequired bool - for _, r := range records { - switch r.Type { - case DNSRecordHTTPS: - sawHTTPS = true - case DNSRecordSVCB: - sawSVCB = true - svcbValue = r.Value - svcbRequired = r.Required - case DNSRecordTXT: - if strings.HasPrefix(r.Name, "_ans.") { - sawLegacyTXT = true - } - } - } - - assert.Equal(t, tc.wantHTTPS, sawHTTPS, "HTTPS RR presence") - assert.Equal(t, tc.wantSVCB, sawSVCB, "SVCB row presence") - assert.Equal(t, tc.wantLegacyTXT, sawLegacyTXT, "_ans TXT presence") - - if tc.wantSVCB { - assert.Equal(t, tc.wantSVCBRequired, svcbRequired, - "SVCB Required flag mismatch (true iff ANS_SVCB is the sole style)") - assert.Contains(t, svcbValue, tc.wantSVCBPort, - "SVCB port SvcParam mismatch") - if tc.wantSVCBWk != "" { - assert.Contains(t, svcbValue, tc.wantSVCBWk, "SVCB wk SvcParam mismatch") - } else { - assert.NotContains(t, svcbValue, "wk=", - "SVCB MUST NOT carry wk= when protocol has no metadata convention") - } - if tc.wantSVCBCard != "" { - assert.Contains(t, svcbValue, tc.wantSVCBCard, "SVCB card-sha256 SvcParam mismatch") - } else { - assert.NotContains(t, svcbValue, "card-sha256", - "SVCB MUST NOT carry card-sha256 when CapabilitiesHash is empty") - } - } - }) - } -} - -// TestCapabilitiesHashBase64URL pins the hex→base64url conversion. -func TestCapabilitiesHashBase64URL(t *testing.T) { - tests := []struct { - name string - in string - want string - }{ - { - name: "live_webmesh_trust_card_digest", - in: "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27", - want: "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc", - }, - { - name: "all_zeros", - in: "0000000000000000000000000000000000000000000000000000000000000000", - want: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - }, - { - name: "empty_input_empty_output", - in: "", - want: "", - }, - { - name: "malformed_hex_returns_empty", - in: "not hex", - want: "", - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got := capabilitiesHashBase64URL(tc.in) - assert.Equal(t, tc.want, got) - }) - } -} - -// TestWkPathFor pins the per-protocol well-known suffix mapping. -func TestWkPathFor(t *testing.T) { - tests := []struct { - p Protocol - want string - }{ - {ProtocolA2A, "agent-card.json"}, - {ProtocolMCP, "mcp.json"}, - {ProtocolHTTPAPI, ""}, - {Protocol("UNKNOWN"), ""}, - } - for _, tc := range tests { - t.Run(string(tc.p), func(t *testing.T) { - assert.Equal(t, tc.want, wkPathFor(tc.p)) - }) - } -} - // TestValidDNSRecordStyles pins the canonical valid set of // DNSRecordStyle values returned by the helper used in the V2 // INVALID_DNS_RECORD_STYLE error message and (eventually) by spec @@ -356,71 +25,23 @@ func TestDefaultDNSRecordStyles(t *testing.T) { assert.Equal(t, want, got) } -// TestSVCBPortFor pins the agentURL → port resolution that drives the -// SVCB `port=` SvcParam. Covers https-default, http-default, explicit -// port, malformed URL, and empty input. -func TestSVCBPortFor(t *testing.T) { +// TestDNSRecordStyle_IsValid covers the typed-enum membership predicate +// applyDNSRecordStyles and the registry-coherence check both rely on. +func TestDNSRecordStyle_IsValid(t *testing.T) { tests := []struct { name string - in string - want int + s DNSRecordStyle + want bool }{ - {name: "https_default_443", in: "https://agent.example.com", want: 443}, - {name: "http_default_80", in: "http://agent.example.com", want: 80}, - {name: "explicit_port_8443", in: "https://agent.example.com:8443", want: 8443}, - {name: "explicit_port_8080_http", in: "http://agent.example.com:8080", want: 8080}, - {name: "with_path_keeps_port", in: "https://agent.example.com:9443/a2a", want: 9443}, - {name: "empty_url_defaults_443", in: "", want: 443}, - {name: "malformed_url_defaults_443", in: "://not-a-url", want: 443}, + {name: "ans_svcb_is_valid", s: DNSRecordStyleSVCB, want: true}, + {name: "ans_txt_is_valid", s: DNSRecordStyleTXT, want: true}, + {name: "empty_is_invalid", s: DNSRecordStyle(""), want: false}, + {name: "unknown_is_invalid", s: DNSRecordStyle("UNKNOWN_FAMILY"), want: false}, + {name: "lowercase_is_invalid", s: DNSRecordStyle("ans_svcb"), want: false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.want, svcbPortFor(tc.in)) + assert.Equal(t, tc.want, tc.s.IsValid()) }) } } - -func TestComputeRequiredDNSRecords_WithCert(t *testing.T) { - ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") - reg := &AgentRegistration{ - AnsName: ansName, - Endpoints: []AgentEndpoint{ - {Protocol: ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, - }, - ServerCert: &ByocServerCertificate{Fingerprint: "abcdef"}, - } - - records := ComputeRequiredDNSRecords(reg) - - var tlsaFound bool - for _, r := range records { - if r.Purpose == PurposeCertificateBinding { - tlsaFound = true - assert.Equal(t, DNSRecordTLSA, r.Type) - assert.Contains(t, r.Name, "_443._tcp.") - assert.Contains(t, r.Value, "abcdef") - // Required=false: TLSA is only meaningful when the - // operator's zone is DNSSEC-signed, which is a runtime - // property the domain layer can't know. The verifier - // enforces a post-verify rule: if the TLSA response was - // DNSSEC-validated, its value must match. See - // RegistrationService.verifyDNSRecords. - assert.False(t, r.Required) - } - } - assert.True(t, tlsaFound) -} - -func TestComputeRequiredDNSRecords_NoEndpoints(t *testing.T) { - ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") - reg := &AgentRegistration{AnsName: ansName} - records := ComputeRequiredDNSRecords(reg) - assert.Empty(t, records) -} - -func TestProtocolToANSValue(t *testing.T) { - assert.Equal(t, "a2a", protocolToANSValue(ProtocolA2A)) - assert.Equal(t, "mcp", protocolToANSValue(ProtocolMCP)) - assert.Equal(t, "http-api", protocolToANSValue(ProtocolHTTPAPI)) - assert.Equal(t, "UNKNOWN", protocolToANSValue(Protocol("UNKNOWN"))) -} diff --git a/internal/port/discovery.go b/internal/port/discovery.go new file mode 100644 index 0000000..2cad34a --- /dev/null +++ b/internal/port/discovery.go @@ -0,0 +1,51 @@ +package port + +import ( + "github.com/godaddy/ans/internal/domain" +) + +// DiscoveryStyle is one named DNS discovery family the RA can emit for an +// agent registration. Implementations live under internal/adapter/discovery/ +// /. Today the bundled set is the ANS family (ANS_SVCB, ANS_TXT); +// additional families plug in as new vendor packages without touching the +// service or domain layers. +// +// Records is a pure function: no I/O, no context, no error. The service +// layer composes per-style outputs by walking a DiscoveryRegistry and +// concatenating each style's records, deduping by (Name, Type, Value) so +// per-family trust records (e.g. _ans-badge, TLSA) emitted by multiple +// styles in the same family land once. +type DiscoveryStyle interface { + // ID returns the wire-format identifier (e.g. "ANS_SVCB", "ANS_TXT"). + // Persisted on agent rows; surfaced on the V2 register schema; used + // as the registry key. + ID() domain.DNSRecordStyle + + // Records returns the DNS records this style needs an operator to + // publish for reg. Includes both per-style discovery records and any + // family-level trust attestation records the style requires (e.g. + // _ans-badge for the ANS family, TLSA for any HTTPS-endpoint binding). + // The service walker dedupes across styles, so a family's shared + // records emit once even when multiple sibling styles request them. + Records(reg *domain.AgentRegistration) []domain.ExpectedDNSRecord +} + +// DiscoveryRegistry is the lookup surface the service uses to compose +// per-style outputs. Implementations are immutable post-construction so +// reads are safe under concurrent registration / verify-dns load without +// locking. The bundled implementation lives at +// internal/adapter/discovery/registry/registry.go. +type DiscoveryRegistry interface { + // Get returns the style registered under id, or (nil, false) when no + // such style is wired. A miss is informational, not an error: the + // service skips unknown stored styles (e.g. post-decommission rows) + // and logs a WARN, rather than failing the registration. + Get(id domain.DNSRecordStyle) (DiscoveryStyle, bool) + + // IDs returns every registered style's ID in registry-wired + // insertion order. Order is stable across calls and process restarts + // for a given wiring — the service walker iterates IDs() and gates + // each by membership in reg.DNSRecordStyles, so wiring order + // determines emission order on the wire (TL canonical bytes). + IDs() []domain.DNSRecordStyle +} diff --git a/internal/ra/handler/dto.go b/internal/ra/handler/dto.go index f124b9b..d5c8471 100644 --- a/internal/ra/handler/dto.go +++ b/internal/ra/handler/dto.go @@ -87,10 +87,10 @@ type agentDetails struct { Links []linkDTO `json:"links"` } -func mapAgentDetails(res *service.DetailResult, r *http.Request) agentDetails { +func mapAgentDetails(res *service.DetailResult, r *http.Request, svc *service.RegistrationService) agentDetails { reg := res.Registration // Stamp endpoints onto the aggregate so the pending-block builder's - // call to domain.ComputeRequiredDNSRecords produces the full record + // call to svc.ComputeRequiredDNSRecords produces the full record // set (endpoints live in their own table and are returned as a // sibling slice by the service layer). reg.Endpoints = res.Endpoints @@ -104,7 +104,7 @@ func mapAgentDetails(res *service.DetailResult, r *http.Request) agentDetails { AgentStatus: string(reg.Status), Endpoints: mapEndpointsToDTO(res.Endpoints), RegistrationTimestamp: reg.Details.RegistrationTimestamp.Format("2006-01-02T15:04:05Z07:00"), - RegistrationPending: buildRegistrationPendingBlock(reg, r), + RegistrationPending: buildRegistrationPendingBlock(reg, r, svc), Links: []linkDTO{ {Rel: "self", Href: agentURL(r, reg.AgentID)}, }, @@ -119,7 +119,7 @@ func mapAgentDetails(res *service.DetailResult, r *http.Request) agentDetails { // buildV1RegistrationPending. Agents still driving validation/DNS // expose the outstanding challenges + DNS records needed to // progress; terminal states omit the block. -func buildRegistrationPendingBlock(reg *domain.AgentRegistration, r *http.Request) *registrationPendingResponse { +func buildRegistrationPendingBlock(reg *domain.AgentRegistration, r *http.Request, svc *service.RegistrationService) *registrationPendingResponse { switch reg.Status { case domain.StatusPendingValidation: base := schemeOf(r) + "://" + r.Host + "/v2/ans/agents/" + reg.AgentID @@ -150,7 +150,7 @@ func buildRegistrationPendingBlock(reg *domain.AgentRegistration, r *http.Reques } case domain.StatusPendingDNS: base := schemeOf(r) + "://" + r.Host + "/v2/ans/agents/" + reg.AgentID - expected := domain.ComputeRequiredDNSRecords(reg) + expected := svc.ComputeRequiredDNSRecords(reg) dnsRecords := make([]dnsRecordDTO, 0, len(expected)) for _, rec := range expected { dnsRecords = append(dnsRecords, dnsRecordDTO{ diff --git a/internal/ra/handler/lifecycle.go b/internal/ra/handler/lifecycle.go index b345410..e42184d 100644 --- a/internal/ra/handler/lifecycle.go +++ b/internal/ra/handler/lifecycle.go @@ -91,7 +91,7 @@ func (h *LifecycleHandler) Detail(w http.ResponseWriter, r *http.Request) { WriteError(w, err) return } - WriteJSON(w, http.StatusOK, mapAgentDetails(res, r)) + WriteJSON(w, http.StatusOK, mapAgentDetails(res, r, h.svc)) } // ----- GET /v2/ans/agents/{agentId}/certificates/identity ----- diff --git a/internal/ra/handler/lifecycle_test.go b/internal/ra/handler/lifecycle_test.go index e73b23b..71ee272 100644 --- a/internal/ra/handler/lifecycle_test.go +++ b/internal/ra/handler/lifecycle_test.go @@ -774,8 +774,12 @@ func newHandlerFixture(t *testing.T) *handlerFixture { t.Fatal(err) } + discoveryReg, err := service.NewDefaultDiscoveryRegistry() + if err != nil { + t.Fatal(err) + } svc := service.NewRegistrationService( - agents, endpoints, certsStore, byoc, renewals, validator, identityCA, bus, outbox, db, + agents, endpoints, certsStore, byoc, renewals, validator, identityCA, bus, outbox, db, discoveryReg, ).WithSigner(service.EventSigner{ KeyManager: km, KeyID: "ra-signer", diff --git a/internal/ra/handler/v1registration.go b/internal/ra/handler/v1registration.go index ca9c296..cc5e4ab 100644 --- a/internal/ra/handler/v1registration.go +++ b/internal/ra/handler/v1registration.go @@ -246,7 +246,7 @@ func (h *V1RegistrationHandler) Detail(w http.ResponseWriter, r *http.Request) { WriteError(w, err) return } - WriteJSON(w, http.StatusOK, mapV1AgentDetail(res.Registration, res.Endpoints, r)) + WriteJSON(w, http.StatusOK, mapV1AgentDetail(res.Registration, res.Endpoints, r, h.svc)) } // ----- DTO mapping helpers ----- @@ -376,7 +376,7 @@ func rfc3339Zero(t time.Time) string { // Endpoints arrive as a separate slice because the domain aggregate // stores them in their own repository; the service layer gathers // both and hands them in. -func mapV1AgentDetail(reg *domain.AgentRegistration, endpoints []domain.AgentEndpoint, r *http.Request) *v1AgentDetailResponse { +func mapV1AgentDetail(reg *domain.AgentRegistration, endpoints []domain.AgentEndpoint, r *http.Request, svc *service.RegistrationService) *v1AgentDetailResponse { eps := make([]v1EndpointDTO, len(endpoints)) for i, e := range endpoints { fns := make([]v1FunctionDTO, len(e.Functions)) @@ -404,7 +404,7 @@ func mapV1AgentDetail(reg *domain.AgentRegistration, endpoints []domain.AgentEnd lastRenewal = reg.Details.LastRenewalTimestamp.UTC().Format("2006-01-02T15:04:05Z") } // Stamp endpoints onto the aggregate so the buildV1RegistrationPending - // helper's call to domain.ComputeRequiredDNSRecords produces the + // helper's call to svc.ComputeRequiredDNSRecords produces the // full TRUST / BADGE / DISCOVERY / TLSA record set. The service // layer returns endpoints as a sibling slice (they live in their // own table); the pending block builder needs them on the @@ -421,7 +421,7 @@ func mapV1AgentDetail(reg *domain.AgentRegistration, endpoints []domain.AgentEnd Endpoints: eps, RegistrationTimestamp: reg.Details.RegistrationTimestamp.UTC().Format("2006-01-02T15:04:05Z"), LastRenewalTimestamp: lastRenewal, - RegistrationPending: buildV1RegistrationPending(reg, r), + RegistrationPending: buildV1RegistrationPending(reg, r, svc), Links: []v1LinkDTO{ {Rel: "self", Href: base}, }, @@ -444,7 +444,7 @@ func mapV1AgentDetail(reg *domain.AgentRegistration, endpoints []domain.AgentEnd // publish (DISCOVERY/TRUST/BADGE/ // CERTIFICATE_BINDING), VERIFY_DNS nextStep, // expiresAt scaled from the challenge deadline. -func buildV1RegistrationPending(reg *domain.AgentRegistration, r *http.Request) *v1RegistrationPendingResponse { +func buildV1RegistrationPending(reg *domain.AgentRegistration, r *http.Request, svc *service.RegistrationService) *v1RegistrationPendingResponse { switch reg.Status { case domain.StatusPendingValidation: base := schemeOf(r) + "://" + r.Host + "/v1/agents/" + reg.AgentID @@ -474,7 +474,7 @@ func buildV1RegistrationPending(reg *domain.AgentRegistration, r *http.Request) } case domain.StatusPendingDNS: base := schemeOf(r) + "://" + r.Host + "/v1/agents/" + reg.AgentID - expected := domain.ComputeRequiredDNSRecords(reg) + expected := svc.ComputeRequiredDNSRecords(reg) dnsRecords := make([]v1DNSRecordDTO, 0, len(expected)) for _, rec := range expected { dnsRecords = append(dnsRecords, v1DNSRecordDTO{ diff --git a/internal/ra/service/discovery_default.go b/internal/ra/service/discovery_default.go new file mode 100644 index 0000000..016575e --- /dev/null +++ b/internal/ra/service/discovery_default.go @@ -0,0 +1,31 @@ +package service + +import ( + "github.com/godaddy/ans/internal/adapter/discovery/ans" + "github.com/godaddy/ans/internal/adapter/discovery/registry" + "github.com/godaddy/ans/internal/port" +) + +// NewDefaultDiscoveryRegistry returns a registry pre-wired with the +// bundled ANS-family styles (TXTStyle, SVCBStyle) in the canonical +// emission order — TXT first, SVCB second — that the V2 TL canonical +// bytes for the union case were established at. cmd/ans-ra/main.go +// uses it for production wiring; tests across the RA layer use it +// for fixture construction so all paths exercise the same emission +// shape. +// +// Iteration order is the load-bearing part: the service walker +// emits records in registry insertion order, and TL leaves carry +// `dnsRecordsProvisioned[]` byte-for-byte from that ordering. Any +// future production deployment that swaps in a different style set +// MUST construct the registry with TXTStyle and SVCBStyle in this +// same relative order to preserve canonical-bytes parity for +// existing agents. +// +// Errors only when registry.New rejects the wiring (duplicate IDs, +// invalid IDs) — the bundled set passes both checks deterministically, +// but the error return preserves callers' ability to fail loudly on +// startup misconfig per the no-panic-in-request-paths rule. +func NewDefaultDiscoveryRegistry() (port.DiscoveryRegistry, error) { + return registry.New(ans.TXTStyle{}, ans.SVCBStyle{}) +} diff --git a/internal/ra/service/dnsrecords.go b/internal/ra/service/dnsrecords.go new file mode 100644 index 0000000..2b03d84 --- /dev/null +++ b/internal/ra/service/dnsrecords.go @@ -0,0 +1,160 @@ +package service + +import ( + "github.com/rs/zerolog/log" + + "github.com/godaddy/ans/internal/domain" +) + +// ComputeRequiredDNSRecords returns the DNS records the operator must +// publish for reg, composed by walking the discovery registry. The RA +// does not create these records — the operator manages their own DNS; +// the RA only verifies they exist and emits the same set onto the TL +// as `dnsRecordsProvisioned[]`. +// +// Composition rules: +// +// 1. The set of styles to emit is reg.DNSRecordStyles, filtered to +// those the registry actually has wired. Empty after filtering +// (operator omitted dnsRecordStyles, or every entry was unknown +// to the registry) normalizes to domain.DefaultDNSRecordStyles(). +// 2. Iteration order is the registry's insertion order (cmd/main +// wires [TXTStyle, SVCBStyle], so emission proceeds TXT-first +// then SVCB). User-supplied order on reg.DNSRecordStyles has no +// effect — `dnsRecordStyles` is set semantics on the wire. +// 3. Each style's full record list (discovery + family trust records) +// is collected and deduped by (Name, Type, Value). Family trust +// records that overlap across sibling styles in the same family +// (e.g. `_ans-badge` from both ANS_SVCB and ANS_TXT) emit once. +// 4. Records are reordered into discovery-then-trust groupings, +// preserving within-group iteration order. This pins the V2 TL +// `dnsRecordsProvisioned[]` canonical bytes for the union case +// to the historical `[discovery..., badge, TLSA]` shape. +// 5. SVCB rows arrive from the adapter with Required=true. When TXT +// is also resolved, every SVCB row is post-processed to +// Required=false — during the §4.4.2 transition the legacy +// `_ans` TXT family carries the operator's required signal and +// SVCB rides along as optional. +// +// Returns nil when reg has no endpoints AND no server cert (nothing +// meaningful for the operator to publish), matching the pre-refactor +// domain function's empty-input contract. +// +// s.discoveryRegistry is guaranteed non-nil by NewRegistrationService +// (constructor panics on nil), so the walker dereferences it +// unconditionally. +func (s *RegistrationService) ComputeRequiredDNSRecords(reg *domain.AgentRegistration) []domain.ExpectedDNSRecord { + requested := s.resolveRequestedStyles(reg) + + logger := log.Debug(). + Str("agentId", reg.AgentID). + Strs("requestedStyles", styleStrings(reg.DNSRecordStyles)). + Strs("resolvedStyles", styleStrings(setToSlice(requested))) + logger.Msg("computing required DNS records") + + collected, seen := []domain.ExpectedDNSRecord{}, make(map[string]bool) + for _, id := range s.discoveryRegistry.IDs() { + if !requested[id] { + continue + } + style, ok := s.discoveryRegistry.Get(id) + if !ok { + continue + } + emitted := style.Records(reg) + log.Debug(). + Str("agentId", reg.AgentID). + Str("style", string(id)). + Int("emittedCount", len(emitted)). + Msg("style emitted records") + for _, r := range emitted { + key := r.Name + "|" + string(r.Type) + "|" + r.Value + if seen[key] { + continue + } + seen[key] = true + collected = append(collected, r) + } + } + + // Group: discovery records first (in walker order), then trust + // records (badge, TLSA) — preserves the V2 union-case canonical + // bytes shape `[discovery..., badge, TLSA]`. + result := make([]domain.ExpectedDNSRecord, 0, len(collected)) + var trust []domain.ExpectedDNSRecord + for _, r := range collected { + if r.Purpose == domain.PurposeDiscovery { + result = append(result, r) + } else { + trust = append(trust, r) + } + } + result = append(result, trust...) + + // SVCB Required-flag post-process: §4.4.2 says TXT carries the + // required signal during the transition; SVCB stays optional + // alongside. + if requested[domain.DNSRecordStyleTXT] { + for i := range result { + if result[i].Type == domain.DNSRecordSVCB { + result[i].Required = false + } + } + } + + if len(result) == 0 && len(reg.Endpoints) > 0 { + log.Warn(). + Str("agentId", reg.AgentID). + Strs("resolvedStyles", styleStrings(setToSlice(requested))). + Msg("DNS record computation produced no records despite having endpoints; check discovery registry wiring") + } + + return result +} + +// resolveRequestedStyles filters reg.DNSRecordStyles to those the +// registry has wired, normalizing empty/all-invalid to the default +// set. Unknown styles trigger a WARN log so an operator can spot a +// post-decommission row in their data without parsing verify-dns +// failures. +func (s *RegistrationService) resolveRequestedStyles(reg *domain.AgentRegistration) map[domain.DNSRecordStyle]bool { + requested := make(map[domain.DNSRecordStyle]bool) + for _, id := range reg.DNSRecordStyles { + if _, ok := s.discoveryRegistry.Get(id); ok { + requested[id] = true + continue + } + log.Warn(). + Str("agentId", reg.AgentID). + Str("style", string(id)). + Msg("registration carries DNS style unknown to the running registry; skipping") + } + if len(requested) == 0 { + for _, id := range domain.DefaultDNSRecordStyles() { + requested[id] = true + } + } + return requested +} + +func styleStrings(styles []domain.DNSRecordStyle) []string { + out := make([]string, len(styles)) + for i, s := range styles { + out[i] = string(s) + } + return out +} + +// setToSlice converts the requested-set map to a deterministic slice +// for logging. Order tracks domain.ValidDNSRecordStyles() so logs are +// stable across runs. +func setToSlice(set map[domain.DNSRecordStyle]bool) []domain.DNSRecordStyle { + var out []domain.DNSRecordStyle + for _, valid := range domain.ValidDNSRecordStyles() { + id := domain.DNSRecordStyle(valid) + if set[id] { + out = append(out, id) + } + } + return out +} diff --git a/internal/ra/service/dnsrecords_test.go b/internal/ra/service/dnsrecords_test.go new file mode 100644 index 0000000..dcd66dd --- /dev/null +++ b/internal/ra/service/dnsrecords_test.go @@ -0,0 +1,480 @@ +package service_test + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/godaddy/ans/internal/adapter/discovery/registry" + anscrypto "github.com/godaddy/ans/internal/crypto" + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/port" + "github.com/godaddy/ans/internal/ra/service" +) + +// newTestRegistry returns the bundled ANS-family registry every +// service-level test uses. Mirrors cmd/ans-ra/main.go's wiring so +// emission order in tests matches production. +func newTestRegistry(t *testing.T) port.DiscoveryRegistry { + t.Helper() + r, err := service.NewDefaultDiscoveryRegistry() + require.NoError(t, err) + return r +} + +// newComputeOnlyService returns a RegistrationService wired only with +// the discovery registry — sufficient for ComputeRequiredDNSRecords +// tests, which never touch storage / signing / DNS verification. +// Other dependencies are passed nil; the walker is a pure function of +// reg + registry. +func newComputeOnlyService(t *testing.T) *service.RegistrationService { + t.Helper() + return service.NewRegistrationService( + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + newTestRegistry(t), + ) +} + +func mustReg(t *testing.T, host string, version string, eps []domain.AgentEndpoint, capHash string, cert *domain.ByocServerCertificate, styles []domain.DNSRecordStyle) *domain.AgentRegistration { + t.Helper() + v, err := domain.ParseSemVer(version) + require.NoError(t, err) + ansName, err := domain.NewAnsName(v, host) + require.NoError(t, err) + return &domain.AgentRegistration{ + AnsName: ansName, + Endpoints: eps, + CapabilitiesHash: capHash, + ServerCert: cert, + DNSRecordStyles: styles, + } +} + +// TestComputeRequiredDNSRecords_StyleMatrix_Integration is the +// migrated cross-style integration matrix from +// internal/domain/dnsrecords_test.go:105-284. Per-adapter tests cover +// within-style rules; this table is the regression suite for the +// styles cross-product (e.g. "SVCB-sole emits no HTTPS RR" — only +// testable across both adapters' output). +func TestComputeRequiredDNSRecords_StyleMatrix_Integration(t *testing.T) { + const cardHex = "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27" + const wantCardBase64 = "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc" + + tests := []struct { + name string + styles []domain.DNSRecordStyle + protocol domain.Protocol + agentURL string + capabilitiesHash string + wantHTTPS bool + wantSVCB bool + wantSVCBRequired bool // applies only when wantSVCB is true + wantLegacyTXT bool + wantSVCBPort string // substring expected in SVCB value (e.g. "port=443") + wantSVCBWk string // "" means SVCB MUST NOT contain "wk=" + wantSVCBCard string // "" means SVCB MUST NOT contain "card-sha256" + }{ + { + name: "ans_txt_only_emits_https_rr_no_svcb", + styles: []domain.DNSRecordStyle{domain.DNSRecordStyleTXT}, + protocol: domain.ProtocolA2A, + agentURL: "https://agent.example.com", + wantHTTPS: true, + wantLegacyTXT: true, + }, + { + name: "ans_svcb_only_omits_https_rr", + styles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + protocol: domain.ProtocolA2A, + agentURL: "https://agent.example.com", + wantSVCB: true, + wantSVCBRequired: true, // SVCB-sole: only PurposeDiscovery record, must be required + wantSVCBPort: "port=443", + wantSVCBWk: "wk=agent-card.json", + }, + { + name: "union_emits_both_families", + styles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB, domain.DNSRecordStyleTXT}, + protocol: domain.ProtocolA2A, + agentURL: "https://agent.example.com", + wantHTTPS: true, + wantLegacyTXT: true, + wantSVCB: true, + // wantSVCBRequired: false — legacy `_ans` TXT carries the + // Required signal during the §4.4.2 transition; SVCB rides + // along as optional. + wantSVCBPort: "port=443", + wantSVCBWk: "wk=agent-card.json", + }, + { + name: "svcb_mcp_wk_mcp_json", + styles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + protocol: domain.ProtocolMCP, + agentURL: "https://agent.example.com/mcp", + wantSVCB: true, + wantSVCBRequired: true, + wantSVCBPort: "port=443", + wantSVCBWk: "wk=mcp.json", + }, + { + name: "svcb_http_api_omits_wk", + styles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + protocol: domain.ProtocolHTTPAPI, + agentURL: "https://agent.example.com", + wantSVCB: true, + wantSVCBRequired: true, + wantSVCBPort: "port=443", + }, + { + name: "svcb_card_sha256_present_when_set", + styles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + protocol: domain.ProtocolA2A, + agentURL: "https://agent.example.com", + capabilitiesHash: cardHex, + wantSVCB: true, + wantSVCBRequired: true, + wantSVCBPort: "port=443", + wantSVCBWk: "wk=agent-card.json", + wantSVCBCard: "card-sha256=" + wantCardBase64, + }, + { + name: "svcb_non_443_port_from_url", + styles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + protocol: domain.ProtocolA2A, + agentURL: "https://agent.example.com:8443", + wantSVCB: true, + wantSVCBRequired: true, + wantSVCBPort: "port=8443", + wantSVCBWk: "wk=agent-card.json", + }, + { + name: "svcb_http_scheme_defaults_port_80", + styles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + protocol: domain.ProtocolA2A, + agentURL: "http://agent.example.com", + wantSVCB: true, + wantSVCBRequired: true, + wantSVCBPort: "port=80", + wantSVCBWk: "wk=agent-card.json", + }, + { + name: "empty_styles_coerces_to_default", + styles: nil, + protocol: domain.ProtocolA2A, + agentURL: "https://agent.example.com", + wantSVCB: true, + wantSVCBRequired: true, // default ({ANS_SVCB}) is SVCB-sole + wantSVCBPort: "port=443", + wantSVCBWk: "wk=agent-card.json", + }, + { + name: "all_invalid_styles_falls_back_to_default", + styles: []domain.DNSRecordStyle{domain.DNSRecordStyle("garbage"), domain.DNSRecordStyle("nonsense")}, + protocol: domain.ProtocolA2A, + agentURL: "https://agent.example.com", + wantSVCB: true, + wantSVCBRequired: true, // fallback default ({ANS_SVCB}) is SVCB-sole + wantSVCBPort: "port=443", + wantSVCBWk: "wk=agent-card.json", + }, + } + + svc := newComputeOnlyService(t) + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + reg := mustReg(t, "agent.example.com", "1.0.0", + []domain.AgentEndpoint{{Protocol: tc.protocol, AgentURL: tc.agentURL}}, + tc.capabilitiesHash, nil, tc.styles) + + records := svc.ComputeRequiredDNSRecords(reg) + + var sawHTTPS, sawSVCB, sawLegacyTXT bool + var svcbValue string + var svcbRequired bool + for _, r := range records { + switch r.Type { + case domain.DNSRecordHTTPS: + sawHTTPS = true + case domain.DNSRecordSVCB: + sawSVCB = true + svcbValue = r.Value + svcbRequired = r.Required + case domain.DNSRecordTXT: + if strings.HasPrefix(r.Name, "_ans.") { + sawLegacyTXT = true + } + } + } + + assert.Equal(t, tc.wantHTTPS, sawHTTPS, "HTTPS RR presence") + assert.Equal(t, tc.wantSVCB, sawSVCB, "SVCB row presence") + assert.Equal(t, tc.wantLegacyTXT, sawLegacyTXT, "_ans TXT presence") + + if tc.wantSVCB { + assert.Equal(t, tc.wantSVCBRequired, svcbRequired, + "SVCB Required flag mismatch (true iff ANS_SVCB is the sole resolved style)") + assert.Contains(t, svcbValue, tc.wantSVCBPort, + "SVCB port SvcParam mismatch") + if tc.wantSVCBWk != "" { + assert.Contains(t, svcbValue, tc.wantSVCBWk, "SVCB wk SvcParam mismatch") + } else { + assert.NotContains(t, svcbValue, "wk=", + "SVCB MUST NOT carry wk= when protocol has no metadata convention") + } + if tc.wantSVCBCard != "" { + assert.Contains(t, svcbValue, tc.wantSVCBCard, "SVCB card-sha256 SvcParam mismatch") + } else { + assert.NotContains(t, svcbValue, "card-sha256", + "SVCB MUST NOT carry card-sha256 when CapabilitiesHash is empty") + } + } + }) + } +} + +// TestComputeRequiredDNSRecords_UnionDedupesFamilyTrustRecords pins +// that when the union {ANS_SVCB, ANS_TXT} emits, family trust records +// (`_ans-badge`, TLSA) appear ONCE in the output even though both +// adapters emit them. Catches a regression where the dedup pass is +// removed or the dedup key drifts. +func TestComputeRequiredDNSRecords_UnionDedupesFamilyTrustRecords(t *testing.T) { + svc := newComputeOnlyService(t) + reg := mustReg(t, "agent.example.com", "1.0.0", + []domain.AgentEndpoint{{Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}}, + "", &domain.ByocServerCertificate{Fingerprint: "abcdef"}, + []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB, domain.DNSRecordStyleTXT}) + + records := svc.ComputeRequiredDNSRecords(reg) + + var badgeCount, tlsaCount int + for _, r := range records { + if r.Purpose == domain.PurposeBadge { + badgeCount++ + } + if r.Purpose == domain.PurposeCertificateBinding { + tlsaCount++ + } + } + assert.Equal(t, 1, badgeCount, "exactly one `_ans-badge` record across the union") + assert.Equal(t, 1, tlsaCount, "exactly one TLSA record across the union") +} + +// TestComputeRequiredDNSRecords_NoEndpoints pins the empty-input +// contract: no endpoints → no records (ServerCert + nil endpoints +// alone is also covered, but the typical case is the V1/V2 detail +// handler hitting an aggregate that hasn't reached PENDING_DNS yet). +func TestComputeRequiredDNSRecords_NoEndpoints(t *testing.T) { + svc := newComputeOnlyService(t) + reg := mustReg(t, "agent.example.com", "1.0.0", nil, "", nil, nil) + records := svc.ComputeRequiredDNSRecords(reg) + assert.Empty(t, records) +} + +// TestNewRegistrationService_PanicsOnNilDiscoveryRegistry pins the +// fail-loud invariant the constructor enforces. A missing registry +// would silently emit zero `dnsRecordsProvisioned[]` and accept any +// DNS state at verify-dns — trust-root corruption masquerading as +// graceful degradation. Construction is process-start-time, not a +// request path, so the panic does not violate the no-panics-in- +// request-paths rule. +func TestNewRegistrationService_PanicsOnNilDiscoveryRegistry(t *testing.T) { + defer func() { + r := recover() + require.NotNil(t, r, "constructor must panic when discoveryRegistry is nil") + msg, ok := r.(string) + require.True(t, ok, "panic value must be a string explaining the missing dependency") + assert.Contains(t, msg, "discoveryRegistry is required") + }() + _ = service.NewRegistrationService( + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + ) +} + +// TestComputeRequiredDNSRecords_UnknownStyleSkipped pins that a +// reg.DNSRecordStyles entry the registry doesn't have is silently +// skipped (with a WARN log; not asserted in this test). The remaining +// valid styles still emit. If every entry is unknown, the walker +// falls back to DefaultDNSRecordStyles. +func TestComputeRequiredDNSRecords_UnknownStyleSkipped(t *testing.T) { + svc := newComputeOnlyService(t) + reg := mustReg(t, "agent.example.com", "1.0.0", + []domain.AgentEndpoint{{Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}}, + "", nil, + []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB, domain.DNSRecordStyle("UNKNOWN_FUTURE")}) + + records := svc.ComputeRequiredDNSRecords(reg) + + // SVCB is recognized → SVCB-sole emission. UNKNOWN_FUTURE is dropped. + var sawSVCB bool + for _, r := range records { + if r.Type == domain.DNSRecordSVCB { + sawSVCB = true + } + } + assert.True(t, sawSVCB, "valid style alongside unknown-style still emits") +} + +// TestComputeRequiredDNSRecords_UnionCanonicalBytesRegression pins +// the V2 TL `dnsRecordsProvisioned[]` canonical wire for the §4.4.2 +// transition union (ANS_SVCB + ANS_TXT). Any change to slice ORDER +// (JCS preserves array order per RFC 8785 §3.2.2) would shift the +// SHA-256, signal a wire-shape regression, and break offline-verifier +// hashes for in-flight agents at deploy time. +// +// The hex constant was captured from the pre-refactor domain function +// against this exact input. Do NOT regenerate without explicit +// approval — a change here is a wire-format change, not a test fix. +func TestComputeRequiredDNSRecords_UnionCanonicalBytesRegression(t *testing.T) { + const wantSHA256Hex = "20b7c2c90986deb7891e8637e2be0adf439b3050ecad9d07429f0b707ad05875" + + svc := newComputeOnlyService(t) + reg := mustReg(t, "agent.example.com", "1.2.3", + []domain.AgentEndpoint{ + {Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com/a2a"}, + {Protocol: domain.ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, + }, + "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27", + &domain.ByocServerCertificate{Fingerprint: "deadbeefcafe1234"}, + []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB, domain.DNSRecordStyleTXT}) + + records := svc.ComputeRequiredDNSRecords(reg) + + // The expected emission shape (order-preserved) is: + // 1. _ans. TXT (a2a) Required=true + // 2. _ans. TXT (mcp) Required=true + // 3. HTTPS (1 . alpn=h2) Required=false + // 4. SVCB (a2a) Required=false (TXT also resolved) + // 5. SVCB (mcp) Required=false + // 6. _ans-badge. TXT (badge) Required=true + // 7. _443._tcp. TLSA Required=false + require.Len(t, records, 7, "union case must emit exactly 7 records") + assert.Equal(t, "_ans.agent.example.com", records[0].Name) + assert.Equal(t, domain.DNSRecordTXT, records[0].Type) + assert.Equal(t, "_ans.agent.example.com", records[1].Name) + assert.Equal(t, "agent.example.com", records[2].Name) + assert.Equal(t, domain.DNSRecordHTTPS, records[2].Type) + assert.Equal(t, "agent.example.com", records[3].Name) + assert.Equal(t, domain.DNSRecordSVCB, records[3].Type) + assert.False(t, records[3].Required, "SVCB Required=false during transition (TXT carries the required signal)") + assert.Equal(t, "agent.example.com", records[4].Name) + assert.Equal(t, domain.DNSRecordSVCB, records[4].Type) + assert.Equal(t, "_ans-badge.agent.example.com", records[5].Name) + assert.Equal(t, "_443._tcp.agent.example.com", records[6].Name) + assert.Equal(t, domain.DNSRecordTLSA, records[6].Type) + + // SHA-256 over JCS-canonical bytes — pins the exact wire bytes + // the V2 TL leaf will canonicalize. + jsonBytes, err := json.Marshal(records) + require.NoError(t, err) + canonical, err := anscrypto.Canonicalize(jsonBytes) + require.NoError(t, err) + sum := sha256.Sum256(canonical) + gotHex := hex.EncodeToString(sum[:]) + assert.Equal(t, wantSHA256Hex, gotHex, + "V2 union canonical-bytes SHA-256 drifted; investigate before changing the constant") +} + +// TestNewDefaultDiscoveryRegistry pins the default-wiring contract: +// returns a registry containing both ANS-family styles in TXT-then-SVCB +// insertion order. The order is the V2 canonical-bytes input. +func TestNewDefaultDiscoveryRegistry(t *testing.T) { + r, err := service.NewDefaultDiscoveryRegistry() + require.NoError(t, err) + + got := r.IDs() + want := []domain.DNSRecordStyle{domain.DNSRecordStyleTXT, domain.DNSRecordStyleSVCB} + assert.Equal(t, want, got, "default registry must wire TXT before SVCB to preserve V2 union canonical bytes") +} + +// TestComputeRequiredDNSRecords_RegistryIterationOrderDeterminesEmission +// pins that a non-default registry wiring (SVCB before TXT) actually +// produces a different emission order — proving the walker honours +// registry insertion order rather than user-supplied +// reg.DNSRecordStyles order. +func TestComputeRequiredDNSRecords_RegistryIterationOrderDeterminesEmission(t *testing.T) { + // Build a "production" service (default registry wiring: TXT, SVCB) + // and a custom one with SVCB before TXT. + defaultSvc := newComputeOnlyService(t) + + customReg, err := registry.New(svcStub{id: domain.DNSRecordStyleSVCB, marker: "S"}, svcStub{id: domain.DNSRecordStyleTXT, marker: "T"}) + require.NoError(t, err) + customSvc := service.NewRegistrationService( + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, customReg) + + reg := mustReg(t, "agent.example.com", "1.0.0", + []domain.AgentEndpoint{{Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}}, + "", nil, + []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB, domain.DNSRecordStyleTXT}) + + defaultOut := defaultSvc.ComputeRequiredDNSRecords(reg) + customOut := customSvc.ComputeRequiredDNSRecords(reg) + + // Default: TXT first → first record is the `_ans` TXT. + require.NotEmpty(t, defaultOut) + assert.Equal(t, "_ans.agent.example.com", defaultOut[0].Name, + "default registry wires TXT first; first emitted record is `_ans` TXT") + + // Custom: SVCB stub (marker=S) first, no records (stub returns + // empty slice). Then TXT stub (marker=T), also empty. So custom + // out is empty — pinning that the custom registry was actually + // consulted (without the registry, the walker would fall back to + // the default and produce non-empty records). + assert.Empty(t, customOut, "stub registry produces no records; default fallback is gated by registry presence, not adapter output") +} + +// svcStub is a minimal port.DiscoveryStyle for ordering tests; emits +// no records so the test asserts purely on walker behavior. +type svcStub struct { + id domain.DNSRecordStyle + marker string +} + +func (s svcStub) ID() domain.DNSRecordStyle { return s.id } +func (svcStub) Records(*domain.AgentRegistration) []domain.ExpectedDNSRecord { + return nil +} + +// inconsistentRegistry violates the IDs()/Get consistency contract: +// IDs() advertises a style that Get() does not have. The walker's +// defensive `if !ok { continue }` branch is the safety net for that +// contract violation. The bundled registry maintains the contract by +// construction, so this fake exercises a branch only a custom +// port.DiscoveryRegistry implementation could ever reach. +type inconsistentRegistry struct{} + +func (inconsistentRegistry) IDs() []domain.DNSRecordStyle { + return []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB} +} + +func (inconsistentRegistry) Get(domain.DNSRecordStyle) (port.DiscoveryStyle, bool) { + return nil, false +} + +// TestComputeRequiredDNSRecords_RegistryGetMissDoesNotPanic pins the +// defensive branch the walker takes when registry.IDs() and Get fall +// out of sync. The branch is unreachable in production wiring; it +// exists so a future custom port.DiscoveryRegistry implementation +// (e.g. one that hot-reloads styles and races between IDs() and Get) +// degrades to "skip the missing ID" instead of nil-dereferencing the +// returned style. +func TestComputeRequiredDNSRecords_RegistryGetMissDoesNotPanic(t *testing.T) { + svc := service.NewRegistrationService( + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + inconsistentRegistry{}) + reg := mustReg(t, "agent.example.com", "1.0.0", + []domain.AgentEndpoint{{Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}}, + "", nil, + []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}) + + // IDs() returns SVCB; Get returns (nil, false). Walker must + // continue without dereferencing style. Result: empty record set + // since the walker has nothing to emit. No panic. + records := svc.ComputeRequiredDNSRecords(reg) + assert.Empty(t, records) +} diff --git a/internal/ra/service/lifecycle.go b/internal/ra/service/lifecycle.go index 10ed8da..7471e5a 100644 --- a/internal/ra/service/lifecycle.go +++ b/internal/ra/service/lifecycle.go @@ -456,7 +456,7 @@ type DNSMismatch struct { } // VerifyDNS checks the operator's authoritative nameserver for the -// required records (computed by domain.ComputeRequiredDNSRecords) and +// required records (computed by s.ComputeRequiredDNSRecords) and // advances the registration to ACTIVE on success. // // On success, emits an AGENT_ACTIVE event whose attestations carry @@ -502,7 +502,7 @@ func (s *RegistrationService) VerifyDNS(ctx context.Context, agentID string, in reg.ServerCert = byoc } - expected := domain.ComputeRequiredDNSRecords(reg) + expected := s.ComputeRequiredDNSRecords(reg) mismatches, perRecord, err := s.verifyDNSRecords(ctx, reg.FQDN(), expected) if err != nil { @@ -809,7 +809,7 @@ func (s *RegistrationService) Revoke(ctx context.Context, agentID string, in Rev return &RevokeResult{ Registration: reg, RevokedAt: now, - DNSRecordsToRemove: domain.ComputeRequiredDNSRecords(reg), + DNSRecordsToRemove: s.ComputeRequiredDNSRecords(reg), }, nil } @@ -923,6 +923,6 @@ func (s *RegistrationService) Revoke(ctx context.Context, agentID string, in Rev return &RevokeResult{ Registration: reg, RevokedAt: now, - DNSRecordsToRemove: domain.ComputeRequiredDNSRecords(reg), + DNSRecordsToRemove: s.ComputeRequiredDNSRecords(reg), }, nil } diff --git a/internal/ra/service/registration.go b/internal/ra/service/registration.go index 429c6e3..aba9486 100644 --- a/internal/ra/service/registration.go +++ b/internal/ra/service/registration.go @@ -129,18 +129,19 @@ type OutboxPayload struct { // sqlx.Tx, cloud adapters can use TransactWriteItems-style atomic // batches. type RegistrationService struct { - agents port.AgentStore - endpoints port.EndpointStore - certs port.CertificateStore - byoc port.ByocCertificateStore - renewals port.RenewalStore - validator port.CertificateValidator - identityCA port.IdentityCertificateAuthority - serverCA port.ServerCertificateAuthority // optional; nil = CSR path rejected - bus port.EventBus - outbox OutboxEnqueuer - uow port.UnitOfWork - dnsVerifier port.DNSVerifier + agents port.AgentStore + endpoints port.EndpointStore + certs port.CertificateStore + byoc port.ByocCertificateStore + renewals port.RenewalStore + validator port.CertificateValidator + identityCA port.IdentityCertificateAuthority + serverCA port.ServerCertificateAuthority // optional; nil = CSR path rejected + bus port.EventBus + outbox OutboxEnqueuer + uow port.UnitOfWork + dnsVerifier port.DNSVerifier + discoveryRegistry port.DiscoveryRegistry // signer is the KeyManager + keyID + raID tuple used to sign // outbox events. When nil, events are still persisted but without // a signature — this is only valid for tests; production configs @@ -159,6 +160,18 @@ type EventSigner struct { // NewRegistrationService constructs a RegistrationService. Dependencies // are injected per SOLID; tests substitute fakes. +// +// discoveryRegistry is required at construction and the constructor +// panics on nil — a missing registry would silently emit zero +// `dnsRecordsProvisioned[]` and accept any DNS state at verify-dns +// (trust-root corruption masquerading as graceful degradation), so +// fail-loud at construction is the only correct policy. Construction +// runs at process start, never on a request path, so the no-panics-in- +// request-paths rule (CLAUDE.md) is upheld. Production builds wire the +// bundled ANS-family registry in cmd/ans-ra/main.go via +// registry.New(ans.TXTStyle{}, ans.SVCBStyle{}); tests build the same +// registry through service.NewDefaultDiscoveryRegistry. There is no +// optional builder. func NewRegistrationService( agents port.AgentStore, endpoints port.EndpointStore, @@ -170,19 +183,24 @@ func NewRegistrationService( bus port.EventBus, outbox OutboxEnqueuer, uow port.UnitOfWork, + discoveryRegistry port.DiscoveryRegistry, ) *RegistrationService { + if discoveryRegistry == nil { + panic("service.NewRegistrationService: discoveryRegistry is required (nil interface — wire registry.New(...) at construction)") + } return &RegistrationService{ - agents: agents, - endpoints: endpoints, - certs: certs, - byoc: byoc, - renewals: renewals, - validator: validator, - identityCA: identityCA, - bus: bus, - outbox: outbox, - uow: uow, - clock: time.Now, + agents: agents, + endpoints: endpoints, + certs: certs, + byoc: byoc, + renewals: renewals, + validator: validator, + identityCA: identityCA, + bus: bus, + outbox: outbox, + uow: uow, + discoveryRegistry: discoveryRegistry, + clock: time.Now, } } diff --git a/internal/ra/service/registration_test.go b/internal/ra/service/registration_test.go index 5eadb75..c31fed6 100644 --- a/internal/ra/service/registration_test.go +++ b/internal/ra/service/registration_test.go @@ -68,6 +68,7 @@ func TestRegistration_NoSigner(t *testing.T) { svcNoSig := service.NewRegistrationService( fx.agents, fx.endpoints, fx.certs, fx.byoc, fx.renewals, fx.validator, fx.identityCA, fx.bus, fx.outboxStore, fx.uow, + fx.discoveryReg, ).WithServerCertificateAuthority(fx.serverCA) // Use a fresh ANS name + matching CSR + matching endpoints so @@ -124,6 +125,7 @@ func TestRegistration_RollsBackOnPartialFailure(t *testing.T) { svc := service.NewRegistrationService( fx.agents, failingEndpoints, fx.certs, fx.byoc, fx.renewals, fx.validator, fx.identityCA, fx.bus, fx.outboxStore, fx.uow, + fx.discoveryReg, ).WithServerCertificateAuthority(fx.serverCA) if _, err := svc.RegisterAgent(context.Background(), fx.req); err == nil { @@ -198,6 +200,7 @@ func TestRevoke_RollsBackOnOutboxFailure(t *testing.T) { svc := service.NewRegistrationService( fx.agents, fx.endpoints, fx.certs, fx.byoc, fx.renewals, fx.validator, fx.identityCA, fx.bus, &failingOutbox{}, fx.uow, + fx.discoveryReg, ).WithServerCertificateAuthority(fx.serverCA) if _, err := svc.Revoke(context.Background(), agentID, service.RevokeInput{ @@ -266,6 +269,7 @@ type regFixture struct { identityCA port.IdentityCertificateAuthority serverCA port.ServerCertificateAuthority bus port.EventBus + discoveryReg port.DiscoveryRegistry signerPubPEM string } @@ -321,8 +325,13 @@ func newRegFixture(t *testing.T) *regFixture { t.Fatal(err) } + discoveryReg, err := service.NewDefaultDiscoveryRegistry() + if err != nil { + t.Fatal(err) + } + svc := service.NewRegistrationService( - agents, endpoints, certsStore, byoc, renewals, validator, identityCA, bus, outbox, db, + agents, endpoints, certsStore, byoc, renewals, validator, identityCA, bus, outbox, db, discoveryReg, ).WithSigner(service.EventSigner{ KeyManager: km, KeyID: "ra-signer", @@ -349,6 +358,7 @@ func newRegFixture(t *testing.T) *regFixture { identityCA: identityCA, serverCA: serverCA, bus: bus, + discoveryReg: discoveryReg, signerPubPEM: pubPEM, req: service.RegisterRequest{ OwnerID: "owner-1", diff --git a/internal/ra/service/v1event.go b/internal/ra/service/v1event.go index 7b8ba00..d42e95d 100644 --- a/internal/ra/service/v1event.go +++ b/internal/ra/service/v1event.go @@ -265,7 +265,7 @@ func (s *RegistrationService) buildAgentRevokedV1Event( // sees the full record set (including per-endpoint metadata // records). If it didn't, we'd get back an empty list and the // revoke envelope would ship with no DNS tear-down guidance. - expected := domain.ComputeRequiredDNSRecords(reg) + expected := s.ComputeRequiredDNSRecords(reg) dnsMap := make(map[string]string, len(expected)) for _, r := range expected { dnsMap[r.Name] = r.Value From 2bedb6235d2ec0facec5489bc8a7905a4dd18888 Mon Sep 17 00:00:00 2001 From: kperry Date: Tue, 26 May 2026 15:09:20 -0500 Subject: [PATCH 11/13] feat(discovery): integrate default discovery registry and update DNS record computation --- internal/adapter/discovery/ans/svcb.go | 42 ++++++----- internal/adapter/discovery/ans/svcb_test.go | 84 +++++++++++---------- internal/adapter/discovery/ans/txt_test.go | 8 +- internal/adapter/dns/dns_test.go | 16 ++-- internal/adapter/docsui/openapi/ra.yaml | 6 +- internal/domain/agent.go | 8 -- internal/domain/dnsrecords.go | 3 +- internal/ra/service/dnsrecords_test.go | 64 +++++++++------- internal/ra/service/lifecycle.go | 5 +- spec/api-spec-v2.yaml | 6 +- 10 files changed, 129 insertions(+), 113 deletions(-) diff --git a/internal/adapter/discovery/ans/svcb.go b/internal/adapter/discovery/ans/svcb.go index dc64e72..7333a54 100644 --- a/internal/adapter/discovery/ans/svcb.go +++ b/internal/adapter/discovery/ans/svcb.go @@ -6,6 +6,7 @@ import ( "fmt" "net/url" "strconv" + "strings" "github.com/godaddy/ans/internal/domain" "github.com/godaddy/ans/internal/port" @@ -32,9 +33,11 @@ import ( // 80 (http) when the URL omits a port. // - wk: well-known suffix per protocol (agent-card.json for A2A, // mcp.json for MCP, omitted for HTTP-API). -// - card-sha256: base64url(reg.CapabilitiesHash) when set; absent -// otherwise, in which case verifiers fall back to TOFU on first -// Trust Card fetch. +// - card-sha256: base64url(raw SHA-256 bytes) sourced from this +// endpoint's MetadataHash when the operator submitted one. +// Absent when MetadataHash is empty or malformed; in that case +// verifiers fetch the metadata document and accept any payload +// whose URL matches. // // `wk` and `card-sha256` are not yet IANA-registered SvcParamKeys; // see the consolidated-draft §6 note for the keyNNNNN-form fallback @@ -48,16 +51,16 @@ func (SVCBStyle) ID() domain.DNSRecordStyle { return domain.DNSRecordStyleSVCB } // needs an operator to publish. func (s SVCBStyle) Records(reg *domain.AgentRegistration) []domain.ExpectedDNSRecord { fqdn := reg.FQDN() - cardSHA := capabilitiesHashBase64URL(reg.CapabilitiesHash) records := make([]domain.ExpectedDNSRecord, 0, len(reg.Endpoints)+2) for _, ep := range reg.Endpoints { alpn := protocolToANSValue(ep.Protocol) wk := wkPathFor(ep.Protocol) port := svcbPortFor(ep.AgentURL) + cardSHA := metadataHashToCardSHA256(ep.MetadataHash) // RFC 9460 §2.1 presentation form: unquoted SvcParamValue when the // value has no characters special to the presentation format. - // alpn tokens, port digits, well-known path suffixes, and base64url - // digests all qualify. + // alpn tokens, port digits, well-known path suffixes, and + // base64url digests all qualify. value := fmt.Sprintf(`1 . alpn=%s port=%d`, alpn, port) if wk != "" { value += fmt.Sprintf(` wk=%s`, wk) @@ -111,21 +114,24 @@ func svcbPortFor(agentURL string) int { return 443 } -// capabilitiesHashBase64URL re-encodes a hex-lowercase SHA-256 digest -// (the form `AgentRegistration.CapabilitiesHash` carries) into the -// base64url form (RFC 4648 §5, no padding) the SVCB `card-sha256` -// SvcParam expects. Empty input returns empty output, which the caller -// treats as "omit the SvcParam entirely" — agents registered without -// `agentCardContent` have no committed value to publish. -func capabilitiesHashBase64URL(hexDigest string) string { - if hexDigest == "" { +// metadataHashToCardSHA256 converts an AgentEndpoint.MetadataHash +// (`SHA256:<64-hex-chars>`) into the base64url form (RFC 4648 §5, +// no padding) the SVCB `card-sha256` SvcParam expects. Empty input, +// missing prefix, or malformed hex all return the empty string, +// which the caller treats as "omit the SvcParam entirely". The +// domain layer (endpoint.go's metadataHashPattern) validates the +// canonical shape on input, so the defensive returns here exist +// for boundary safety only. +func metadataHashToCardSHA256(metadataHash string) string { + if metadataHash == "" { return "" } - raw, err := hex.DecodeString(hexDigest) + const prefix = "SHA256:" + if !strings.HasPrefix(metadataHash, prefix) { + return "" + } + raw, err := hex.DecodeString(strings.TrimPrefix(metadataHash, prefix)) if err != nil || len(raw) == 0 { - // Malformed hex is logically equivalent to absence; the RA - // stores well-formed hex by construction (helpers.go: - // hashAgentCardContent), but defensive on the boundary. return "" } return base64.RawURLEncoding.EncodeToString(raw) diff --git a/internal/adapter/discovery/ans/svcb_test.go b/internal/adapter/discovery/ans/svcb_test.go index aebfc9a..a2b2cb3 100644 --- a/internal/adapter/discovery/ans/svcb_test.go +++ b/internal/adapter/discovery/ans/svcb_test.go @@ -10,17 +10,16 @@ import ( "github.com/godaddy/ans/internal/domain" ) -func mustReg(t *testing.T, host string, eps []domain.AgentEndpoint, capHash string, cert *domain.ByocServerCertificate) *domain.AgentRegistration { +func mustReg(t *testing.T, host string, eps []domain.AgentEndpoint, cert *domain.ByocServerCertificate) *domain.AgentRegistration { t.Helper() v, err := domain.NewSemVer(1, 0, 0) require.NoError(t, err) ansName, err := domain.NewAnsName(v, host) require.NoError(t, err) return &domain.AgentRegistration{ - AnsName: ansName, - Endpoints: eps, - CapabilitiesHash: capHash, - ServerCert: cert, + AnsName: ansName, + Endpoints: eps, + ServerCert: cert, } } @@ -32,13 +31,12 @@ func TestSVCBStyle_ID(t *testing.T) { // port / wk / card-sha256) the consolidated-draft fixes, plus the // always-Required default the service walker post-processes. func TestSVCBStyle_Records(t *testing.T) { - const cardHex = "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27" - const wantCardBase64 = "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc" + const sampleMetadataHash = "SHA256:098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27" + const wantSampleCardBase64 = "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc" tests := []struct { name string eps []domain.AgentEndpoint - capHash string wantCount int // svcb rows expected wantPort string wantAlpn string @@ -77,16 +75,19 @@ func TestSVCBStyle_Records(t *testing.T) { wantWk: "", // HTTP-API has no per-protocol metadata file }, { - name: "card_sha256_present_when_capabilities_hash_set", + name: "card_sha256_present_when_endpoint_metadata_hash_set", eps: []domain.AgentEndpoint{ - {Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}, + { + Protocol: domain.ProtocolA2A, + AgentURL: "https://agent.example.com", + MetadataHash: sampleMetadataHash, + }, }, - capHash: cardHex, wantCount: 1, wantPort: "port=443", wantAlpn: "alpn=a2a", wantWk: "wk=agent-card.json", - wantCard: "card-sha256=" + wantCardBase64, + wantCard: "card-sha256=" + wantSampleCardBase64, }, { name: "non_443_port_from_url_authority", @@ -134,7 +135,7 @@ func TestSVCBStyle_Records(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - reg := mustReg(t, "agent.example.com", tc.eps, tc.capHash, nil) + reg := mustReg(t, "agent.example.com", tc.eps, nil) records := SVCBStyle{}.Records(reg) var svcbRows []domain.ExpectedDNSRecord @@ -172,7 +173,8 @@ func TestSVCBStyle_Records(t *testing.T) { if tc.wantCard != "" { assert.Contains(t, r.Value, tc.wantCard) } else { - assert.NotContains(t, r.Value, "card-sha256", "card-sha256 MUST be absent when CapabilitiesHash is empty") + assert.NotContains(t, r.Value, "card-sha256", + "card-sha256 MUST be absent when endpoint MetadataHash is empty") } }) } @@ -185,7 +187,7 @@ func TestSVCBStyle_Records(t *testing.T) { func TestSVCBStyle_RecordsIncludesFamilyTrustRecords(t *testing.T) { reg := mustReg(t, "agent.example.com", []domain.AgentEndpoint{{Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}}, - "", &domain.ByocServerCertificate{Fingerprint: "deadbeef"}) + &domain.ByocServerCertificate{Fingerprint: "deadbeef"}) records := SVCBStyle{}.Records(reg) @@ -204,49 +206,51 @@ func TestSVCBStyle_RecordsIncludesFamilyTrustRecords(t *testing.T) { assert.True(t, sawTLSA, "SVCB style must include the TLSA record when ServerCert is set") } -func TestSVCBPortFor(t *testing.T) { +func TestMetadataHashToCardSHA256(t *testing.T) { tests := []struct { name string in string - want int + want string }{ - {name: "https_default_443", in: "https://agent.example.com", want: 443}, - {name: "http_default_80", in: "http://agent.example.com", want: 80}, - {name: "explicit_port_8443", in: "https://agent.example.com:8443", want: 8443}, - {name: "explicit_port_8080_http", in: "http://agent.example.com:8080", want: 8080}, - {name: "with_path_keeps_port", in: "https://agent.example.com:9443/a2a", want: 9443}, - {name: "empty_url_defaults_443", in: "", want: 443}, - {name: "malformed_url_defaults_443", in: "://not-a-url", want: 443}, + { + name: "valid_sha256_prefixed_hex_lowers_to_base64url", + in: "SHA256:098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27", + want: "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc", + }, + { + name: "all_zeros_round_trip", + in: "SHA256:0000000000000000000000000000000000000000000000000000000000000000", + want: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }, + {name: "empty_input_empty_output", in: "", want: ""}, + {name: "missing_sha256_prefix_returns_empty", in: "098d650cc6d2", want: ""}, + {name: "wrong_prefix_returns_empty", in: "SHA1:abc", want: ""}, + {name: "malformed_hex_after_prefix_returns_empty", in: "SHA256:not hex", want: ""}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.want, svcbPortFor(tc.in)) + assert.Equal(t, tc.want, metadataHashToCardSHA256(tc.in)) }) } } -func TestCapabilitiesHashBase64URL(t *testing.T) { +func TestSVCBPortFor(t *testing.T) { tests := []struct { name string in string - want string + want int }{ - { - name: "live_webmesh_trust_card_digest", - in: "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27", - want: "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc", - }, - { - name: "all_zeros", - in: "0000000000000000000000000000000000000000000000000000000000000000", - want: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - }, - {name: "empty_input_empty_output", in: "", want: ""}, - {name: "malformed_hex_returns_empty", in: "not hex", want: ""}, + {name: "https_default_443", in: "https://agent.example.com", want: 443}, + {name: "http_default_80", in: "http://agent.example.com", want: 80}, + {name: "explicit_port_8443", in: "https://agent.example.com:8443", want: 8443}, + {name: "explicit_port_8080_http", in: "http://agent.example.com:8080", want: 8080}, + {name: "with_path_keeps_port", in: "https://agent.example.com:9443/a2a", want: 9443}, + {name: "empty_url_defaults_443", in: "", want: 443}, + {name: "malformed_url_defaults_443", in: "://not-a-url", want: 443}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.want, capabilitiesHashBase64URL(tc.in)) + assert.Equal(t, tc.want, svcbPortFor(tc.in)) }) } } diff --git a/internal/adapter/discovery/ans/txt_test.go b/internal/adapter/discovery/ans/txt_test.go index 8c63820..414572e 100644 --- a/internal/adapter/discovery/ans/txt_test.go +++ b/internal/adapter/discovery/ans/txt_test.go @@ -64,7 +64,7 @@ func TestTXTStyle_Records(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - reg := mustReg(t, "agent.example.com", tc.eps, "", nil) + reg := mustReg(t, "agent.example.com", tc.eps, nil) records := TXTStyle{}.Records(reg) var txtRows int @@ -110,7 +110,7 @@ func TestTXTStyle_Records(t *testing.T) { func TestTXTStyle_RecordsIncludesFamilyTrustRecords(t *testing.T) { reg := mustReg(t, "agent.example.com", []domain.AgentEndpoint{{Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}}, - "", &domain.ByocServerCertificate{Fingerprint: "deadbeef"}) + &domain.ByocServerCertificate{Fingerprint: "deadbeef"}) records := TXTStyle{}.Records(reg) @@ -132,7 +132,7 @@ func TestTXTStyle_RecordsIncludesFamilyTrustRecords(t *testing.T) { // empty record set when ServerCert is also nil. Zero endpoints + nil // cert means there's nothing meaningful to publish. func TestTXTStyle_NoEndpointsSkipsAllFamilyAndDiscoveryRecords(t *testing.T) { - reg := mustReg(t, "agent.example.com", nil, "", nil) + reg := mustReg(t, "agent.example.com", nil, nil) records := TXTStyle{}.Records(reg) require.Empty(t, records) } @@ -141,7 +141,7 @@ func TestTXTStyle_NoEndpointsSkipsAllFamilyAndDiscoveryRecords(t *testing.T) { // zero endpoints, a registration that has a server cert still gets the // TLSA record. (The badge requires endpoints; TLSA does not.) func TestTXTStyle_ZeroEndpointsWithCertOnlyEmitsTLSA(t *testing.T) { - reg := mustReg(t, "agent.example.com", nil, "", + reg := mustReg(t, "agent.example.com", nil, &domain.ByocServerCertificate{Fingerprint: "abcd"}) records := TXTStyle{}.Records(reg) require.Len(t, records, 1) diff --git a/internal/adapter/dns/dns_test.go b/internal/adapter/dns/dns_test.go index 24f289e..9767caf 100644 --- a/internal/adapter/dns/dns_test.go +++ b/internal/adapter/dns/dns_test.go @@ -259,11 +259,12 @@ func TestLookupVerifier_HTTPSMatch(t *testing.T) { // // Restricted to IANA-registered SvcParamKeys (alpn + port) because the // miekg/dns zone-file parser used by the test fixture rejects symbolic -// names for the still-provisional Consolidated Approach SvcParams (`wk`, -// `card-sha256`, `cap`, etc.). Until those keys are IANA-registered per -// RFC 9460 §6, the verifier-side test exercises the dispatch and +// names for the still-provisional Consolidated Approach SvcParams +// (`wk`, `card-sha256`, etc.). Until those keys are IANA-registered +// per RFC 9460 §6, the verifier-side test exercises the dispatch and // matching path with registered keys; the unregistered keys are -// unit-tested at the domain layer (internal/domain/dnsrecords_test.go). +// unit-tested at the adapter layer +// (internal/adapter/discovery/ans/svcb_test.go). func TestLookupVerifier_SVCB(t *testing.T) { tests := []struct { name string @@ -379,9 +380,10 @@ func TestLookupVerifier_HTTPS_DNSSECFlagPropagates(t *testing.T) { } // TestLookupVerifier_SVCB_DNSSECFlagPropagates is the SVCB-side -// counterpart to the HTTPS test above. SVCB carries the security- -// bearing card-sha256 SvcParam (when the RA committed one), so the AD -// bit is load-bearing for the lifecycle SVCB_DNSSEC_MISMATCH rule. +// counterpart to the HTTPS test above. SVCB rows carry per-protocol +// service-binding parameters and the security-bearing card-sha256 +// SvcParam (when the endpoint has a MetadataHash), so the AD bit is +// load-bearing for the lifecycle SVCB_DNSSEC_MISMATCH rule. func TestLookupVerifier_SVCB_DNSSECFlagPropagates(t *testing.T) { t.Parallel() s := newTestServer(t) diff --git a/internal/adapter/docsui/openapi/ra.yaml b/internal/adapter/docsui/openapi/ra.yaml index 8b80e52..13b38d7 100644 --- a/internal/adapter/docsui/openapi/ra.yaml +++ b/internal/adapter/docsui/openapi/ra.yaml @@ -1031,9 +1031,9 @@ components: registration. Used as the element type of dnsRecordStyles[]. - ANS_SVCB: Consolidated Approach SVCB rows at the bare FQDN - per RFC 9460. One row per protocol carrying alpn, port, and - capability-locator SvcParams (wk, card-sha256). The - recommended default for new integrations. + per RFC 9460. One row per protocol carrying alpn, port, wk, + and (when the endpoint has a metadataHash) card-sha256 + SvcParams. The recommended default for new integrations. - ANS_TXT: original `_ans` TXT shape (one row per protocol), supported indefinitely for operators with existing zone-edit tooling that targets `_ans.{fqdn}`. Emits an HTTPS RR at diff --git a/internal/domain/agent.go b/internal/domain/agent.go index 6149609..46f414e 100644 --- a/internal/domain/agent.go +++ b/internal/domain/agent.go @@ -103,14 +103,6 @@ type AgentRegistration struct { // deviation. ACMEChallenge ACMEChallenge `json:"acmeChallenge,omitzero"` - // CapabilitiesHash is the hex-lowercase SHA-256 digest of the - // operator's submitted Trust Card body. SVCB record emission - // surfaces it as the `card-sha256=` SvcParam (base64url-encoded) - // when non-empty; empty leaves the SvcParam absent and verifiers - // fall back to TOFU. PR13 carries the read-side surface; the - // populator path is intentionally not wired in this PR. - CapabilitiesHash string `json:"capabilitiesHash,omitempty"` - // DNSRecordStyles is the set of DNS record families the RA emits // for this registration. Each value names one family — typically // {ANS_SVCB} (Consolidated Approach), {ANS_TXT} (original `_ans` diff --git a/internal/domain/dnsrecords.go b/internal/domain/dnsrecords.go index 47bc211..4453f13 100644 --- a/internal/domain/dnsrecords.go +++ b/internal/domain/dnsrecords.go @@ -16,7 +16,8 @@ type DNSRecordStyle string const ( // DNSRecordStyleSVCB emits Consolidated Approach SVCB records per // RFC 9460 — one row per protocol at the bare FQDN, carrying alpn, - // port, wk, and card-sha256 SvcParams. + // port, wk, and (when the endpoint has a MetadataHash) card-sha256 + // SvcParams. DNSRecordStyleSVCB DNSRecordStyle = "ANS_SVCB" // DNSRecordStyleTXT emits the original `_ans` TXT shape — one row diff --git a/internal/ra/service/dnsrecords_test.go b/internal/ra/service/dnsrecords_test.go index dcd66dd..b1a3ce1 100644 --- a/internal/ra/service/dnsrecords_test.go +++ b/internal/ra/service/dnsrecords_test.go @@ -40,18 +40,17 @@ func newComputeOnlyService(t *testing.T) *service.RegistrationService { ) } -func mustReg(t *testing.T, host string, version string, eps []domain.AgentEndpoint, capHash string, cert *domain.ByocServerCertificate, styles []domain.DNSRecordStyle) *domain.AgentRegistration { +func mustReg(t *testing.T, host string, version string, eps []domain.AgentEndpoint, cert *domain.ByocServerCertificate, styles []domain.DNSRecordStyle) *domain.AgentRegistration { t.Helper() v, err := domain.ParseSemVer(version) require.NoError(t, err) ansName, err := domain.NewAnsName(v, host) require.NoError(t, err) return &domain.AgentRegistration{ - AnsName: ansName, - Endpoints: eps, - CapabilitiesHash: capHash, - ServerCert: cert, - DNSRecordStyles: styles, + AnsName: ansName, + Endpoints: eps, + ServerCert: cert, + DNSRecordStyles: styles, } } @@ -62,15 +61,15 @@ func mustReg(t *testing.T, host string, version string, eps []domain.AgentEndpoi // styles cross-product (e.g. "SVCB-sole emits no HTTPS RR" — only // testable across both adapters' output). func TestComputeRequiredDNSRecords_StyleMatrix_Integration(t *testing.T) { - const cardHex = "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27" - const wantCardBase64 = "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc" + const sampleMetadataHash = "SHA256:098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27" + const wantSampleCardBase64 = "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc" tests := []struct { name string styles []domain.DNSRecordStyle protocol domain.Protocol agentURL string - capabilitiesHash string + metadataHash string // optional per-endpoint MetadataHash wantHTTPS bool wantSVCB bool wantSVCBRequired bool // applies only when wantSVCB is true @@ -131,16 +130,16 @@ func TestComputeRequiredDNSRecords_StyleMatrix_Integration(t *testing.T) { wantSVCBPort: "port=443", }, { - name: "svcb_card_sha256_present_when_set", + name: "svcb_card_sha256_from_endpoint_metadata_hash", styles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, protocol: domain.ProtocolA2A, agentURL: "https://agent.example.com", - capabilitiesHash: cardHex, + metadataHash: sampleMetadataHash, wantSVCB: true, wantSVCBRequired: true, wantSVCBPort: "port=443", wantSVCBWk: "wk=agent-card.json", - wantSVCBCard: "card-sha256=" + wantCardBase64, + wantSVCBCard: "card-sha256=" + wantSampleCardBase64, }, { name: "svcb_non_443_port_from_url", @@ -189,8 +188,12 @@ func TestComputeRequiredDNSRecords_StyleMatrix_Integration(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { reg := mustReg(t, "agent.example.com", "1.0.0", - []domain.AgentEndpoint{{Protocol: tc.protocol, AgentURL: tc.agentURL}}, - tc.capabilitiesHash, nil, tc.styles) + []domain.AgentEndpoint{{ + Protocol: tc.protocol, + AgentURL: tc.agentURL, + MetadataHash: tc.metadataHash, + }}, + nil, tc.styles) records := svc.ComputeRequiredDNSRecords(reg) @@ -231,7 +234,7 @@ func TestComputeRequiredDNSRecords_StyleMatrix_Integration(t *testing.T) { assert.Contains(t, svcbValue, tc.wantSVCBCard, "SVCB card-sha256 SvcParam mismatch") } else { assert.NotContains(t, svcbValue, "card-sha256", - "SVCB MUST NOT carry card-sha256 when CapabilitiesHash is empty") + "SVCB MUST NOT carry card-sha256 when endpoint MetadataHash is empty") } } }) @@ -247,7 +250,7 @@ func TestComputeRequiredDNSRecords_UnionDedupesFamilyTrustRecords(t *testing.T) svc := newComputeOnlyService(t) reg := mustReg(t, "agent.example.com", "1.0.0", []domain.AgentEndpoint{{Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}}, - "", &domain.ByocServerCertificate{Fingerprint: "abcdef"}, + &domain.ByocServerCertificate{Fingerprint: "abcdef"}, []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB, domain.DNSRecordStyleTXT}) records := svc.ComputeRequiredDNSRecords(reg) @@ -271,7 +274,7 @@ func TestComputeRequiredDNSRecords_UnionDedupesFamilyTrustRecords(t *testing.T) // handler hitting an aggregate that hasn't reached PENDING_DNS yet). func TestComputeRequiredDNSRecords_NoEndpoints(t *testing.T) { svc := newComputeOnlyService(t) - reg := mustReg(t, "agent.example.com", "1.0.0", nil, "", nil, nil) + reg := mustReg(t, "agent.example.com", "1.0.0", nil, nil, nil) records := svc.ComputeRequiredDNSRecords(reg) assert.Empty(t, records) } @@ -305,7 +308,7 @@ func TestComputeRequiredDNSRecords_UnknownStyleSkipped(t *testing.T) { svc := newComputeOnlyService(t) reg := mustReg(t, "agent.example.com", "1.0.0", []domain.AgentEndpoint{{Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}}, - "", nil, + nil, []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB, domain.DNSRecordStyle("UNKNOWN_FUTURE")}) records := svc.ComputeRequiredDNSRecords(reg) @@ -327,19 +330,26 @@ func TestComputeRequiredDNSRecords_UnknownStyleSkipped(t *testing.T) { // SHA-256, signal a wire-shape regression, and break offline-verifier // hashes for in-flight agents at deploy time. // -// The hex constant was captured from the pre-refactor domain function -// against this exact input. Do NOT regenerate without explicit -// approval — a change here is a wire-format change, not a test fix. +// The hex constant was captured against this exact input. Do NOT +// regenerate without explicit approval — a change here is a +// wire-format change, not a test fix. func TestComputeRequiredDNSRecords_UnionCanonicalBytesRegression(t *testing.T) { - const wantSHA256Hex = "20b7c2c90986deb7891e8637e2be0adf439b3050ecad9d07429f0b707ad05875" + const wantSHA256Hex = "ab1efc56fcc5dc088ff0f35d5ed1e0164b8ee70a11116e60f180a55fe794bf64" svc := newComputeOnlyService(t) reg := mustReg(t, "agent.example.com", "1.2.3", []domain.AgentEndpoint{ - {Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com/a2a"}, - {Protocol: domain.ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, + { + Protocol: domain.ProtocolA2A, + AgentURL: "https://agent.example.com/a2a", + MetadataHash: "SHA256:098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27", + }, + { + Protocol: domain.ProtocolMCP, + AgentURL: "https://agent.example.com/mcp", + MetadataHash: "SHA256:1111111111111111111111111111111111111111111111111111111111111111", + }, }, - "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27", &domain.ByocServerCertificate{Fingerprint: "deadbeefcafe1234"}, []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB, domain.DNSRecordStyleTXT}) @@ -409,7 +419,7 @@ func TestComputeRequiredDNSRecords_RegistryIterationOrderDeterminesEmission(t *t reg := mustReg(t, "agent.example.com", "1.0.0", []domain.AgentEndpoint{{Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}}, - "", nil, + nil, []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB, domain.DNSRecordStyleTXT}) defaultOut := defaultSvc.ComputeRequiredDNSRecords(reg) @@ -469,7 +479,7 @@ func TestComputeRequiredDNSRecords_RegistryGetMissDoesNotPanic(t *testing.T) { inconsistentRegistry{}) reg := mustReg(t, "agent.example.com", "1.0.0", []domain.AgentEndpoint{{Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}}, - "", nil, + nil, []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}) // IDs() returns SVCB; Get returns (nil, false). Walker must diff --git a/internal/ra/service/lifecycle.go b/internal/ra/service/lifecycle.go index 7471e5a..5f51c3b 100644 --- a/internal/ra/service/lifecycle.go +++ b/internal/ra/service/lifecycle.go @@ -608,8 +608,9 @@ func (s *RegistrationService) verifyDNSRecords(ctx context.Context, fqdn string, // `DNSSECVerified && !Found` captures "response was signed, // but its content disagreed with what we issued" — the exact // attack we block (an attacker rewrote a record in a signed - // zone). Applies to TLSA (cert binding), SVCB (capability - // locator with card-sha256), and HTTPS (service binding). + // zone). Applies to TLSA (cert binding), SVCB (per-protocol + // service binding, including card-sha256 commitments), and + // HTTPS (service binding). if r.DNSSECVerified && !r.Found { switch r.Record.Type { case domain.DNSRecordTLSA, domain.DNSRecordSVCB, domain.DNSRecordHTTPS: diff --git a/spec/api-spec-v2.yaml b/spec/api-spec-v2.yaml index 8b80e52..13b38d7 100644 --- a/spec/api-spec-v2.yaml +++ b/spec/api-spec-v2.yaml @@ -1031,9 +1031,9 @@ components: registration. Used as the element type of dnsRecordStyles[]. - ANS_SVCB: Consolidated Approach SVCB rows at the bare FQDN - per RFC 9460. One row per protocol carrying alpn, port, and - capability-locator SvcParams (wk, card-sha256). The - recommended default for new integrations. + per RFC 9460. One row per protocol carrying alpn, port, wk, + and (when the endpoint has a metadataHash) card-sha256 + SvcParams. The recommended default for new integrations. - ANS_TXT: original `_ans` TXT shape (one row per protocol), supported indefinitely for operators with existing zone-edit tooling that targets `_ans.{fqdn}`. Emits an HTTPS RR at From c3898fb31c64eb34b7aa9681567649c82d2275ae Mon Sep 17 00:00:00 2001 From: kperry Date: Fri, 29 May 2026 00:20:44 -0500 Subject: [PATCH 12/13] feat(dns): enhance DNSMismatch classification for better error reporting --- internal/ra/handler/dto.go | 14 ++-- internal/ra/handler/dto_helpers_test.go | 94 ++++++++++++++----------- internal/ra/handler/v1lifecycle.go | 11 +-- internal/ra/service/lifecycle.go | 39 ++++++++-- 4 files changed, 101 insertions(+), 57 deletions(-) diff --git a/internal/ra/handler/dto.go b/internal/ra/handler/dto.go index d5c8471..58e8bdb 100644 --- a/internal/ra/handler/dto.go +++ b/internal/ra/handler/dto.go @@ -453,7 +453,7 @@ type incorrectRecordDTO struct { func dnsMissingFrom(mismatches []service.DNSMismatch) []dnsRecordDTO { var out []dnsRecordDTO for _, m := range mismatches { - if m.Code != "MISSING" { + if !m.IsMissing() { continue } out = append(out, dnsRecordDTO{ @@ -471,12 +471,12 @@ func dnsMissingFrom(mismatches []service.DNSMismatch) []dnsRecordDTO { func dnsIncorrectFrom(mismatches []service.DNSMismatch) []incorrectRecordDTO { var out []incorrectRecordDTO for _, m := range mismatches { - // MISMATCH = required record with wrong value. - // TLSA_DNSSEC_MISMATCH = TLSA response came back - // DNSSEC-authenticated but didn't match the expected cert - // fingerprint (signed-zone tampering). Both surface as - // incorrect records — same DTO shape. - if m.Code != "MISMATCH" && m.Code != "TLSA_DNSSEC_MISMATCH" { + // Incorrect = present but wrong: a plain value MISMATCH, or + // DNSSEC-authenticated tampering on a TLSA/SVCB/HTTPS record + // (_DNSSEC_MISMATCH, signed-zone tampering). All + // surface here as incorrect records — same DTO shape; missing + // records go to dnsMissingFrom. + if !m.IsIncorrect() { continue } out = append(out, incorrectRecordDTO{ diff --git a/internal/ra/handler/dto_helpers_test.go b/internal/ra/handler/dto_helpers_test.go index 355c765..55ace6e 100644 --- a/internal/ra/handler/dto_helpers_test.go +++ b/internal/ra/handler/dto_helpers_test.go @@ -40,30 +40,60 @@ func TestDNSMissingFrom_FiltersMissingOnly(t *testing.T) { } } -func TestDNSIncorrectFrom_FiltersMismatchOnly(t *testing.T) { - in := []service.DNSMismatch{ - { - Expected: domain.ExpectedDNSRecord{Name: "x", Type: domain.DNSRecordTXT, Value: "want"}, - Code: "MISSING", // filtered out - }, - { - Expected: domain.ExpectedDNSRecord{Name: "y", Type: domain.DNSRecordTXT, Value: "want"}, - Found: "actually-got", - Code: "MISMATCH", - }, - } - got := dnsIncorrectFrom(in) - if len(got) != 1 { - t.Fatalf("want 1 mismatch, got %d", len(got)) - } - if got[0].Record.Name != "y" { - t.Errorf("record.name: %q", got[0].Record.Name) - } - if got[0].Found != "actually-got" { - t.Errorf("found: %q", got[0].Found) +// The incorrect-record mappers must surface present-but-wrong records: a +// plain value MISMATCH and DNSSEC-authenticated tampering on EVERY +// DNSSEC-bearing record type (TLSA, SVCB, HTTPS) — not just TLSA, which +// was the original gap. MISSING records are excluded (they belong in the +// missing-records array). V2 and V1 classify identically, so one table +// drives both lanes. +func TestDNSIncorrectMappers_SurfaceMismatchAndAllDNSSEC(t *testing.T) { + cases := []struct { + name string + code string + wantSurface bool + }{ + {"plain_value_mismatch", "MISMATCH", true}, + {"tlsa_dnssec_tampering", "TLSA_DNSSEC_MISMATCH", true}, + {"svcb_dnssec_tampering", "SVCB_DNSSEC_MISMATCH", true}, + {"https_dnssec_tampering", "HTTPS_DNSSEC_MISMATCH", true}, + {"missing_is_excluded", "MISSING", false}, } - if got[0].Expected != "want" { - t.Errorf("expected: %q", got[0].Expected) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + in := []service.DNSMismatch{{ + Expected: domain.ExpectedDNSRecord{ + Name: "rec.example.com", Type: domain.DNSRecordSVCB, Value: "want", + }, + Found: "wrong", + Code: tc.code, + }} + + v2 := dnsIncorrectFrom(in) + v1 := v1DNSIncorrectFrom(in) + + if !tc.wantSurface { + if len(v2) != 0 { + t.Errorf("V2: code %q must be excluded, got %d", tc.code, len(v2)) + } + if len(v1) != 0 { + t.Errorf("V1: code %q must be excluded, got %d", tc.code, len(v1)) + } + return + } + + if len(v2) != 1 { + t.Fatalf("V2: code %q must surface, got %d", tc.code, len(v2)) + } + if v2[0].Record.Name != "rec.example.com" || v2[0].Found != "wrong" || v2[0].Expected != "want" { + t.Errorf("V2 mapping wrong: %+v", v2[0]) + } + if len(v1) != 1 { + t.Fatalf("V1: code %q must surface, got %d", tc.code, len(v1)) + } + if v1[0].Name != "rec.example.com" || v1[0].Found != "wrong" || v1[0].Expected != "want" { + t.Errorf("V1 mapping wrong: %+v", v1[0]) + } + }) } } @@ -86,24 +116,6 @@ func TestV1DNSMissingFrom_FiltersMissingOnly(t *testing.T) { } } -func TestV1DNSIncorrectFrom_FiltersMismatchOnly(t *testing.T) { - in := []service.DNSMismatch{ - {Expected: domain.ExpectedDNSRecord{Name: "a"}, Code: "MISSING"}, - {Expected: domain.ExpectedDNSRecord{Name: "b", Type: domain.DNSRecordTXT, Value: "want"}, - Code: "MISMATCH", Found: "wrong"}, - } - got := v1DNSIncorrectFrom(in) - if len(got) != 1 { - t.Fatalf("got %d, want 1", len(got)) - } - if got[0].Name != "b" { - t.Errorf("name: %q", got[0].Name) - } - if got[0].Found != "wrong" || got[0].Expected != "want" { - t.Errorf("found/expected: %q/%q", got[0].Found, got[0].Expected) - } -} - // ----- V1 renewal mappers ----- func TestMapV1RenewalStatus_ActiveAndFailed(t *testing.T) { diff --git a/internal/ra/handler/v1lifecycle.go b/internal/ra/handler/v1lifecycle.go index b026d59..2e1a73c 100644 --- a/internal/ra/handler/v1lifecycle.go +++ b/internal/ra/handler/v1lifecycle.go @@ -190,7 +190,7 @@ func (h *V1LifecycleHandler) Revoke(w http.ResponseWriter, r *http.Request) { func v1DNSMissingFrom(mismatches []service.DNSMismatch) []v1DNSRecordDTO { out := make([]v1DNSRecordDTO, 0) for _, m := range mismatches { - if m.Code != "MISSING" { + if !m.IsMissing() { continue } out = append(out, v1DNSRecordDTO{ @@ -208,10 +208,11 @@ func v1DNSMissingFrom(mismatches []service.DNSMismatch) []v1DNSRecordDTO { func v1DNSIncorrectFrom(mismatches []service.DNSMismatch) []v1DNSMismatchDTO { out := make([]v1DNSMismatchDTO, 0) for _, m := range mismatches { - // MISMATCH and TLSA_DNSSEC_MISMATCH both surface here as - // incorrect-record entries. See dto.go's dnsIncorrectFrom - // for the DNSSEC-mismatch rationale. - if m.Code != "MISMATCH" && m.Code != "TLSA_DNSSEC_MISMATCH" { + // Present-but-wrong records — plain MISMATCH or DNSSEC tampering + // on TLSA/SVCB/HTTPS (_DNSSEC_MISMATCH) — surface + // here as incorrect-record entries. See + // service.DNSMismatch.IsIncorrect for the classification. + if !m.IsIncorrect() { continue } out = append(out, v1DNSMismatchDTO{ diff --git a/internal/ra/service/lifecycle.go b/internal/ra/service/lifecycle.go index 5f51c3b..fa8f381 100644 --- a/internal/ra/service/lifecycle.go +++ b/internal/ra/service/lifecycle.go @@ -447,12 +447,43 @@ type VerifyDNSResult struct { DNSMismatches []DNSMismatch // non-empty → handler emits 422 } +// DNS mismatch codes carried by DNSMismatch.Code. MISSING and MISMATCH +// are exact codes; DNSSEC-authenticated tampering is reported per record +// type as "_DNSSEC_MISMATCH" (TLSA_DNSSEC_MISMATCH, +// SVCB_DNSSEC_MISMATCH, HTTPS_DNSSEC_MISMATCH). Consumers classify with +// the IsMissing / IsIncorrect predicates rather than matching raw strings, +// so a new DNSSEC-bearing record type surfaces without editing every +// consumer — the drift that previously dropped SVCB/HTTPS tampering from +// the 422 body. +const ( + dnsCodeMissing = "MISSING" + dnsCodeMismatch = "MISMATCH" + dnssecMismatchSuffix = "_DNSSEC_MISMATCH" +) + // DNSMismatch names a missing or incorrect record encountered during // verification. Surface-level shape matches the V2 DnsVerificationError. type DNSMismatch struct { Expected domain.ExpectedDNSRecord Found string // empty if the record was missing entirely - Code string // "MISSING" | "MISMATCH" + // Code is "MISSING", "MISMATCH", or "_DNSSEC_MISMATCH". + // Classify with IsMissing / IsIncorrect rather than comparing raw + // strings — the DNSSEC codes are generated per record type. + Code string +} + +// IsMissing reports whether the expected record was absent from the zone. +// These records belong in the 422 body's missingRecords array. +func (m DNSMismatch) IsMissing() bool { return m.Code == dnsCodeMissing } + +// IsIncorrect reports whether the record was present but wrong: a plain +// value mismatch (MISMATCH) or DNSSEC-authenticated tampering on a TLSA, +// SVCB, or HTTPS record ("_DNSSEC_MISMATCH"). Matching the +// suffix rather than each type means a future DNSSEC-bearing record type +// lands in incorrectRecords automatically. These records belong in the +// 422 body's incorrectRecords array. +func (m DNSMismatch) IsIncorrect() bool { + return m.Code == dnsCodeMismatch || strings.HasSuffix(m.Code, dnssecMismatchSuffix) } // VerifyDNS checks the operator's authoritative nameserver for the @@ -616,7 +647,7 @@ func (s *RegistrationService) verifyDNSRecords(ctx context.Context, fqdn string, case domain.DNSRecordTLSA, domain.DNSRecordSVCB, domain.DNSRecordHTTPS: out = append(out, DNSMismatch{ Expected: r.Record, Found: r.Actual, - Code: string(r.Record.Type) + "_DNSSEC_MISMATCH", + Code: string(r.Record.Type) + dnssecMismatchSuffix, }) continue case domain.DNSRecordTXT: @@ -631,9 +662,9 @@ func (s *RegistrationService) verifyDNSRecords(ctx context.Context, fqdn string, } switch { case !r.Found: - out = append(out, DNSMismatch{Expected: r.Record, Code: "MISSING"}) + out = append(out, DNSMismatch{Expected: r.Record, Code: dnsCodeMissing}) case r.Found && r.Actual != r.Record.Value: - out = append(out, DNSMismatch{Expected: r.Record, Found: r.Actual, Code: "MISMATCH"}) + out = append(out, DNSMismatch{Expected: r.Record, Found: r.Actual, Code: dnsCodeMismatch}) } } return out, res.Results, nil From 7b9af37ce2f0b486327b547e551f7b1ebb9ac0db Mon Sep 17 00:00:00 2001 From: kperry Date: Thu, 11 Jun 2026 16:59:28 -0500 Subject: [PATCH 13/13] =?UTF-8?q?fix(discovery):=20publishable=20keyNNNNN?= =?UTF-8?q?=20SVCB=20params,=20per-port=20DANE,=20verify-dns=20classificat?= =?UTF-8?q?ion;=20rename=20DNSRecordStyle=20to=20DiscoveryProfile=20Four?= =?UTF-8?q?=20defects=20in=20the=20Consolidated=20Approach=20pipeline,=20a?= =?UTF-8?q?ll=20verified=20against=20miekg/dns=20v1.1.72=20and=20proven=20?= =?UTF-8?q?end-to-end=20with=20the=20bundled=20ans-dns=20server=20(--with-?= =?UTF-8?q?dns=20lifecycle=20now=20reaches=20ACTIVE=20and=20offline=20ans-?= =?UTF-8?q?verify=20passes):=20-=20SVCB=20SvcParams:=20emit=20the=20well-k?= =?UTF-8?q?nown=20suffix=20and=20capability=20digest=20=20=20as=20RFC=2094?= =?UTF-8?q?60=20=C2=A714.3.1=20Private=20Use=20key65280/key65281=20instead?= =?UTF-8?q?=20of=20the=20=20=20named=20`wk=3D`/`card-sha256=3D`=20forms.?= =?UTF-8?q?=20The=20named=20forms=20have=20no=20IANA=20=20=20code=20points?= =?UTF-8?q?;=20strict=20parsers=20(miekg/dns,=20real=20DNS=20providers)=20?= =?UTF-8?q?reject=20=20=20them,=20so=20ans-dns=20served=20NXDOMAIN=20and?= =?UTF-8?q?=20the=20lookup=20verifier=20could=20=20=20never=20match=20?= =?UTF-8?q?=E2=80=94=20A2A/MCP=20agents=20on=20the=20default=20ANS=5FSVCB?= =?UTF-8?q?=20profile=20were=20=20=20permanently=20stuck=20in=20PENDING=5F?= =?UTF-8?q?DNS.=20Naming=20standardizes=20on=20the=20=20=20published=20dra?= =?UTF-8?q?ft's=20cap-sha256=20(not=20card-sha256).=20-=20TLSA:=20one=20re?= =?UTF-8?q?cord=20per=20distinct=20TLS=20endpoint=20port=20at=20=20=20`=5F?= =?UTF-8?q?.=5Ftcp.{fqdn}`=20(RFC=206698=20owner=20names;=20was=20ha?= =?UTF-8?q?rdcoded=20=5F443)=20=20=20with=20selector=200=20=E2=80=94=20`3?= =?UTF-8?q?=200=201=20`=20=E2=80=94=20matching=20what=20=20?= =?UTF-8?q?=20CertificateFingerprint=20actually=20hashes=20(full=20DER=20c?= =?UTF-8?q?ert).=20The=20old=20=20=20`3=201=201`=20paired=20an=20SPKI=20se?= =?UTF-8?q?lector=20with=20a=20full-cert=20hash=20and=20never=20=20=20vali?= =?UTF-8?q?dated=20for=20any=20DANE=20client.=20Ports=20sort=20numerically?= =?UTF-8?q?;=20the=20order=20=20=20is=20load-bearing=20for=20TL=20canonica?= =?UTF-8?q?l=20bytes=20and=20V1=20revoke-map=20keys.=20-=20verify-dns=20cl?= =?UTF-8?q?assification:=20records=20present-but-wrong=20now=20report=20?= =?UTF-8?q?=20=20MISMATCH=20carrying=20the=20live=20value=20(was:=20mislab?= =?UTF-8?q?eled=20MISSING=20with=20the=20=20=20value=20dropped);=20records?= =?UTF-8?q?=20matched=20with=20benign=20string=20deltas=20(SVCB=20=20=20co?= =?UTF-8?q?existence=20extras,=20TLSA=20hex=20case)=20no=20longer=20produc?= =?UTF-8?q?e=20spurious=20=20=20422s.=20The=20port.RecordVerification.Actu?= =?UTF-8?q?al=20contract=20is=20documented=20=20=20and=20pinned=20by=20a?= =?UTF-8?q?=20stub-verifier=20test.=20The=20Required=20gate=20and=20the=20?= =?UTF-8?q?=20=20DNSSEC=20tamper=20hard-fail=20are=20unchanged.=20Added=20?= =?UTF-8?q?WARN=20(systemic=20=20=20verifier=20error)=20and=20INFO=20(mism?= =?UTF-8?q?atch=20set,=20names/types/codes=20only)=20=20=20logging=20to=20?= =?UTF-8?q?the=20previously-silent=20verify=20path.=20-=20Rename=20DNSReco?= =?UTF-8?q?rdStyle=20->=20DiscoveryProfile=20across=20domain,=20port=20=20?= =?UTF-8?q?=20(ProfileEmitter/ProfileRegistry),=20adapters,=20service,=20w?= =?UTF-8?q?ire=20field=20=20=20(discoveryProfiles),=20spec=20schema,=20and?= =?UTF-8?q?=20the=20006=20migration/column.=20=20=20Enum=20values=20ANS=5F?= =?UTF-8?q?SVCB/ANS=5FTXT=20are=20unchanged.=20discoveryProfiles=20is=20?= =?UTF-8?q?=20=20now=20normalized=20defensively=20(empty=20->=20default,?= =?UTF-8?q?=20duplicates=20deduped;=20=20=20only=20unrecognized=20values?= =?UTF-8?q?=20422)=20=E2=80=94=20the=20schema's=20minItems/uniqueItems=20?= =?UTF-8?q?=20=20remain=20the=20canonical=20client=20contract.=20Wire=20ch?= =?UTF-8?q?ange:=20the=20canonical-bytes=20regression=20hash=20(TestComput?= =?UTF-8?q?eRequiredDNSRecords=5FUnionCanonicalBytesRegression)=20is=20reg?= =?UTF-8?q?enerated=20for=20the=20new=20SVCB=20values=20and=20TLSA=20selec?= =?UTF-8?q?tor.=20No=20migration:=20record=20values=20are=20recomputed=20o?= =?UTF-8?q?n=20every=20read,=20and=20the=20old=20SVCB=20values=20were=20un?= =?UTF-8?q?publishable,=20so=20no=20operator=20has=20them=20live.=20Alread?= =?UTF-8?q?y-sealed=20TL=20leaves=20keep=20their=20bytes=20(append-only).?= =?UTF-8?q?=20Also:=20-=20Validate=20endpoint=20URL=20ports=20(1-65535)=20?= =?UTF-8?q?at=20registration;=20out-of-range=20=20=20ports=20produced=20un?= =?UTF-8?q?publishable=20record=20sets=20(operator=20self-DoS).=20-=20run-?= =?UTF-8?q?lifecycle.sh=20now=20publishes=20the=20ACME=20DNS-01=20challeng?= =?UTF-8?q?e=20TXT=20into=20=20=20the=20ans-dns=20zone=20before=20verify-a?= =?UTF-8?q?cme;=20ans-dns=20install=20cannot=20do=20this=20=20=20stage=20(?= =?UTF-8?q?it=20reads=20dnsRecords[],=20which=20is=20empty=20during=20=20?= =?UTF-8?q?=20PENDING=5FVALIDATION)=20=E2=80=94=20the=20--with-dns=20flow?= =?UTF-8?q?=20was=20broken=20at=20verify-acme=20=20=20before=20this.=20-?= =?UTF-8?q?=20Drop=20deprecated=20middleware.RealIP=20from=20ans-ra/ans-tl?= =?UTF-8?q?=20(chi=205.3=20flags=20=20=20it=20as=20spoofable;=20nothing=20?= =?UTF-8?q?consumes=20RemoteAddr)=20=E2=80=94=20clears=20the=20lint=20=20?= =?UTF-8?q?=20baseline.=20Remove=20the=20unused=20net-import=20keep-alive?= =?UTF-8?q?=20in=20ans-dns.=20-=20First=20tests=20for=20cmd/ans-dns=20(ser?= =?UTF-8?q?ve-path=20round-trip=20incl.=20the=20=20=20named-wk=20rejection?= =?UTF-8?q?=20case),=20sqlite=20discovery=5Fprofiles=20round-trip,=20=20?= =?UTF-8?q?=20port-range=20and=20TLSA=20sort/fallback=20tables.=20-=20Spec?= =?UTF-8?q?:=20document=20the=20full=20ANS=5FSVCB=20record=20set=20(SVCB?= =?UTF-8?q?=20+=20badge=20TXT=20+=20=20=20per-port=20TLSA),=20correct=20th?= =?UTF-8?q?e=20202-response=20dnsRecords=20claim,=20note=20the=20=20=20HTT?= =?UTF-8?q?PS=20RR=20is=20best-effort,=20alphabetize=20required[];=20docsu?= =?UTF-8?q?i=20ra.yaml=20=20=20re-synced.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/ans-dns/main.go | 5 - cmd/ans-dns/main_test.go | 178 ++++++++++++ cmd/ans-ra/main.go | 47 +++- cmd/ans-tl/main.go | 12 +- internal/adapter/discovery/ans/protocol.go | 32 ++- internal/adapter/discovery/ans/svcb.go | 130 ++++++--- internal/adapter/discovery/ans/svcb_test.go | 80 +++--- internal/adapter/discovery/ans/tlsa.go | 103 ++++++- internal/adapter/discovery/ans/tlsa_test.go | 120 ++++++-- internal/adapter/discovery/ans/txt.go | 18 +- internal/adapter/discovery/ans/txt_test.go | 28 +- .../adapter/discovery/registry/registry.go | 54 ++-- .../discovery/registry/registry_test.go | 98 +++---- internal/adapter/dns/dns_test.go | 71 ++++- internal/adapter/docsui/openapi/ra.yaml | 92 ++++-- internal/adapter/store/sqlite/agent.go | 40 +-- internal/adapter/store/sqlite/agent_test.go | 60 ++++ ...s.sql => 006_agent_discovery_profiles.sql} | 16 +- internal/domain/agent.go | 8 +- internal/domain/dnsrecords.go | 54 ++-- internal/domain/dnsrecords_test.go | 38 +-- internal/domain/endpoint.go | 17 ++ internal/domain/endpoint_test.go | 28 ++ internal/port/discovery.go | 46 +-- internal/port/dns.go | 12 +- internal/ra/handler/lifecycle_test.go | 2 +- internal/ra/handler/registration.go | 30 +- .../ra/handler/v1lifecycle_direct_test.go | 126 +++++++-- internal/ra/service/discovery_default.go | 18 +- internal/ra/service/dnsmismatch_test.go | 42 +++ internal/ra/service/dnsrecords.go | 79 +++--- internal/ra/service/dnsrecords_test.go | 151 +++++----- internal/ra/service/helpers.go | 77 +++-- internal/ra/service/helpers_test.go | 151 +++++----- internal/ra/service/lifecycle.go | 61 +++- .../ra/service/lifecycle_verifydns_test.go | 262 ++++++++++++++++++ internal/ra/service/registration.go | 20 +- internal/ra/service/registration_test.go | 4 +- scripts/demo/run-lifecycle.sh | 23 +- spec/api-spec-v2.yaml | 92 ++++-- 40 files changed, 1842 insertions(+), 683 deletions(-) create mode 100644 cmd/ans-dns/main_test.go rename internal/adapter/store/sqlite/migrations/{006_agent_dns_record_styles.sql => 006_agent_discovery_profiles.sql} (79%) create mode 100644 internal/ra/service/dnsmismatch_test.go create mode 100644 internal/ra/service/lifecycle_verifydns_test.go diff --git a/cmd/ans-dns/main.go b/cmd/ans-dns/main.go index 3ac9ef6..b273976 100644 --- a/cmd/ans-dns/main.go +++ b/cmd/ans-dns/main.go @@ -30,7 +30,6 @@ import ( "flag" "fmt" "io" - "net" "net/http" "os" "os/signal" @@ -373,7 +372,3 @@ func runClear(args []string) error { fmt.Printf("cleared %d records for %s from %s\n", n, agentID, *zonePath) return nil } - -// Ensure net package stays referenced so future edits that thread a -// net.Listener / net.PacketConn through don't require chasing imports. -var _ = net.InterfaceAddrs diff --git a/cmd/ans-dns/main_test.go b/cmd/ans-dns/main_test.go new file mode 100644 index 0000000..18d66c0 --- /dev/null +++ b/cmd/ans-dns/main_test.go @@ -0,0 +1,178 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/miekg/dns" +) + +// svcbWithKeyNNNNN is the Consolidated Approach SVCB presentation the +// RA's ANS_SVCB profile emits: ServiceMode `1 .` plus alpn, port, the +// well-known suffix as the RFC 9460 §14.3.1 Private Use key65280, and +// the capability digest as key65281. These keyNNNNN forms are what +// makes the value publishable — see the named-form negative case below. +const ( + svcbValueKeyNNNNN = `1 . alpn=a2a port=443 key65280=agent-card.json key65281=CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc` + // svcbValueNamedWK is the pre-fix named form. dns.NewRR rejects it + // (`bad SVCB key`), so answersFor drops it and the server returns no + // answer — the unpublishability that Fix A's no-migration argument + // rests on. ans-dns serving this value is indistinguishable from + // NXDOMAIN to a resolver. + svcbValueNamedWK = `1 . alpn=a2a port=443 wk=agent-card.json` + tlsaValueSel0 = `3 0 1 deadbeefcafe1234` + txtValue = `v=ans1; version=1.0.0; p=a2a; mode=direct; url=https://agent.example.com/a2a` +) + +// TestAnswersFor_ServePathRoundTrip drives the serve path (answersFor) +// directly: each zone record is composed into a presentation line and +// parsed with dns.NewRR exactly as the running server does, so a value +// the parser rejects yields zero answers. Table-driven over the record +// shapes the RA emits post-Fix-A/B2. +func TestAnswersFor_ServePathRoundTrip(t *testing.T) { + const fqdn = "agent.example.com" + + tests := []struct { + name string + record zoneRecord + queryName string + queryType uint16 + wantAnswer bool // true → exactly one RR served + wantInRR string // substring required in the served RR string (when wantAnswer) + }{ + { + name: "svcb_keyNNNNN_parses_and_serves", + record: zoneRecord{Name: fqdn, Type: "SVCB", Value: svcbValueKeyNNNNN, TTL: 3600}, + queryName: fqdn, + queryType: dns.TypeSVCB, + wantAnswer: true, + // dns.NewRR re-renders Private Use SvcParams quoted; pin the + // key numbers survive the round-trip. + wantInRR: `key65280="agent-card.json"`, + }, + { + name: "svcb_keyNNNNN_carries_capability_digest", + record: zoneRecord{Name: fqdn, Type: "SVCB", Value: svcbValueKeyNNNNN, TTL: 3600}, + queryName: fqdn, + queryType: dns.TypeSVCB, + wantAnswer: true, + wantInRR: `key65281="CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc"`, + }, + { + name: "svcb_named_wk_rejected_and_dropped", + record: zoneRecord{Name: fqdn, Type: "SVCB", Value: svcbValueNamedWK, TTL: 3600}, + queryName: fqdn, + queryType: dns.TypeSVCB, + wantAnswer: false, // dns.NewRR("… wk=…") errors → answersFor skips it + }, + { + name: "txt_value_served_quoted", + record: zoneRecord{Name: "_ans." + fqdn, Type: "TXT", Value: txtValue, TTL: 3600}, + queryName: "_ans." + fqdn, + queryType: dns.TypeTXT, + wantAnswer: true, + wantInRR: `"` + txtValue + `"`, + }, + { + name: "tlsa_selector0_served", + record: zoneRecord{Name: "_443._tcp." + fqdn, Type: "TLSA", Value: tlsaValueSel0, TTL: 3600}, + queryName: "_443._tcp." + fqdn, + queryType: dns.TypeTLSA, + wantAnswer: true, + wantInRR: "3 0 1 deadbeefcafe1234", + }, + { + name: "type_mismatch_yields_no_answer", + record: zoneRecord{Name: fqdn, Type: "SVCB", Value: svcbValueKeyNNNNN, TTL: 3600}, + queryName: fqdn, + queryType: dns.TypeA, // querying A against an SVCB record + wantAnswer: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + q := dns.Question{Name: dns.Fqdn(tc.queryName), Qtype: tc.queryType, Qclass: dns.ClassINET} + answers := answersFor(q, []zoneRecord{tc.record}) + + if !tc.wantAnswer { + if len(answers) != 0 { + t.Fatalf("want zero answers, got %d: %v", len(answers), answers) + } + return + } + if len(answers) != 1 { + t.Fatalf("want exactly one answer, got %d: %v", len(answers), answers) + } + got := answers[0].String() + if tc.wantInRR != "" && !strings.Contains(got, tc.wantInRR) { + t.Errorf("served RR %q does not contain %q", got, tc.wantInRR) + } + }) + } +} + +// TestLoadZoneThenServe pins the full disk-to-wire path: a JSON zone +// file written by `install` is loaded by loadZone, flattened, and +// served by answersFor. Exercises the keyNNNNN SVCB and selector-0 TLSA +// records together as one agent's record set, the way an operator +// publishes them. +func TestLoadZoneThenServe(t *testing.T) { + const fqdn = "agent.example.com" + dir := t.TempDir() + zonePath := filepath.Join(dir, "zone.json") + + zoneJSON := `{ + "records": { + "agent-1": [ + {"name": "agent.example.com", "type": "SVCB", "value": "1 . alpn=a2a port=443 key65280=agent-card.json key65281=CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc", "ttl": 3600}, + {"name": "_443._tcp.agent.example.com", "type": "TLSA", "value": "3 0 1 deadbeefcafe1234", "ttl": 3600} + ] + } +}` + if err := os.WriteFile(zonePath, []byte(zoneJSON), 0o600); err != nil { + t.Fatal(err) + } + + z, err := loadZone(zonePath) + if err != nil { + t.Fatalf("loadZone: %v", err) + } + records := z.flatten() + if len(records) != 2 { + t.Fatalf("want 2 flattened records, got %d", len(records)) + } + + svcb := answersFor(dns.Question{Name: dns.Fqdn(fqdn), Qtype: dns.TypeSVCB, Qclass: dns.ClassINET}, records) + if len(svcb) != 1 { + t.Fatalf("want one SVCB answer, got %d", len(svcb)) + } + if !strings.Contains(svcb[0].String(), `key65280="agent-card.json"`) { + t.Errorf("SVCB answer missing key65280: %q", svcb[0].String()) + } + if !strings.Contains(svcb[0].String(), `key65281="CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc"`) { + t.Errorf("SVCB answer missing key65281 capability digest after disk round-trip: %q", svcb[0].String()) + } + + tlsa := answersFor(dns.Question{Name: dns.Fqdn("_443._tcp." + fqdn), Qtype: dns.TypeTLSA, Qclass: dns.ClassINET}, records) + if len(tlsa) != 1 { + t.Fatalf("want one TLSA answer, got %d", len(tlsa)) + } + if !strings.Contains(tlsa[0].String(), "3 0 1 deadbeefcafe1234") { + t.Errorf("TLSA answer missing selector-0 binding: %q", tlsa[0].String()) + } +} + +// TestLoadZoneMissingFileIsEmpty pins loadZone's "no file → empty zone" +// contract that lets `serve` start before any `install` has run. +func TestLoadZoneMissingFileIsEmpty(t *testing.T) { + z, err := loadZone(filepath.Join(t.TempDir(), "does-not-exist.json")) + if err != nil { + t.Fatalf("loadZone on missing file must not error, got %v", err) + } + if len(z.flatten()) != 0 { + t.Errorf("missing-file zone must be empty, got %d records", len(z.flatten())) + } +} diff --git a/cmd/ans-ra/main.go b/cmd/ans-ra/main.go index 99c33ff..1dd61eb 100644 --- a/cmd/ans-ra/main.go +++ b/cmd/ans-ra/main.go @@ -164,18 +164,21 @@ func run(cfgPath string) error { // Event bus. bus := eventbus.NewInMemoryBus(logger) - // Discovery registry: composes the bundled ANS-family port.DiscoveryStyle + // Discovery registry: composes the bundled ANS-family port.ProfileEmitter // adapters (ANS_TXT, ANS_SVCB) the V2 register / verify-dns paths walk // to compute `dnsRecordsProvisioned[]`. Insertion order here pins the // canonical-bytes emission order for the §4.4.2 union case // (`[TXT×N, HTTPS, SVCB×N, badge, TLSA]`). - discoveryReg, err := service.NewDefaultDiscoveryRegistry(cfg.TLClient.PublicBaseURL) + discoveryReg, err := service.NewDefaultProfileRegistry(cfg.TLClient.PublicBaseURL) if err != nil { return fmt.Errorf("init discovery registry: %w", err) } if err := assertRegistryDomainCoherence(discoveryReg); err != nil { - return fmt.Errorf("init discovery registry: %w", err) + return fmt.Errorf("discovery registry coherence: %w", err) } + logger.Info(). + Strs("profiles", profileIDStrings(discoveryReg.IDs())). + Msg("discovery registry ready") // Services. regSvc := service.NewRegistrationService( @@ -191,7 +194,10 @@ func run(cfgPath string) error { r := chi.NewRouter() r.Use(middleware.Recoverer) r.Use(middleware.RequestID) - r.Use(middleware.RealIP) + // middleware.RealIP is deliberately NOT wired: chi 5.3 deprecates it + // as IP-spoofable (it trusts X-Forwarded-For / X-Real-IP regardless + // of whether a proxy set them), and nothing in this service reads + // RemoteAddr — no request logger, no rate limiter, no IP audit. r.Use(middleware.Timeout(30 * time.Second)) r.Use(middleware.AllowContentType("application/json")) r.Use(authProvider.Middleware()) @@ -315,9 +321,10 @@ func run(cfgPath string) error { logger.Info().Str("addr", addr).Msg("listening") // Hardened timeouts: // - ReadHeaderTimeout caps slowloris-style header dribbling. - // - WriteTimeout > the 30s chi handler timeout (line 176) so - // chi gets the chance to write a clean 503 before the server - // drops the connection on a slow handler/client. + // - WriteTimeout > the 30s chi middleware.Timeout wired on the + // router above, so chi gets the chance to write a clean 503 + // before the server drops the connection on a slow + // handler/client. // - IdleTimeout caps how long an idle keep-alive connection // can sit on the runner; pairs with HTTP/1.1 connection reuse // for SDK clients while keeping a hard ceiling. @@ -414,19 +421,29 @@ type providerWithAnonymous interface { } // assertRegistryDomainCoherence verifies the discovery registry's -// wired styles are exactly the set domain advertises as valid via -// domain.ValidDNSRecordStyles(). Drift in either direction is a -// startup misconfig: registry-only style means request-side validation -// rejects it (operator error noise); domain-only style means -// applyDNSRecordStyles accepts a value verify-dns can never satisfy +// wired profiles are exactly the set domain advertises as valid via +// domain.ValidDiscoveryProfiles(). Drift in either direction is a +// startup misconfig: registry-only profile means request-side validation +// rejects it (operator error noise); domain-only profile means +// applyDiscoveryProfiles accepts a value verify-dns can never satisfy // (silent broken-by-omission). Both fail server start. -func assertRegistryDomainCoherence(reg port.DiscoveryRegistry) error { +// profileIDStrings converts the registry's typed profile IDs to the +// plain strings the startup readiness log line wants. +func profileIDStrings(ids []domain.DiscoveryProfile) []string { + out := make([]string, len(ids)) + for i, id := range ids { + out[i] = string(id) + } + return out +} + +func assertRegistryDomainCoherence(reg port.ProfileRegistry) error { registryIDs := make(map[string]bool) for _, id := range reg.IDs() { registryIDs[string(id)] = true } domainIDs := make(map[string]bool) - for _, s := range domain.ValidDNSRecordStyles() { + for _, s := range domain.ValidDiscoveryProfiles() { domainIDs[s] = true } var registryOnly, domainOnly []string @@ -441,7 +458,7 @@ func assertRegistryDomainCoherence(reg port.DiscoveryRegistry) error { } } if len(registryOnly) > 0 || len(domainOnly) > 0 { - return fmt.Errorf("drift between registry.IDs() and domain.ValidDNSRecordStyles(): registry-only=%v, domain-only=%v", + return fmt.Errorf("drift between registry.IDs() and domain.ValidDiscoveryProfiles(): registry-only=%v, domain-only=%v", registryOnly, domainOnly) } return nil diff --git a/cmd/ans-tl/main.go b/cmd/ans-tl/main.go index 4ab015d..ebc39a1 100644 --- a/cmd/ans-tl/main.go +++ b/cmd/ans-tl/main.go @@ -209,7 +209,10 @@ func run(cfgPath string) error { r := chi.NewRouter() r.Use(middleware.Recoverer) r.Use(middleware.RequestID) - r.Use(middleware.RealIP) + // middleware.RealIP is deliberately NOT wired: chi 5.3 deprecates it + // as IP-spoofable (it trusts X-Forwarded-For / X-Real-IP regardless + // of whether a proxy set them), and nothing in this service reads + // RemoteAddr — no request logger, no rate limiter, no IP audit. r.Use(middleware.Timeout(30 * time.Second)) r.Use(authProvider.Middleware()) @@ -279,9 +282,10 @@ func run(cfgPath string) error { logger.Info().Str("addr", addr).Msg("listening") // Hardened timeouts — see the matching block in cmd/ans-ra/main.go // for the full rationale. WriteTimeout sits above the 30s chi - // handler timeout (line 213) so chi can write a clean 503 first; - // IdleTimeout caps keep-alive idle time; MaxHeaderBytes is the - // Go default written explicitly for audit visibility. + // middleware.Timeout wired on the router above so chi can write a + // clean 503 first; IdleTimeout caps keep-alive idle time; + // MaxHeaderBytes is the Go default written explicitly for audit + // visibility. srv := &http.Server{ Addr: addr, Handler: r, diff --git a/internal/adapter/discovery/ans/protocol.go b/internal/adapter/discovery/ans/protocol.go index 5788319..38c9b6a 100644 --- a/internal/adapter/discovery/ans/protocol.go +++ b/internal/adapter/discovery/ans/protocol.go @@ -1,15 +1,18 @@ -// Package ans implements the bundled ANS-family port.DiscoveryStyle -// adapters: SVCBStyle (the Consolidated Approach SVCB shape per RFC 9460 -// plus the `_ans-badge` TXT extension) and TXTStyle (the original `_ans` +// Package ans implements the bundled ANS-family port.ProfileEmitter +// adapters: SVCBProfile (the Consolidated Approach SVCB shape per RFC 9460 +// plus the `_ans-badge` TXT extension) and TXTProfile (the original `_ans` // TXT shape, supported indefinitely for operators with existing zone-edit -// tooling). Both styles share two family-level trust records — the +// tooling). Both profiles share two family-level trust records — the // `_ans-badge` TXT and the server-cert TLSA — emitted by every ANS-family -// style and deduped at the service walker. +// profile and deduped at the service walker. // -// Helpers private to this package handle the per-protocol bits both -// styles need: protocol.go for the human-friendly protocol token and -// well-known suffix mappings, svcb.go for the SVCB-specific port and -// card-sha256 helpers (kept next to their only consumer). +// Helpers private to this package, by file: protocol.go holds the +// protocol-token and well-known-suffix mappings both profiles need; +// svcb.go holds svcbPortFor (consumed by both the SVCB rows and +// tlsa.go's port collection) and the capability-digest (key65281) +// conversion; tlsa.go and ansbadge.go hold the family-level trust +// records (per-port TLSA, `_ans-badge` TXT) every ANS-family profile +// emits. package ans import ( @@ -34,11 +37,12 @@ func protocolToANSValue(p domain.Protocol) string { } // wkPathFor returns the suffix-only well-known path published in the -// Consolidated Approach SVCB record's `wk=` SvcParam. Suffix-only -// matches the consolidated-draft examples (§4 line 134); clients -// prepend `/.well-known/` to construct the full path. Empty result -// means the caller SHOULD omit `wk=` entirely (e.g. direct-mode agents -// that expose no canonical metadata file). +// Consolidated Approach SVCB record's well-known SvcParam (key65280, +// the draft's `wk`). Suffix-only matches the consolidated-draft +// examples (§4 line 134); clients prepend `/.well-known/` to construct +// the full path. Empty result means the caller SHOULD omit the SvcParam +// entirely (e.g. direct-mode agents that expose no canonical metadata +// file). // // A2A: `agent-card.json` (IANA-registered well-known per A2A spec). // MCP: `mcp.json` (de-facto convention; see SEP-1649 progress). diff --git a/internal/adapter/discovery/ans/svcb.go b/internal/adapter/discovery/ans/svcb.go index 36b41e5..9bb2ef1 100644 --- a/internal/adapter/discovery/ans/svcb.go +++ b/internal/adapter/discovery/ans/svcb.go @@ -1,6 +1,7 @@ package ans import ( + "crypto/sha256" "encoding/base64" "encoding/hex" "fmt" @@ -12,10 +13,10 @@ import ( "github.com/godaddy/ans/internal/port" ) -// SVCBStyle implements port.DiscoveryStyle for the Consolidated +// SVCBProfile implements port.ProfileEmitter for the Consolidated // Approach SVCB shape (ANS_SVCB). It emits one SVCB row per protocol // endpoint at the agent's bare FQDN, plus the ANS-family trust -// records (`_ans-badge` TXT and TLSA) so the style is self-contained +// records (`_ans-badge` TXT and TLSA) so the profile is self-contained // when registered alone. // // Records always returns SVCB rows with Required=true. The service @@ -23,63 +24,111 @@ import ( // rows when ANS_TXT is also in the resolved set (during the §4.4.2 // transition the legacy `_ans` TXT family carries the operator's // required signal and SVCB rides along as optional). Keeping the -// post-process at the service layer keeps SVCBStyle style-local — -// it does not need to know which other styles are in play. +// post-process at the service layer keeps SVCBProfile profile-local — +// it does not need to know which other profiles are in play. // // SvcParam composition per RFC 9460: // - alpn: protocol token (a2a / mcp / http-api), distinguishes // protocols within the same RRset. // - port: from endpoint URL authority; defaults to 443 (https) / // 80 (http) when the URL omits a port. -// - wk: well-known suffix per protocol (agent-card.json for A2A, -// mcp.json for MCP, omitted for HTTP-API). -// - card-sha256: base64url(raw SHA-256 bytes) sourced from this -// endpoint's MetadataHash when the operator submitted one. -// Absent when MetadataHash is empty or malformed; in that case -// verifiers fetch the metadata document and accept any payload -// whose URL matches. +// - key65280 (well-known suffix): per-protocol metadata file suffix +// (agent-card.json for A2A, mcp.json for MCP, omitted for +// HTTP-API). The DNS-AID draft names this param `wk`. +// - key65281 (capability digest): base64url(raw SHA-256 bytes) +// sourced from this endpoint's MetadataHash when the operator +// submitted one. Absent when MetadataHash is empty or malformed; +// in that case verifiers fetch the metadata document and accept +// any payload whose URL matches. The DNS-AID draft names this +// param `cap-sha256`. // -// `wk` and `card-sha256` are not yet IANA-registered SvcParamKeys; -// see the consolidated-draft §6 note for the keyNNNNN-form fallback -// strict-RFC parsers may need. -type SVCBStyle struct { +// Private Use SvcParamKeys (RFC 9460 §14.3.1). The draft's `wk` and +// `cap-sha256` have no IANA-assigned code point, so they MUST be +// emitted in the keyNNNNN presentation form (RFC 9460 §2.1) — the +// named forms are unparseable: miekg/dns (and therefore ans-dns) and +// real DNS providers reject `wk=`/`cap-sha256=` with "bad SVCB key", +// and the lookup verifier can only ever observe live records in +// keyNNNNN form. key65280 / key65281 sit in the §14.3.1 Private Use +// range (65280–65534). +// +// Caveats of squatting Private Use space: +// - The DNS-AID draft's own examples squat unassigned space +// (key65001) — we deliberately do not copy that point; 65280+ is +// the registered-as-private range, not merely unassigned. +// - Collision with another experiment that picks the same code +// points is intrinsic to Private Use, but it is bounded to +// denial-of-verification: the verifier's subset matcher requires +// equal values, so a colliding key with a different value can +// only cause a false negative (verify-dns fails), never a false +// accept. +// - If `wk`/`cap-sha256` are IANA-registered later, switching the +// presentation form back to named keys is a real operator-facing +// migration (the published record value changes), not a silent +// swap. +// +// Deliberately NOT emitted (see the package design notes): +// - mandatory=: emitting `mandatory=key65280` would make the whole +// record invisible to every generic RFC 9460 client that doesn't +// understand the key, defeating the §8 coexistence goal. The +// draft text that gates client behavior on these params is an +// upstream DNS-AID tension, not a reason to fence off the record. +// - ipv4hint/ipv6hint: the RA knows endpoint URLs, not addresses; +// registry-sourced address hints go stale and are out of scope. +type SVCBProfile struct { // tlPublicBaseURL feeds the family `_ans-badge` url= via BadgeRecord // (empty falls the badge back to the agent's own endpoint URL). Set - // once by NewSVCBStyle; styles are immutable after wiring, so Records + // once by NewSVCBProfile; profiles are immutable after wiring, so Records // stays a pure function of reg. tlPublicBaseURL string } -// NewSVCBStyle builds an ANS_SVCB style whose family `_ans-badge` record +// RFC 9460 §14.3.1 Private Use SvcParamKeys for the DNS-AID draft +// params that have no IANA code point. Emitted in keyNNNNN +// presentation form because the named forms (`wk`, `cap-sha256`) are +// rejected by miekg/dns and real DNS providers (see SVCBProfile doc). +const ( + // svcbKeyWellKnown carries the per-protocol well-known suffix + // (draft param `wk`). + svcbKeyWellKnown = "key65280" + // svcbKeyCapSHA256 carries the base64url SHA-256 capability digest + // of the endpoint metadata document (draft param `cap-sha256`). + svcbKeyCapSHA256 = "key65281" +) + +// NewSVCBProfile builds an ANS_SVCB profile whose family `_ans-badge` record // points at the transparency log at tlPublicBaseURL. Empty tlPublicBaseURL // falls the badge back to the agent's own endpoint URL. -func NewSVCBStyle(tlPublicBaseURL string) SVCBStyle { - return SVCBStyle{tlPublicBaseURL: tlPublicBaseURL} +func NewSVCBProfile(tlPublicBaseURL string) SVCBProfile { + return SVCBProfile{tlPublicBaseURL: tlPublicBaseURL} } // ID returns ANS_SVCB. -func (SVCBStyle) ID() domain.DNSRecordStyle { return domain.DNSRecordStyleSVCB } +func (SVCBProfile) ID() domain.DiscoveryProfile { return domain.DiscoveryProfileANSSVCB } -// Records returns the SVCB rows + family trust records the SVCB style +// Records returns the SVCB rows + family trust records the SVCB profile // needs an operator to publish. -func (s SVCBStyle) Records(reg *domain.AgentRegistration) []domain.ExpectedDNSRecord { +func (s SVCBProfile) Records(reg *domain.AgentRegistration) []domain.ExpectedDNSRecord { fqdn := reg.FQDN() - records := make([]domain.ExpectedDNSRecord, 0, len(reg.Endpoints)+2) + // Capacity: N SVCB rows + badge + up to N TLSA (one per distinct TLS + // port, see TLSARecord/Fix B). + records := make([]domain.ExpectedDNSRecord, 0, 2*len(reg.Endpoints)+1) for _, ep := range reg.Endpoints { alpn := protocolToANSValue(ep.Protocol) wk := wkPathFor(ep.Protocol) port := svcbPortFor(ep.AgentURL) - cardSHA := metadataHashToCardSHA256(ep.MetadataHash) + capSHA := metadataHashToCapSHA256(ep.MetadataHash) // RFC 9460 §2.1 presentation form: unquoted SvcParamValue when the // value has no characters special to the presentation format. // alpn tokens, port digits, well-known path suffixes, and - // base64url digests all qualify. + // base64url digests all qualify. key65280/key65281 are the + // §14.3.1 Private Use presentation of the draft wk/cap-sha256 + // params (named forms are unparseable — see SVCBProfile doc). value := fmt.Sprintf(`1 . alpn=%s port=%d`, alpn, port) if wk != "" { - value += fmt.Sprintf(` wk=%s`, wk) + value += fmt.Sprintf(` %s=%s`, svcbKeyWellKnown, wk) } - if cardSHA != "" { - value += fmt.Sprintf(` card-sha256=%s`, cardSHA) + if capSHA != "" { + value += fmt.Sprintf(` %s=%s`, svcbKeyCapSHA256, capSHA) } records = append(records, domain.ExpectedDNSRecord{ Name: fqdn, @@ -96,9 +145,9 @@ func (s SVCBStyle) Records(reg *domain.AgentRegistration) []domain.ExpectedDNSRe } // Compile-time interface satisfaction check. Catches accidental -// signature drift on port.DiscoveryStyle without needing a runtime +// signature drift on port.ProfileEmitter without needing a runtime // assertion in cmd/main. -var _ port.DiscoveryStyle = SVCBStyle{} +var _ port.ProfileEmitter = SVCBProfile{} // svcbPortFor returns the TCP port to advertise in the SVCB SvcParam // `port=`. Reads it from the endpoint URL's authority. Falls back to @@ -127,15 +176,18 @@ func svcbPortFor(agentURL string) int { return 443 } -// metadataHashToCardSHA256 converts an AgentEndpoint.MetadataHash +// metadataHashToCapSHA256 converts an AgentEndpoint.MetadataHash // (`SHA256:<64-hex-chars>`) into the base64url form (RFC 4648 §5, -// no padding) the SVCB `card-sha256` SvcParam expects. Empty input, -// missing prefix, or malformed hex all return the empty string, -// which the caller treats as "omit the SvcParam entirely". The -// domain layer (endpoint.go's metadataHashPattern) validates the -// canonical shape on input, so the defensive returns here exist -// for boundary safety only. -func metadataHashToCardSHA256(metadataHash string) string { +// no padding) the SVCB capability-digest SvcParam (key65281, draft +// `cap-sha256`) expects. Empty input, missing prefix, malformed hex, +// or a digest that isn't exactly 32 bytes all return the empty +// string, which the caller treats as "omit the SvcParam entirely". +// The domain layer (endpoint.go's metadataHashPattern) validates the +// canonical shape on input, so the defensive returns here exist for +// boundary safety only — the length check makes this function's +// defensiveness match the SHA-256 contract it documents rather than +// relying solely on the upstream regex. +func metadataHashToCapSHA256(metadataHash string) string { if metadataHash == "" { return "" } @@ -144,7 +196,7 @@ func metadataHashToCardSHA256(metadataHash string) string { return "" } raw, err := hex.DecodeString(strings.TrimPrefix(metadataHash, prefix)) - if err != nil || len(raw) == 0 { + if err != nil || len(raw) != sha256.Size { return "" } return base64.RawURLEncoding.EncodeToString(raw) diff --git a/internal/adapter/discovery/ans/svcb_test.go b/internal/adapter/discovery/ans/svcb_test.go index a2b2cb3..878b7ac 100644 --- a/internal/adapter/discovery/ans/svcb_test.go +++ b/internal/adapter/discovery/ans/svcb_test.go @@ -23,16 +23,20 @@ func mustReg(t *testing.T, host string, eps []domain.AgentEndpoint, cert *domain } } -func TestSVCBStyle_ID(t *testing.T) { - assert.Equal(t, domain.DNSRecordStyleSVCB, SVCBStyle{}.ID()) +func TestSVCBProfile_ID(t *testing.T) { + assert.Equal(t, domain.DiscoveryProfileANSSVCB, SVCBProfile{}.ID()) } -// TestSVCBStyle_Records walks the SvcParam composition rules (alpn / -// port / wk / card-sha256) the consolidated-draft fixes, plus the -// always-Required default the service walker post-processes. -func TestSVCBStyle_Records(t *testing.T) { +// TestSVCBProfile_Records walks the SvcParam composition rules (alpn / +// port / key65280 / key65281) the consolidated-draft fixes, plus the +// always-Required default the service walker post-processes. The +// well-known suffix and capability digest ride in RFC 9460 §14.3.1 +// Private Use keyNNNNN SvcParams (key65280 / key65281), not the named +// forms `wk=` / `card-sha256=` — those have no IANA code point and the +// miekg/dns zone parser rejects them. +func TestSVCBProfile_Records(t *testing.T) { const sampleMetadataHash = "SHA256:098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27" - const wantSampleCardBase64 = "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc" + const wantSampleCapBase64 = "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc" tests := []struct { name string @@ -40,8 +44,8 @@ func TestSVCBStyle_Records(t *testing.T) { wantCount int // svcb rows expected wantPort string wantAlpn string - wantWk string // empty means MUST NOT appear - wantCard string // empty means MUST NOT appear + wantWk string // empty means MUST NOT appear (well-known suffix in key65280) + wantCap string // empty means MUST NOT appear (capability digest in key65281) wantNotPort string // value MUST NOT contain this string (e.g. wrong default) }{ { @@ -52,7 +56,7 @@ func TestSVCBStyle_Records(t *testing.T) { wantCount: 1, wantPort: "port=443", wantAlpn: "alpn=a2a", - wantWk: "wk=agent-card.json", + wantWk: "key65280=agent-card.json", }, { name: "mcp_emits_mcp_json_well_known", @@ -62,7 +66,7 @@ func TestSVCBStyle_Records(t *testing.T) { wantCount: 1, wantPort: "port=443", wantAlpn: "alpn=mcp", - wantWk: "wk=mcp.json", + wantWk: "key65280=mcp.json", }, { name: "http_api_omits_wk", @@ -75,7 +79,7 @@ func TestSVCBStyle_Records(t *testing.T) { wantWk: "", // HTTP-API has no per-protocol metadata file }, { - name: "card_sha256_present_when_endpoint_metadata_hash_set", + name: "cap_sha256_present_when_endpoint_metadata_hash_set", eps: []domain.AgentEndpoint{ { Protocol: domain.ProtocolA2A, @@ -86,8 +90,8 @@ func TestSVCBStyle_Records(t *testing.T) { wantCount: 1, wantPort: "port=443", wantAlpn: "alpn=a2a", - wantWk: "wk=agent-card.json", - wantCard: "card-sha256=" + wantSampleCardBase64, + wantWk: "key65280=agent-card.json", + wantCap: "key65281=" + wantSampleCapBase64, }, { name: "non_443_port_from_url_authority", @@ -97,7 +101,7 @@ func TestSVCBStyle_Records(t *testing.T) { wantCount: 1, wantPort: "port=8443", wantAlpn: "alpn=a2a", - wantWk: "wk=agent-card.json", + wantWk: "key65280=agent-card.json", wantNotPort: "port=443", }, { @@ -108,14 +112,14 @@ func TestSVCBStyle_Records(t *testing.T) { wantCount: 1, wantPort: "port=80", wantAlpn: "alpn=a2a", - wantWk: "wk=agent-card.json", + wantWk: "key65280=agent-card.json", }, { // First row asserted below; assertions on the A2A protocol's - // SvcParam composition (port, alpn, wk). The MCP row's wk=mcp.json - // is covered by the dedicated mcp test case above; here we only - // pin that the count is right and the row order tracks endpoint - // order. + // SvcParam composition (port, alpn, key65280). The MCP row's + // key65280=mcp.json is covered by the dedicated mcp test case + // above; here we only pin that the count is right and the row + // order tracks endpoint order. name: "two_endpoints_emits_two_svcb_rows", eps: []domain.AgentEndpoint{ {Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com/a2a"}, @@ -124,7 +128,7 @@ func TestSVCBStyle_Records(t *testing.T) { wantCount: 2, wantPort: "port=443", wantAlpn: "alpn=a2a", - wantWk: "wk=agent-card.json", + wantWk: "key65280=agent-card.json", }, { name: "zero_endpoints_emits_no_svcb_rows", @@ -136,7 +140,7 @@ func TestSVCBStyle_Records(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { reg := mustReg(t, "agent.example.com", tc.eps, nil) - records := SVCBStyle{}.Records(reg) + records := SVCBProfile{}.Records(reg) var svcbRows []domain.ExpectedDNSRecord for _, r := range records { @@ -168,28 +172,40 @@ func TestSVCBStyle_Records(t *testing.T) { if tc.wantWk != "" { assert.Contains(t, r.Value, tc.wantWk) } else { - assert.NotContains(t, r.Value, "wk=", "wk= MUST be absent for protocols with no metadata file convention") + assert.NotContains(t, r.Value, "key65280=", + "key65280 (well-known suffix) MUST be absent for protocols with no metadata file convention") } - if tc.wantCard != "" { - assert.Contains(t, r.Value, tc.wantCard) + if tc.wantCap != "" { + assert.Contains(t, r.Value, tc.wantCap) } else { - assert.NotContains(t, r.Value, "card-sha256", - "card-sha256 MUST be absent when endpoint MetadataHash is empty") + assert.NotContains(t, r.Value, "key65281=", + "key65281 (capability digest) MUST be absent when endpoint MetadataHash is empty") } + // Named-form regression guards: every SVCB value must use the + // keyNNNNN Private Use presentation, never the unpublishable + // named forms. miekg/dns rejects `wk=` / `card-sha256=` at the + // zone parser (proven empirically), so a backslide here strands + // agents in PENDING_DNS under the lookup verifier. + assert.NotContains(t, r.Value, "wk=", + "named `wk=` SvcParam MUST NOT appear; key65280 is the publishable form") + assert.NotContains(t, r.Value, "cap-sha256", + "named `cap-sha256=` SvcParam MUST NOT appear; key65281 is the publishable form") + assert.NotContains(t, r.Value, "card-sha256", + "legacy `card-sha256=` SvcParam MUST NOT appear; key65281 is the publishable form") }) } } -// TestSVCBStyle_RecordsIncludesFamilyTrustRecords pins that SVCBStyle +// TestSVCBProfile_RecordsIncludesFamilyTrustRecords pins that SVCBProfile // is self-contained — it emits the family's badge and TLSA records too, // so registering ANS_SVCB alone produces a complete set without any // service-layer trust-record plumbing. -func TestSVCBStyle_RecordsIncludesFamilyTrustRecords(t *testing.T) { +func TestSVCBProfile_RecordsIncludesFamilyTrustRecords(t *testing.T) { reg := mustReg(t, "agent.example.com", []domain.AgentEndpoint{{Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}}, &domain.ByocServerCertificate{Fingerprint: "deadbeef"}) - records := SVCBStyle{}.Records(reg) + records := SVCBProfile{}.Records(reg) var sawBadge, sawTLSA bool for _, r := range records { @@ -206,7 +222,7 @@ func TestSVCBStyle_RecordsIncludesFamilyTrustRecords(t *testing.T) { assert.True(t, sawTLSA, "SVCB style must include the TLSA record when ServerCert is set") } -func TestMetadataHashToCardSHA256(t *testing.T) { +func TestMetadataHashToCapSHA256(t *testing.T) { tests := []struct { name string in string @@ -229,7 +245,7 @@ func TestMetadataHashToCardSHA256(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.want, metadataHashToCardSHA256(tc.in)) + assert.Equal(t, tc.want, metadataHashToCapSHA256(tc.in)) }) } } diff --git a/internal/adapter/discovery/ans/tlsa.go b/internal/adapter/discovery/ans/tlsa.go index 417f5eb..c40e615 100644 --- a/internal/adapter/discovery/ans/tlsa.go +++ b/internal/adapter/discovery/ans/tlsa.go @@ -2,17 +2,37 @@ package ans import ( "fmt" + "net/url" + "sort" "github.com/godaddy/ans/internal/domain" ) -// TLSARecord returns the TLSA cert-binding record every ANS-family -// style emits for the agent's server cert. Returns a one-element slice -// when reg.ServerCert is set; returns an empty slice otherwise. +// TLSARecord returns the TLSA cert-binding records every ANS-family +// profile emits for the agent's server cert — one record per distinct +// TLS endpoint port. Returns an empty slice when reg.ServerCert is nil. // -// Both ANS_SVCB and ANS_TXT styles call TLSARecord. When both are in +// Both ANS_SVCB and ANS_TXT profiles call TLSARecord. When both are in // the resolved set the service walker dedupes on (Name, Type, Value) -// so the TLSA lands once. +// so each TLSA lands once. +// +// Owner name carries the port (RFC 6698): `_._tcp.`. A +// DANE client connecting to a non-443 endpoint (8443 etc.) queries +// `_8443._tcp.` — emitting only `_443._tcp.` strands it. Ports come +// from the endpoint URLs (via svcbPortFor, the same source the SVCB +// row's `port=` SvcParam uses, so the TLSA owner name and the +// advertised port agree). Plaintext `http` endpoints are skipped — +// there is no TLS to bind. When the collected port set is empty (no +// endpoints, or all plaintext) the 443 fallback fires so a ServerCert +// always gets a cert-binding record. +// +// Ports are sorted numerically ascending. Deterministic order is +// load-bearing twice: the TL canonical-bytes invariant +// (ComputeRequiredDNSRecords feeds the V2 leaf hash) and the V1 +// revoke map's injectivity (v1event keys the DNSRecordsToRemove map on +// Name, so distinct `_._tcp.` names must stay distinct — which +// requires ports be deduped). The sort is numeric, not lexical: port +// 443 sorts before 1024 even though "_1024" < "_443" as strings. // // Required=false: TLSA is only meaningful when the operator's zone is // DNSSEC-signed, which is a runtime property the domain layer cannot @@ -22,17 +42,72 @@ import ( // a signed zone — the worst failure mode). That post-verify check // lives alongside the verifier (lifecycle.go), not in the record set. // -// `3 1 1 ` = DANE-EE + SubjectPublicKeyInfo + SHA-256 (RFC 6698). +// `3 0 1 ` = DANE-EE + full-certificate + SHA-256 (RFC 6698). +// Selector 0 (full cert), not 1 (SPKI): the hex is +// ServerCert.Fingerprint, which is SHA-256 over the full DER cert (see +// internal/crypto/x509.go CertificateFingerprint), so selector 0 is +// what actually matches those bytes. Selector 0 ties the binding to +// full-cert reissuance — the TLSA value changes on every cert rotation +// even when the key is unchanged. Surviving rotation (RFC 7671 §5.1's +// 3 1 1 SPKI-hash preference) would need a dedicated SPKI-hash helper, +// not CertificateFingerprint. func TLSARecord(reg *domain.AgentRegistration) []domain.ExpectedDNSRecord { if reg.ServerCert == nil { return nil } - return []domain.ExpectedDNSRecord{{ - Name: fmt.Sprintf("_443._tcp.%s", reg.FQDN()), - Type: domain.DNSRecordTLSA, - Value: fmt.Sprintf("3 1 1 %s", reg.ServerCert.Fingerprint), - Purpose: domain.PurposeCertificateBinding, - Required: false, - TTL: 3600, - }} + + ports := tlsPorts(reg.Endpoints) + + records := make([]domain.ExpectedDNSRecord, 0, len(ports)) + for _, port := range ports { + records = append(records, domain.ExpectedDNSRecord{ + Name: fmt.Sprintf("_%d._tcp.%s", port, reg.FQDN()), + Type: domain.DNSRecordTLSA, + Value: fmt.Sprintf("3 0 1 %s", reg.ServerCert.Fingerprint), + Purpose: domain.PurposeCertificateBinding, + Required: false, + TTL: 3600, + }) + } + return records +} + +// tlsPorts returns the distinct TLS endpoint ports, sorted numerically +// ascending. Plaintext `http` endpoints are skipped. An empty result +// (no endpoints, or all plaintext) falls back to {443} so the cert +// binding always has a home. +func tlsPorts(endpoints []domain.AgentEndpoint) []int { + seen := make(map[int]struct{}, len(endpoints)) + for _, ep := range endpoints { + if isPlaintextHTTP(ep.AgentURL) { + continue + } + seen[svcbPortFor(ep.AgentURL)] = struct{}{} + } + if len(seen) == 0 { + return []int{443} + } + ports := make([]int, 0, len(seen)) + for p := range seen { + ports = append(ports, p) + } + sort.Ints(ports) + return ports +} + +// isPlaintextHTTP reports whether agentURL uses the plaintext `http` +// scheme (no TLS to bind). Any other scheme — including a malformed or +// schemeless URL — is treated as TLS, matching svcbPortFor's +// default-to-443 posture: a TLSA record for an endpoint we can't fully +// parse is harmless (Required=false), but skipping one would silently +// drop a cert binding. +func isPlaintextHTTP(agentURL string) bool { + if agentURL == "" { + return false + } + u, err := url.Parse(agentURL) + if err != nil { + return false + } + return u.Scheme == "http" } diff --git a/internal/adapter/discovery/ans/tlsa_test.go b/internal/adapter/discovery/ans/tlsa_test.go index be07f6b..10609f4 100644 --- a/internal/adapter/discovery/ans/tlsa_test.go +++ b/internal/adapter/discovery/ans/tlsa_test.go @@ -10,34 +10,111 @@ import ( ) func TestTLSARecord(t *testing.T) { - mustReg := func(t *testing.T, host string, cert *domain.ByocServerCertificate) *domain.AgentRegistration { + mustRegWithEndpoints := func(t *testing.T, host string, eps []domain.AgentEndpoint, cert *domain.ByocServerCertificate) *domain.AgentRegistration { t.Helper() v, err := domain.NewSemVer(1, 0, 0) require.NoError(t, err) ansName, err := domain.NewAnsName(v, host) require.NoError(t, err) - return &domain.AgentRegistration{AnsName: ansName, ServerCert: cert} + return &domain.AgentRegistration{AnsName: ansName, Endpoints: eps, ServerCert: cert} } + const fp = "abcdef0123456789" + tests := []struct { name string reg *domain.AgentRegistration wantEmpty bool - wantName string - wantValue string + // wantNamesInOrder is the exact sequence of TLSA owner names the + // record set must contain, in order. Ports are sorted numerically + // ascending, so this also pins the sort. + wantNamesInOrder []string + wantValue string // applied to every emitted record }{ { name: "no_server_cert_emits_no_tlsa", - reg: mustReg(t, "agent.example.com", nil), + reg: mustRegWithEndpoints(t, "agent.example.com", nil, nil), wantEmpty: true, }, { - name: "with_cert_emits_dane_ee_spki_sha256_record", - reg: mustReg(t, "agent.example.com", &domain.ByocServerCertificate{ - Fingerprint: "abcdef0123456789", - }), - wantName: "_443._tcp.agent.example.com", - wantValue: "3 1 1 abcdef0123456789", + // 443 fallback: ServerCert set but no endpoints carry a port. + // Exactly one record at _443._tcp. + name: "no_endpoints_falls_back_to_443", + reg: mustRegWithEndpoints(t, "agent.example.com", nil, + &domain.ByocServerCertificate{Fingerprint: fp}), + wantNamesInOrder: []string{"_443._tcp.agent.example.com"}, + wantValue: "3 0 1 " + fp, + }, + { + name: "single_https_non_443_port", + reg: mustRegWithEndpoints(t, "agent.example.com", + []domain.AgentEndpoint{ + {Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com:8443"}, + }, + &domain.ByocServerCertificate{Fingerprint: fp}), + wantNamesInOrder: []string{"_8443._tcp.agent.example.com"}, + wantValue: "3 0 1 " + fp, + }, + { + // Numeric-not-lexical sort + dedupe. Ports 443 and 1024 sort + // 443 < 1024 numerically, but "_1024" < "_443" lexically + // (since '1' < '4'). The output MUST be _443 then _1024, + // proving the sort is by int, not by string. + name: "two_ports_443_and_1024_sort_numerically", + reg: mustRegWithEndpoints(t, "agent.example.com", + []domain.AgentEndpoint{ + {Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com:443"}, + {Protocol: domain.ProtocolMCP, AgentURL: "https://agent.example.com:1024"}, + }, + &domain.ByocServerCertificate{Fingerprint: fp}), + wantNamesInOrder: []string{ + "_443._tcp.agent.example.com", + "_1024._tcp.agent.example.com", + }, + wantValue: "3 0 1 " + fp, + }, + { + // Duplicate ports across endpoints collapse to one record. + name: "duplicate_ports_deduped", + reg: mustRegWithEndpoints(t, "agent.example.com", + []domain.AgentEndpoint{ + {Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}, + {Protocol: domain.ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, + }, + &domain.ByocServerCertificate{Fingerprint: fp}), + wantNamesInOrder: []string{"_443._tcp.agent.example.com"}, + wantValue: "3 0 1 " + fp, + }, + { + // All-plaintext (http) endpoints + ServerCert set. The cert + // binding still needs a home, so the 443 fallback fires: + // exactly one record at _443._tcp. + name: "all_plaintext_endpoints_with_cert_falls_back_to_443", + reg: mustRegWithEndpoints(t, "agent.example.com", + []domain.AgentEndpoint{ + {Protocol: domain.ProtocolA2A, AgentURL: "http://agent.example.com"}, + {Protocol: domain.ProtocolMCP, AgentURL: "http://agent.example.com:8080"}, + }, + &domain.ByocServerCertificate{Fingerprint: fp}), + wantNamesInOrder: []string{"_443._tcp.agent.example.com"}, + wantValue: "3 0 1 " + fp, + }, + { + // Boundary URLs: empty and unparseable AgentURLs are treated + // as TLS (not plaintext http), so they take svcbPortFor's + // default-443 path. Both collapse to one _443._tcp record. A + // TLSA for an endpoint we can't fully parse is harmless + // (Required=false); dropping one would silently lose a cert + // binding. + name: "empty_and_malformed_urls_treated_as_tls_default_443", + reg: mustRegWithEndpoints(t, "agent.example.com", + []domain.AgentEndpoint{ + {Protocol: domain.ProtocolA2A, AgentURL: ""}, + {Protocol: domain.ProtocolMCP, AgentURL: "://not-a-url"}, + }, + &domain.ByocServerCertificate{Fingerprint: fp}), + wantNamesInOrder: []string{"_443._tcp.agent.example.com"}, + wantValue: "3 0 1 " + fp, }, } for _, tc := range tests { @@ -47,14 +124,19 @@ func TestTLSARecord(t *testing.T) { assert.Empty(t, got) return } - require.Len(t, got, 1) - r := got[0] - assert.Equal(t, tc.wantName, r.Name) - assert.Equal(t, domain.DNSRecordTLSA, r.Type) - assert.Equal(t, tc.wantValue, r.Value) - assert.Equal(t, domain.PurposeCertificateBinding, r.Purpose) - assert.False(t, r.Required, "TLSA is non-required because operator zones may not be DNSSEC-signed") - assert.Equal(t, 3600, r.TTL) + require.Len(t, got, len(tc.wantNamesInOrder), + "TLSA record count mismatch") + for i, r := range got { + assert.Equal(t, tc.wantNamesInOrder[i], r.Name, + "TLSA owner name / sort order mismatch at index %d", i) + assert.Equal(t, domain.DNSRecordTLSA, r.Type) + assert.Equal(t, tc.wantValue, r.Value, + "TLSA value must be `3 0 1 ` (DANE-EE, selector 0)") + assert.Equal(t, domain.PurposeCertificateBinding, r.Purpose) + assert.False(t, r.Required, + "TLSA is non-required because operator zones may not be DNSSEC-signed") + assert.Equal(t, 3600, r.TTL) + } }) } } diff --git a/internal/adapter/discovery/ans/txt.go b/internal/adapter/discovery/ans/txt.go index d95675d..e8eb273 100644 --- a/internal/adapter/discovery/ans/txt.go +++ b/internal/adapter/discovery/ans/txt.go @@ -7,7 +7,7 @@ import ( "github.com/godaddy/ans/internal/port" ) -// TXTStyle implements port.DiscoveryStyle for the original `_ans` TXT +// TXTProfile implements port.ProfileEmitter for the original `_ans` TXT // shape (ANS_TXT). It emits one TXT row per protocol endpoint at // `_ans.` plus an HTTPS RR at the bare FQDN (when at least one // endpoint is present), plus the ANS-family trust records. @@ -24,28 +24,28 @@ import ( // Required=false because operators on CNAME-fronted apex zones cannot // publish this record at the same name (CNAME at @ blocks HTTPS RR // per RFC 1034 §3.6.2); the spec does not block them on its absence. -type TXTStyle struct { +type TXTProfile struct { // tlPublicBaseURL feeds the family `_ans-badge` url= via BadgeRecord // (empty falls the badge back to the agent's own endpoint URL). Set - // once by NewTXTStyle; styles are immutable after wiring, so Records + // once by NewTXTProfile; profiles are immutable after wiring, so Records // stays a pure function of reg. tlPublicBaseURL string } -// NewTXTStyle builds an ANS_TXT style whose family `_ans-badge` record +// NewTXTProfile builds an ANS_TXT profile whose family `_ans-badge` record // points at the transparency log at tlPublicBaseURL. Empty tlPublicBaseURL // falls the badge back to the agent's own endpoint URL. -func NewTXTStyle(tlPublicBaseURL string) TXTStyle { - return TXTStyle{tlPublicBaseURL: tlPublicBaseURL} +func NewTXTProfile(tlPublicBaseURL string) TXTProfile { + return TXTProfile{tlPublicBaseURL: tlPublicBaseURL} } // ID returns ANS_TXT. -func (TXTStyle) ID() domain.DNSRecordStyle { return domain.DNSRecordStyleTXT } +func (TXTProfile) ID() domain.DiscoveryProfile { return domain.DiscoveryProfileANSTXT } // Records returns the `_ans` TXT rows (one per endpoint) plus the // HTTPS RR (when at least one endpoint exists) plus the family trust // records. -func (s TXTStyle) Records(reg *domain.AgentRegistration) []domain.ExpectedDNSRecord { +func (s TXTProfile) Records(reg *domain.AgentRegistration) []domain.ExpectedDNSRecord { fqdn := reg.FQDN() version := reg.AnsName.Version().String() var records []domain.ExpectedDNSRecord @@ -76,4 +76,4 @@ func (s TXTStyle) Records(reg *domain.AgentRegistration) []domain.ExpectedDNSRec return records } -var _ port.DiscoveryStyle = TXTStyle{} +var _ port.ProfileEmitter = TXTProfile{} diff --git a/internal/adapter/discovery/ans/txt_test.go b/internal/adapter/discovery/ans/txt_test.go index 414572e..d8ef2e8 100644 --- a/internal/adapter/discovery/ans/txt_test.go +++ b/internal/adapter/discovery/ans/txt_test.go @@ -10,11 +10,11 @@ import ( "github.com/godaddy/ans/internal/domain" ) -func TestTXTStyle_ID(t *testing.T) { - assert.Equal(t, domain.DNSRecordStyleTXT, TXTStyle{}.ID()) +func TestTXTProfile_ID(t *testing.T) { + assert.Equal(t, domain.DiscoveryProfileANSTXT, TXTProfile{}.ID()) } -func TestTXTStyle_Records(t *testing.T) { +func TestTXTProfile_Records(t *testing.T) { tests := []struct { name string eps []domain.AgentEndpoint @@ -65,7 +65,7 @@ func TestTXTStyle_Records(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { reg := mustReg(t, "agent.example.com", tc.eps, nil) - records := TXTStyle{}.Records(reg) + records := TXTProfile{}.Records(reg) var txtRows int var httpsRows int @@ -104,15 +104,15 @@ func TestTXTStyle_Records(t *testing.T) { } } -// TestTXTStyle_RecordsIncludesFamilyTrustRecords pins that TXTStyle is -// self-contained in the same way as SVCBStyle: it emits the family +// TestTXTProfile_RecordsIncludesFamilyTrustRecords pins that TXTProfile is +// self-contained in the same way as SVCBProfile: it emits the family // badge and TLSA records. -func TestTXTStyle_RecordsIncludesFamilyTrustRecords(t *testing.T) { +func TestTXTProfile_RecordsIncludesFamilyTrustRecords(t *testing.T) { reg := mustReg(t, "agent.example.com", []domain.AgentEndpoint{{Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}}, &domain.ByocServerCertificate{Fingerprint: "deadbeef"}) - records := TXTStyle{}.Records(reg) + records := TXTProfile{}.Records(reg) var sawBadge, sawTLSA bool for _, r := range records { @@ -127,23 +127,23 @@ func TestTXTStyle_RecordsIncludesFamilyTrustRecords(t *testing.T) { assert.True(t, sawTLSA) } -// TestTXTStyle_NoEndpointsSkipsAllFamilyAndDiscoveryRecords pins the +// TestTXTProfile_NoEndpointsSkipsAllFamilyAndDiscoveryRecords pins the // existing-behavior contract that an empty endpoint list produces an // empty record set when ServerCert is also nil. Zero endpoints + nil // cert means there's nothing meaningful to publish. -func TestTXTStyle_NoEndpointsSkipsAllFamilyAndDiscoveryRecords(t *testing.T) { +func TestTXTProfile_NoEndpointsSkipsAllFamilyAndDiscoveryRecords(t *testing.T) { reg := mustReg(t, "agent.example.com", nil, nil) - records := TXTStyle{}.Records(reg) + records := TXTProfile{}.Records(reg) require.Empty(t, records) } -// TestTXTStyle_ZeroEndpointsWithCertOnlyEmitsTLSA pins that even with +// TestTXTProfile_ZeroEndpointsWithCertOnlyEmitsTLSA pins that even with // zero endpoints, a registration that has a server cert still gets the // TLSA record. (The badge requires endpoints; TLSA does not.) -func TestTXTStyle_ZeroEndpointsWithCertOnlyEmitsTLSA(t *testing.T) { +func TestTXTProfile_ZeroEndpointsWithCertOnlyEmitsTLSA(t *testing.T) { reg := mustReg(t, "agent.example.com", nil, &domain.ByocServerCertificate{Fingerprint: "abcd"}) - records := TXTStyle{}.Records(reg) + records := TXTProfile{}.Records(reg) require.Len(t, records, 1) assert.Equal(t, domain.DNSRecordTLSA, records[0].Type) } diff --git a/internal/adapter/discovery/registry/registry.go b/internal/adapter/discovery/registry/registry.go index 556b096..c7817e8 100644 --- a/internal/adapter/discovery/registry/registry.go +++ b/internal/adapter/discovery/registry/registry.go @@ -1,12 +1,12 @@ // Package registry holds the immutable composition facade for -// port.DiscoveryStyle implementations. It is the bundled -// port.DiscoveryRegistry: cmd/ans-ra/main.go calls registry.New(...) -// with the styles the binary should serve, and the service consumes the +// port.ProfileEmitter implementations. It is the bundled +// port.ProfileRegistry: cmd/ans-ra/main.go calls registry.New(...) +// with the profiles the binary should serve, and the service consumes the // returned *Registry through the port interface. // // The registry itself is intentionally small (no global state, no -// init-time registration, no plug-in loading). Adding a new style is a -// matter of registering an additional port.DiscoveryStyle in +// init-time registration, no plug-in loading). Adding a new profile is a +// matter of registering an additional port.ProfileEmitter in // cmd/ans-ra/main.go — the contributor walk-through lives in // docs/contributing-discovery-profiles.md. package registry @@ -18,18 +18,18 @@ import ( "github.com/godaddy/ans/internal/port" ) -// Registry composes port.DiscoveryStyle implementations by ID. Immutable +// Registry composes port.ProfileEmitter implementations by ID. Immutable // post-construction: New stores both a lookup map (for O(1) Get) and an // insertion-order slice (for stable IDs() iteration). Reads are safe for // concurrent use without locking; there is no Add/Remove API. type Registry struct { - styles map[domain.DNSRecordStyle]port.DiscoveryStyle - order []domain.DNSRecordStyle + profiles map[domain.DiscoveryProfile]port.ProfileEmitter + order []domain.DiscoveryProfile } -// New constructs a Registry from the given styles in argument order. -// Returns an error when any style's ID is invalid (per -// domain.DNSRecordStyle.IsValid) or when two styles share the same ID — +// New constructs a Registry from the given profiles in argument order. +// Returns an error when any profile's ID is invalid (per +// domain.DiscoveryProfile.IsValid) or when two profiles share the same ID — // both are deterministic startup misconfigurations the wiring code in // cmd/ans-ra/main.go must surface as a fail-loud server-start error, // rather than degrading silently at the first registration. @@ -37,37 +37,37 @@ type Registry struct { // Iteration order matches argument order (stable across process restarts // for a given wiring) so the service walker's emission order on the wire // is determined here, not by request input. -func New(styles ...port.DiscoveryStyle) (*Registry, error) { +func New(profiles ...port.ProfileEmitter) (*Registry, error) { r := &Registry{ - styles: make(map[domain.DNSRecordStyle]port.DiscoveryStyle, len(styles)), - order: make([]domain.DNSRecordStyle, 0, len(styles)), + profiles: make(map[domain.DiscoveryProfile]port.ProfileEmitter, len(profiles)), + order: make([]domain.DiscoveryProfile, 0, len(profiles)), } - for _, s := range styles { + for _, s := range profiles { id := s.ID() if !id.IsValid() { - return nil, fmt.Errorf("registry: style ID %q is not a valid DNSRecordStyle", id) + return nil, fmt.Errorf("registry: profile ID %q is not a valid DiscoveryProfile", id) } - if _, dup := r.styles[id]; dup { - return nil, fmt.Errorf("registry: duplicate style ID %q", id) + if _, dup := r.profiles[id]; dup { + return nil, fmt.Errorf("registry: duplicate profile ID %q", id) } - r.styles[id] = s + r.profiles[id] = s r.order = append(r.order, id) } return r, nil } -// Get returns the style registered under id, or (nil, false) when no such -// style is wired. Implements port.DiscoveryRegistry. -func (r *Registry) Get(id domain.DNSRecordStyle) (port.DiscoveryStyle, bool) { - s, ok := r.styles[id] +// Get returns the profile registered under id, or (nil, false) when no such +// profile is wired. Implements port.ProfileRegistry. +func (r *Registry) Get(id domain.DiscoveryProfile) (port.ProfileEmitter, bool) { + s, ok := r.profiles[id] return s, ok } -// IDs returns the registered style IDs in insertion order. The returned +// IDs returns the registered profile IDs in insertion order. The returned // slice is a fresh copy; callers may mutate it without affecting the -// registry. Implements port.DiscoveryRegistry. -func (r *Registry) IDs() []domain.DNSRecordStyle { - out := make([]domain.DNSRecordStyle, len(r.order)) +// registry. Implements port.ProfileRegistry. +func (r *Registry) IDs() []domain.DiscoveryProfile { + out := make([]domain.DiscoveryProfile, len(r.order)) copy(out, r.order) return out } diff --git a/internal/adapter/discovery/registry/registry_test.go b/internal/adapter/discovery/registry/registry_test.go index e8b62b5..0c8fe8a 100644 --- a/internal/adapter/discovery/registry/registry_test.go +++ b/internal/adapter/discovery/registry/registry_test.go @@ -10,71 +10,71 @@ import ( "github.com/godaddy/ans/internal/port" ) -// fakeStyle is a minimal port.DiscoveryStyle test double. ID() is the +// fakeProfile is a minimal port.ProfileEmitter test double. ID() is the // only behavior the registry inspects; Records() returns nil so the // fake stays cheap to instantiate in tables. -type fakeStyle struct{ id domain.DNSRecordStyle } +type fakeProfile struct{ id domain.DiscoveryProfile } -func (f fakeStyle) ID() domain.DNSRecordStyle { return f.id } -func (f fakeStyle) Records(*domain.AgentRegistration) []domain.ExpectedDNSRecord { +func (f fakeProfile) ID() domain.DiscoveryProfile { return f.id } +func (f fakeProfile) Records(*domain.AgentRegistration) []domain.ExpectedDNSRecord { return nil } func TestNew(t *testing.T) { tests := []struct { name string - styles []port.DiscoveryStyle + profiles []port.ProfileEmitter wantErr string // substring; empty means success expected - wantOrder []domain.DNSRecordStyle + wantOrder []domain.DiscoveryProfile }{ { name: "empty_registry_constructs", - styles: nil, - wantOrder: []domain.DNSRecordStyle{}, + profiles: nil, + wantOrder: []domain.DiscoveryProfile{}, }, { - name: "single_valid_style", - styles: []port.DiscoveryStyle{fakeStyle{id: domain.DNSRecordStyleSVCB}}, - wantOrder: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + name: "single_valid_profile", + profiles: []port.ProfileEmitter{fakeProfile{id: domain.DiscoveryProfileANSSVCB}}, + wantOrder: []domain.DiscoveryProfile{domain.DiscoveryProfileANSSVCB}, }, { - name: "two_valid_styles_preserve_argument_order", - styles: []port.DiscoveryStyle{ - fakeStyle{id: domain.DNSRecordStyleTXT}, - fakeStyle{id: domain.DNSRecordStyleSVCB}, + name: "two_valid_profiles_preserve_argument_order", + profiles: []port.ProfileEmitter{ + fakeProfile{id: domain.DiscoveryProfileANSTXT}, + fakeProfile{id: domain.DiscoveryProfileANSSVCB}, }, - wantOrder: []domain.DNSRecordStyle{ - domain.DNSRecordStyleTXT, - domain.DNSRecordStyleSVCB, + wantOrder: []domain.DiscoveryProfile{ + domain.DiscoveryProfileANSTXT, + domain.DiscoveryProfileANSSVCB, }, }, { name: "duplicate_id_rejected", - styles: []port.DiscoveryStyle{ - fakeStyle{id: domain.DNSRecordStyleSVCB}, - fakeStyle{id: domain.DNSRecordStyleSVCB}, + profiles: []port.ProfileEmitter{ + fakeProfile{id: domain.DiscoveryProfileANSSVCB}, + fakeProfile{id: domain.DiscoveryProfileANSSVCB}, }, - wantErr: "duplicate style ID", + wantErr: "duplicate profile ID", }, { name: "invalid_id_rejected", - styles: []port.DiscoveryStyle{ - fakeStyle{id: domain.DNSRecordStyle("NOT_A_STYLE")}, + profiles: []port.ProfileEmitter{ + fakeProfile{id: domain.DiscoveryProfile("NOT_A_STYLE")}, }, - wantErr: "is not a valid DNSRecordStyle", + wantErr: "is not a valid DiscoveryProfile", }, { name: "invalid_id_rejected_after_valid_one", - styles: []port.DiscoveryStyle{ - fakeStyle{id: domain.DNSRecordStyleSVCB}, - fakeStyle{id: domain.DNSRecordStyle("")}, + profiles: []port.ProfileEmitter{ + fakeProfile{id: domain.DiscoveryProfileANSSVCB}, + fakeProfile{id: domain.DiscoveryProfile("")}, }, - wantErr: "is not a valid DNSRecordStyle", + wantErr: "is not a valid DiscoveryProfile", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - r, err := New(tc.styles...) + r, err := New(tc.profiles...) if tc.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tc.wantErr) @@ -89,21 +89,21 @@ func TestNew(t *testing.T) { } func TestGet(t *testing.T) { - svcb := fakeStyle{id: domain.DNSRecordStyleSVCB} - txt := fakeStyle{id: domain.DNSRecordStyleTXT} + svcb := fakeProfile{id: domain.DiscoveryProfileANSSVCB} + txt := fakeProfile{id: domain.DiscoveryProfileANSTXT} r, err := New(svcb, txt) require.NoError(t, err) tests := []struct { name string - id domain.DNSRecordStyle + id domain.DiscoveryProfile wantHit bool - wantID domain.DNSRecordStyle + wantID domain.DiscoveryProfile }{ - {name: "hit_svcb", id: domain.DNSRecordStyleSVCB, wantHit: true, wantID: domain.DNSRecordStyleSVCB}, - {name: "hit_txt", id: domain.DNSRecordStyleTXT, wantHit: true, wantID: domain.DNSRecordStyleTXT}, - {name: "miss_unknown_style", id: domain.DNSRecordStyle("UNKNOWN_FAMILY"), wantHit: false}, - {name: "miss_empty_id", id: domain.DNSRecordStyle(""), wantHit: false}, + {name: "hit_svcb", id: domain.DiscoveryProfileANSSVCB, wantHit: true, wantID: domain.DiscoveryProfileANSSVCB}, + {name: "hit_txt", id: domain.DiscoveryProfileANSTXT, wantHit: true, wantID: domain.DiscoveryProfileANSTXT}, + {name: "miss_unknown_style", id: domain.DiscoveryProfile("UNKNOWN_FAMILY"), wantHit: false}, + {name: "miss_empty_id", id: domain.DiscoveryProfile(""), wantHit: false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -124,18 +124,18 @@ func TestGet(t *testing.T) { // concurrent readers can safely iterate without coordination. func TestIDs_ReturnsCopy(t *testing.T) { r, err := New( - fakeStyle{id: domain.DNSRecordStyleTXT}, - fakeStyle{id: domain.DNSRecordStyleSVCB}, + fakeProfile{id: domain.DiscoveryProfileANSTXT}, + fakeProfile{id: domain.DiscoveryProfileANSSVCB}, ) require.NoError(t, err) first := r.IDs() - first[0] = domain.DNSRecordStyle("MUTATED") + first[0] = domain.DiscoveryProfile("MUTATED") second := r.IDs() - assert.Equal(t, []domain.DNSRecordStyle{ - domain.DNSRecordStyleTXT, - domain.DNSRecordStyleSVCB, + assert.Equal(t, []domain.DiscoveryProfile{ + domain.DiscoveryProfileANSTXT, + domain.DiscoveryProfileANSSVCB, }, second, "mutating one IDs() result must not affect a subsequent call") } @@ -144,15 +144,15 @@ func TestIDs_ReturnsCopy(t *testing.T) { // the registry must materialize order from the order slice, not the map. func TestIDs_StableAcrossCalls(t *testing.T) { r, err := New( - fakeStyle{id: domain.DNSRecordStyleTXT}, - fakeStyle{id: domain.DNSRecordStyleSVCB}, + fakeProfile{id: domain.DiscoveryProfileANSTXT}, + fakeProfile{id: domain.DiscoveryProfileANSSVCB}, ) require.NoError(t, err) for range 100 { - assert.Equal(t, []domain.DNSRecordStyle{ - domain.DNSRecordStyleTXT, - domain.DNSRecordStyleSVCB, + assert.Equal(t, []domain.DiscoveryProfile{ + domain.DiscoveryProfileANSTXT, + domain.DiscoveryProfileANSSVCB, }, r.IDs()) } } diff --git a/internal/adapter/dns/dns_test.go b/internal/adapter/dns/dns_test.go index 9767caf..f57bdcc 100644 --- a/internal/adapter/dns/dns_test.go +++ b/internal/adapter/dns/dns_test.go @@ -254,17 +254,19 @@ func TestLookupVerifier_HTTPSMatch(t *testing.T) { // TestLookupVerifier_SVCB exercises the Consolidated Approach SVCB // verifier across match, missing, and shape-mismatch paths. The match -// case tests the same presentation form the RA's -// ComputeRequiredDNSRecords emits (see internal/domain/dnsrecords.go). +// case tests the same presentation form the RA's profile emitters +// produce (SVCBProfile in internal/adapter/discovery/ans/svcb.go, +// composed by the service walker in internal/ra/service/dnsrecords.go). // -// Restricted to IANA-registered SvcParamKeys (alpn + port) because the -// miekg/dns zone-file parser used by the test fixture rejects symbolic -// names for the still-provisional Consolidated Approach SvcParams -// (`wk`, `card-sha256`, etc.). Until those keys are IANA-registered -// per RFC 9460 §6, the verifier-side test exercises the dispatch and -// matching path with registered keys; the unregistered keys are -// unit-tested at the adapter layer -// (internal/adapter/discovery/ans/svcb_test.go). +// This is the Fix A acceptance gate: the RA emits the draft `wk`/ +// `cap-sha256` params in RFC 9460 §14.3.1 Private Use keyNNNNN form +// (key65280 / key65281) precisely because the named forms are +// unparseable. These cases drive live key65280/key65281 records +// through the in-process miekg/dns server — the same parser ans-dns +// and real resolvers use — and prove formatHTTPSValue renders them +// byte-identically to what parseSVCBValue expects, so the adapter's +// emitted value round-trips through a real DNS answer without any +// verifier-side normalization. func TestLookupVerifier_SVCB(t *testing.T) { tests := []struct { name string @@ -329,6 +331,48 @@ func TestLookupVerifier_SVCB(t *testing.T) { found: false, why: "subset match requires every expected SvcParam present in the live record", }, + { + // Fix A acceptance gate, exact match. A live record carrying + // the keyNNNNN Private Use params the RA emits (key65280 = + // well-known suffix, key65281 = capability digest) parses + // through the miekg/dns zone fixture and matches the expected + // value verbatim. The named forms (`wk=`/`cap-sha256=`) would + // fail dns.NewRR here — that they parse at all proves the + // publishability the no-migration argument rests on. + name: "key65280-and-key65281-exact-match", + zoneName: "agent.example.com.", + zoneRR: `agent.example.com. 3600 IN SVCB 1 . alpn=a2a port=443 key65280=agent-card.json key65281=CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc`, + queryName: "agent.example.com", + want: `1 . alpn=a2a port=443 key65280=agent-card.json key65281=CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc`, + found: true, + why: "live keyNNNNN record must round-trip byte-symmetrically and match the RA's emitted value", + }, + { + // Coexistence (RFC 9460 §8): a live record carrying our + // key65280 plus an extra SvcParam from a coexisting spec must + // still match — the subset matcher tolerates the extra param. + name: "key65280-coexists-with-extra-svcparam", + zoneName: "agent.example.com.", + zoneRR: `agent.example.com. 3600 IN SVCB 1 . alpn=a2a port=443 key65280=agent-card.json key65282=somethingelse`, + queryName: "agent.example.com", + want: `1 . alpn=a2a port=443 key65280=agent-card.json`, + found: true, + why: "subset match: live record carries an extra keyNNNNN param, expected params still satisfied", + }, + { + // Collision: another experiment squats key65280 with a + // different value. The subset matcher requires equal values, + // so this is a clean not-found (false negative — denial of + // verification), never a false accept. This bounds the + // Private Use collision risk the svcb.go doc describes. + name: "key65280-value-collision-is-clean-not-found", + zoneName: "agent.example.com.", + zoneRR: `agent.example.com. 3600 IN SVCB 1 . alpn=a2a port=443 key65280=someone-elses-value`, + queryName: "agent.example.com", + want: `1 . alpn=a2a port=443 key65280=agent-card.json`, + found: false, + why: "a colliding key65280 with a different value must fail the value-equality check, not falsely match", + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -381,9 +425,10 @@ func TestLookupVerifier_HTTPS_DNSSECFlagPropagates(t *testing.T) { // TestLookupVerifier_SVCB_DNSSECFlagPropagates is the SVCB-side // counterpart to the HTTPS test above. SVCB rows carry per-protocol -// service-binding parameters and the security-bearing card-sha256 -// SvcParam (when the endpoint has a MetadataHash), so the AD bit is -// load-bearing for the lifecycle SVCB_DNSSEC_MISMATCH rule. +// service-binding parameters and the security-bearing capability +// digest (the draft cap-sha256 param, key65281 on the wire) when the +// endpoint has a MetadataHash, so the AD bit is load-bearing for the +// lifecycle SVCB_DNSSEC_MISMATCH rule. func TestLookupVerifier_SVCB_DNSSECFlagPropagates(t *testing.T) { t.Parallel() s := newTestServer(t) diff --git a/internal/adapter/docsui/openapi/ra.yaml b/internal/adapter/docsui/openapi/ra.yaml index 13b38d7..9f7138b 100644 --- a/internal/adapter/docsui/openapi/ra.yaml +++ b/internal/adapter/docsui/openapi/ra.yaml @@ -1023,22 +1023,48 @@ components: type: string enum: [A2A, MCP, HTTP-API] - DNSRecordStyle: + DiscoveryProfile: type: string enum: [ANS_SVCB, ANS_TXT] description: | Names one DNS record family the RA can emit for an agent - registration. Used as the element type of dnsRecordStyles[]. - - - ANS_SVCB: Consolidated Approach SVCB rows at the bare FQDN - per RFC 9460. One row per protocol carrying alpn, port, wk, - and (when the endpoint has a metadataHash) card-sha256 - SvcParams. The recommended default for new integrations. - - ANS_TXT: original `_ans` TXT shape (one row per protocol), - supported indefinitely for operators with existing zone-edit - tooling that targets `_ans.{fqdn}`. Emits an HTTPS RR at - the bare FQDN alongside, since `_ans` TXT carries no - connection hints. + registration. Used as the element type of discoveryProfiles[]. + Each value provisions a complete record set, not a single + record — the bullets below enumerate exactly what an operator + must publish for that profile. + + - ANS_SVCB: the Consolidated Approach (RFC 9460), recommended + default for new integrations. Provisions: + 1. One SVCB row per protocol endpoint at the bare FQDN + (ServiceMode `1 .`), carrying `alpn` (the protocol + token: a2a / mcp / http-api), `port` (the endpoint's + TLS port), `key65280` (the well-known path suffix, e.g. + agent-card.json for A2A; omitted for protocols with no + metadata-file convention), and `key65281` (the + base64url capability digest, present only when the + endpoint declared a metaDataHash). `key65280` and + `key65281` are the RFC 9460 §14.3.1 Private Use + presentation of the draft `wk` / `cap-sha256` SvcParams, + which have no IANA code point yet; the named forms are + unpublishable (strict RFC 9460 parsers reject them), so + the keyNNNNN forms are what reaches DNS. They switch + back to named forms if/when IANA registers the keys. + 2. One `_ans-badge` TXT at `_ans-badge.{fqdn}` (the + transparency-log discovery hint). + 3. One TLSA per distinct TLS port at `_._tcp.{fqdn}` + binding the server certificate (DANE-EE, full cert, + SHA-256), emitted only when a server certificate exists. + - ANS_TXT: original `_ans` TXT shape (one row per protocol at + `_ans.{fqdn}`), supported indefinitely for operators with + existing zone-edit tooling that targets `_ans.{fqdn}`. Emits + an HTTPS RR at the bare FQDN alongside carrying only + `alpn=h2` (an IANA-registered SvcParam — no keyNNNNN form is + needed here; the asymmetry with ANS_SVCB is intentional), + since `_ans` TXT carries no connection hints. The HTTPS RR is + best-effort: operators on CNAME-fronted apexes cannot publish + a record at that name (RFC 1034 §3.6.2) and verification does + not require it. Provisions the same `_ans-badge` TXT and + per-port TLSA records as ANS_SVCB. RevocationReason: type: string @@ -1082,33 +1108,42 @@ components: type: string identityCsrPEM: type: string - dnsRecordStyles: + discoveryProfiles: type: array items: - $ref: '#/components/schemas/DNSRecordStyle' + $ref: '#/components/schemas/DiscoveryProfile' uniqueItems: true minItems: 1 description: | - Set of DNS record families the RA emits in the 202 register - response's dnsRecords[] and in the AGENT_REGISTERED TL - event's attestations.dnsRecordsProvisioned[]. Not echoed on - GET /v2/ans/agents/{agentId}. + Set of DNS record families the RA tells the operator to + publish and emits in the AGENT_REGISTERED TL event's + attestations.dnsRecordsProvisioned[]. The computed records + surface to the client on GET /v2/ans/agents/{agentId} as + registrationPending.dnsRecords[] once the agent reaches + PENDING_DNS — not on the 202 register response, which + returns only the ACME challenge (production records are + deferred until verify-acme proves domain control and issues + the certificates the TLSA binding depends on). Each value names one record family; an operator publishing the union (Consolidated Approach SVCB plus the original - `_ans` TXT shape) sends both. Order is not significant and - duplicates are rejected (`uniqueItems: true`). - - Omitted/missing normalizes to ["ANS_SVCB"] server-side - (the recommended default per RFC 9460). An explicit empty - array is rejected (`minItems: 1`). + `_ans` TXT shape) sends both. Order is not significant. + + Omitted normalizes to ["ANS_SVCB"] server-side (the + recommended default per RFC 9460). The `minItems`/ + `uniqueItems` schema constraints are the canonical client + contract — validate before sending. A non-conformant + request (an explicit empty array, or duplicate values) is + handled defensively rather than rejected: empty is treated + as omitted and duplicates are ignored, but conformant + clients never send either. example: ["ANS_SVCB"] required: - agentDisplayName - - version - agentHost - endpoints - identityCsrPEM + - version AgentRevocationRequest: type: object @@ -1618,5 +1653,10 @@ components: $ref: '#/components/schemas/DnsRecord' found: type: string + description: | + The live record value observed when a record exists + but does not match the required value. expected: - type: string \ No newline at end of file + type: string + description: | + The required value; equals record.value. \ No newline at end of file diff --git a/internal/adapter/store/sqlite/agent.go b/internal/adapter/store/sqlite/agent.go index abd7bd3..1986ca8 100644 --- a/internal/adapter/store/sqlite/agent.go +++ b/internal/adapter/store/sqlite/agent.go @@ -39,7 +39,7 @@ type agentRow struct { SupersedesRegistrationID sql.NullInt64 `db:"supersedes_registration_id"` ACMEDNS01Token sql.NullString `db:"acme_dns01_token"` ACMEChallengeExpiresAtMs sql.NullInt64 `db:"acme_challenge_expires_at_ms"` - DNSRecordStyles sql.NullString `db:"dns_record_styles"` + DiscoveryProfiles sql.NullString `db:"discovery_profiles"` CreatedAtMs int64 `db:"created_at_ms"` UpdatedAtMs int64 `db:"updated_at_ms"` } @@ -74,22 +74,22 @@ func (r agentRow) toDomain() (*domain.AgentRegistration, error) { if r.ACMEChallengeExpiresAtMs.Valid { reg.ACMEChallenge.ExpiresAt = msToTime(r.ACMEChallengeExpiresAtMs.Int64) } - if r.DNSRecordStyles.Valid && r.DNSRecordStyles.String != "" { - styles, err := decodeDNSRecordStyles(r.DNSRecordStyles.String) + if r.DiscoveryProfiles.Valid && r.DiscoveryProfiles.String != "" { + profiles, err := decodeDiscoveryProfiles(r.DiscoveryProfiles.String) if err != nil { - return nil, fmt.Errorf("sqlite: decode dns_record_styles: %w", err) + return nil, fmt.Errorf("sqlite: decode discovery_profiles: %w", err) } - reg.DNSRecordStyles = styles + reg.DiscoveryProfiles = profiles } return reg, nil } -// decodeDNSRecordStyles parses the JSON-array string stored in -// agent_registrations.dns_record_styles into the typed domain slice. +// decodeDiscoveryProfiles parses the JSON-array string stored in +// agent_registrations.discovery_profiles into the typed domain slice. // Empty array unmarshals to a nil slice (the domain layer treats // empty as "use default") so post-load behavior matches a freshly // registered agent that didn't set the field. -func decodeDNSRecordStyles(raw string) ([]domain.DNSRecordStyle, error) { +func decodeDiscoveryProfiles(raw string) ([]domain.DiscoveryProfile, error) { var strs []string if err := json.Unmarshal([]byte(raw), &strs); err != nil { return nil, err @@ -97,24 +97,24 @@ func decodeDNSRecordStyles(raw string) ([]domain.DNSRecordStyle, error) { if len(strs) == 0 { return nil, nil } - out := make([]domain.DNSRecordStyle, len(strs)) + out := make([]domain.DiscoveryProfile, len(strs)) for i, s := range strs { - out[i] = domain.DNSRecordStyle(s) + out[i] = domain.DiscoveryProfile(s) } return out, nil } -// encodeDNSRecordStyles renders a typed style slice as the canonical -// JSON-array string the agent_registrations.dns_record_styles column +// encodeDiscoveryProfiles renders a typed profile slice as the canonical +// JSON-array string the agent_registrations.discovery_profiles column // stores. nil/empty input renders empty string so nullableString() // stamps SQL NULL — domain treats NULL the same as the default set // per ComputeRequiredDNSRecords. -func encodeDNSRecordStyles(styles []domain.DNSRecordStyle) string { - if len(styles) == 0 { +func encodeDiscoveryProfiles(profiles []domain.DiscoveryProfile) string { + if len(profiles) == 0 { return "" } - strs := make([]string, len(styles)) - for i, s := range styles { + strs := make([]string, len(profiles)) + for i, s := range profiles { strs[i] = string(s) } b, err := json.Marshal(strs) @@ -143,7 +143,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) registration_timestamp_ms, last_renewal_timestamp_ms, supersedes_registration_id, acme_dns01_token, acme_challenge_expires_at_ms, - dns_record_styles, + discovery_profiles, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` res, err := s.db.extx(ctx).ExecContext(ctx, q, @@ -160,7 +160,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) nullableInt64(agent.SupersedesRegistrationID), nullableString(agent.ACMEChallenge.DNS01Token), nullableMs(agent.ACMEChallenge.ExpiresAt), - nullableString(encodeDNSRecordStyles(agent.DNSRecordStyles)), + nullableString(encodeDiscoveryProfiles(agent.DiscoveryProfiles)), now, now, ) if err != nil { @@ -183,7 +183,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) supersedes_registration_id = ?, acme_dns01_token = ?, acme_challenge_expires_at_ms = ?, - dns_record_styles = ?, + discovery_profiles = ?, updated_at_ms = ? WHERE id = ?` _, err := s.db.extx(ctx).ExecContext(ctx, q, @@ -194,7 +194,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) nullableInt64(agent.SupersedesRegistrationID), nullableString(agent.ACMEChallenge.DNS01Token), nullableMs(agent.ACMEChallenge.ExpiresAt), - nullableString(encodeDNSRecordStyles(agent.DNSRecordStyles)), + nullableString(encodeDiscoveryProfiles(agent.DiscoveryProfiles)), now, agent.ID, ) diff --git a/internal/adapter/store/sqlite/agent_test.go b/internal/adapter/store/sqlite/agent_test.go index 5915bbf..dbacb5a 100644 --- a/internal/adapter/store/sqlite/agent_test.go +++ b/internal/adapter/store/sqlite/agent_test.go @@ -3,6 +3,7 @@ package sqlite import ( "context" "errors" + "fmt" "testing" "time" @@ -92,6 +93,65 @@ func TestAgentStore_Save_NilAgent(t *testing.T) { } } +// TestAgentStore_DiscoveryProfilesRoundTrip pins the discovery_profiles +// JSON-array column codec (encodeDiscoveryProfiles/decodeDiscoveryProfiles) +// through a real Save → FindByID cycle — the only direct exercise of the +// column added by migration 006. +func TestAgentStore_DiscoveryProfilesRoundTrip(t *testing.T) { + db := newTestDB(t) + store := NewAgentStore(db) + ctx := context.Background() + + tests := []struct { + name string + profiles []domain.DiscoveryProfile + want []domain.DiscoveryProfile + }{ + { + name: "svcb_only", + profiles: []domain.DiscoveryProfile{domain.DiscoveryProfileANSSVCB}, + want: []domain.DiscoveryProfile{domain.DiscoveryProfileANSSVCB}, + }, + { + name: "union_preserves_order", + profiles: []domain.DiscoveryProfile{ + domain.DiscoveryProfileANSSVCB, domain.DiscoveryProfileANSTXT, + }, + want: []domain.DiscoveryProfile{ + domain.DiscoveryProfileANSSVCB, domain.DiscoveryProfileANSTXT, + }, + }, + { + name: "empty_round_trips_as_empty", + profiles: nil, + want: nil, + }, + } + + for i, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + agent := newAgentFixture(t, fmt.Sprintf("agent-profiles-%d", i), fmt.Sprintf("p%d.example.com", i)) + agent.DiscoveryProfiles = tc.profiles + if err := store.Save(ctx, agent); err != nil { + t.Fatalf("save: %v", err) + } + found, err := store.FindByID(ctx, agent.ID) + if err != nil { + t.Fatalf("FindByID: %v", err) + } + if len(found.DiscoveryProfiles) != len(tc.want) { + t.Fatalf("profiles length: got %d (%v), want %d (%v)", + len(found.DiscoveryProfiles), found.DiscoveryProfiles, len(tc.want), tc.want) + } + for j, p := range tc.want { + if found.DiscoveryProfiles[j] != p { + t.Errorf("profiles[%d]: got %q, want %q", j, found.DiscoveryProfiles[j], p) + } + } + }) + } +} + func TestAgentStore_Save_UniqueAnsNameViolation(t *testing.T) { store := NewAgentStore(newTestDB(t)) ctx := context.Background() diff --git a/internal/adapter/store/sqlite/migrations/006_agent_dns_record_styles.sql b/internal/adapter/store/sqlite/migrations/006_agent_discovery_profiles.sql similarity index 79% rename from internal/adapter/store/sqlite/migrations/006_agent_dns_record_styles.sql rename to internal/adapter/store/sqlite/migrations/006_agent_discovery_profiles.sql index 8ec2f1f..00b1ae6 100644 --- a/internal/adapter/store/sqlite/migrations/006_agent_dns_record_styles.sql +++ b/internal/adapter/store/sqlite/migrations/006_agent_discovery_profiles.sql @@ -1,10 +1,10 @@ --- 006_agent_dns_record_styles.sql +-- 006_agent_discovery_profiles.sql -- Persist the operator's chosen set of DNS record families on the -- registration row so verify-acme / verify-dns / badge responses -- carry the same shape the operator chose at registration time. -- -- Stored as a JSON array of CONSTANT_CASE strings matching the V2 --- register schema's DNSRecordStyle enum: +-- register schema's DiscoveryProfile enum: -- "ANS_SVCB" — Consolidated Approach SVCB rows + shared records -- (RFC 9460; recommended default). -- "ANS_TXT" — original `_ans` TXT shape + HTTPS RR + shared @@ -24,17 +24,17 @@ -- json_valid() (SQLite JSON1) so a malformed array fails at the -- storage boundary instead of silently coercing in the domain. -- Element-level validation lives in the service layer, where the --- INVALID_DNS_RECORD_STYLE error is raised before the row is written. +-- INVALID_DISCOVERY_PROFILE error is raised before the row is written. ALTER TABLE agent_registrations - ADD COLUMN dns_record_styles TEXT - CHECK (dns_record_styles IS NULL OR json_valid(dns_record_styles)); + ADD COLUMN discovery_profiles TEXT + CHECK (discovery_profiles IS NULL OR json_valid(discovery_profiles)); -- Backfill: every row registered before this migration shipped was -- emitting the legacy `_ans` TXT shape (the only shape pre-PR-13). -- Stamp them as ["ANS_TXT"] so post-deploy verify-dns calls demand -- the record family the operator actually published. New rows get --- the value written explicitly by applyDNSRecordStyles in the service. +-- the value written explicitly by applyDiscoveryProfiles in the service. UPDATE agent_registrations - SET dns_record_styles = '["ANS_TXT"]' - WHERE dns_record_styles IS NULL; + SET discovery_profiles = '["ANS_TXT"]' + WHERE discovery_profiles IS NULL; diff --git a/internal/domain/agent.go b/internal/domain/agent.go index 46f414e..c2f42d4 100644 --- a/internal/domain/agent.go +++ b/internal/domain/agent.go @@ -103,13 +103,13 @@ type AgentRegistration struct { // deviation. ACMEChallenge ACMEChallenge `json:"acmeChallenge,omitzero"` - // DNSRecordStyles is the set of DNS record families the RA emits + // DiscoveryProfiles is the set of DNS record families the RA emits // for this registration. Each value names one family — typically // {ANS_SVCB} (Consolidated Approach), {ANS_TXT} (original `_ans` // TXT shape), or the {ANS_SVCB, ANS_TXT} transition union. Empty - // at the domain layer is treated as DefaultDNSRecordStyles() by - // ComputeRequiredDNSRecords. - DNSRecordStyles []DNSRecordStyle `json:"dnsRecordStyles,omitempty"` + // at the domain layer is treated as DefaultDiscoveryProfiles() by + // the service-layer record walker (internal/ra/service/dnsrecords.go). + DiscoveryProfiles []DiscoveryProfile `json:"discoveryProfiles,omitempty"` // PendingEvents holds domain events raised during this aggregate operation. // They are cleared after being published. diff --git a/internal/domain/dnsrecords.go b/internal/domain/dnsrecords.go index 4453f13..2d83a79 100644 --- a/internal/domain/dnsrecords.go +++ b/internal/domain/dnsrecords.go @@ -1,8 +1,8 @@ package domain -// DNSRecordStyle names one DNS record family the RA can emit for an -// agent registration. A registration carries a *set* of styles -// (AgentRegistration.DNSRecordStyles); operators publishing the union +// DiscoveryProfile names one DNS record family the RA can emit for an +// agent registration. A registration carries a *set* of profiles +// (AgentRegistration.DiscoveryProfiles); operators publishing the union // during a Consolidated Approach transition include both ANS_SVCB and // ANS_TXT in the same set. // @@ -11,57 +11,61 @@ package domain // NextStep.action, ChallengeInfo.type, DnsRecord.type, etc.). The // `ANS_` prefix anchors the namespace so a future second agentic spec // adding its own SVCB family doesn't collide. -type DNSRecordStyle string +type DiscoveryProfile string const ( - // DNSRecordStyleSVCB emits Consolidated Approach SVCB records per + // DiscoveryProfileANSSVCB emits Consolidated Approach SVCB records per // RFC 9460 — one row per protocol at the bare FQDN, carrying alpn, - // port, wk, and (when the endpoint has a MetadataHash) card-sha256 - // SvcParams. - DNSRecordStyleSVCB DNSRecordStyle = "ANS_SVCB" + // port, key65280 (well-known suffix), and (when the endpoint has a + // MetadataHash) key65281 (capability digest) SvcParams. key65280/ + // key65281 are the RFC 9460 §14.3.1 Private Use presentation of the + // DNS-AID draft's wk/cap-sha256 params, which have no IANA code point; + // the adapter (internal/adapter/discovery/ans/svcb.go) documents why + // the named forms are unpublishable. + DiscoveryProfileANSSVCB DiscoveryProfile = "ANS_SVCB" - // DNSRecordStyleTXT emits the original `_ans` TXT shape — one row + // DiscoveryProfileANSTXT emits the original `_ans` TXT shape — one row // per protocol at `_ans.{fqdn}`. Supported indefinitely for // operators with existing zone-edit tooling that targets `_ans.`. // Includes an HTTPS RR at the bare FQDN since `_ans` TXT carries // no connection hints. - DNSRecordStyleTXT DNSRecordStyle = "ANS_TXT" + DiscoveryProfileANSTXT DiscoveryProfile = "ANS_TXT" ) -// DefaultDNSRecordStyles is the set applied when the registration -// request omits dnsRecordStyles entirely. Pinned to {ANS_SVCB} so new +// DefaultDiscoveryProfiles is the set applied when the registration +// request omits discoveryProfiles entirely. Pinned to {ANS_SVCB} so new // integrations follow §4.4.2's "publish one SVCB record... rather than // parallel per-ecosystem record trees" SHOULD by default. Returned as a // fresh slice so callers can mutate without affecting the canonical set. -func DefaultDNSRecordStyles() []DNSRecordStyle { - return []DNSRecordStyle{DNSRecordStyleSVCB} +func DefaultDiscoveryProfiles() []DiscoveryProfile { + return []DiscoveryProfile{DiscoveryProfileANSSVCB} } -// IsValid reports whether s is one of the defined styles. Empty +// IsValid reports whether s is one of the defined profiles. Empty // string is treated as invalid; callers normalize empty/missing -// dnsRecordStyles to DefaultDNSRecordStyles() before validation. +// discoveryProfiles to DefaultDiscoveryProfiles() before validation. // // Coherence with the discovery registry is enforced at server start: -// cmd/ans-ra/main.go asserts that every style in -// ValidDNSRecordStyles() has a registered port.DiscoveryStyle adapter +// cmd/ans-ra/main.go asserts that every profile in +// ValidDiscoveryProfiles() has a registered port.ProfileEmitter adapter // and vice versa. Drift fails server start, not the first verify-dns // call. -func (s DNSRecordStyle) IsValid() bool { +func (s DiscoveryProfile) IsValid() bool { switch s { - case DNSRecordStyleSVCB, DNSRecordStyleTXT: + case DiscoveryProfileANSSVCB, DiscoveryProfileANSTXT: return true } return false } -// ValidDNSRecordStyles returns the canonical valid set as strings — +// ValidDiscoveryProfiles returns the canonical valid set as strings — // the single source of truth for enum membership. Used by error -// messages and spec generation tooling so adding a third style is a +// messages and spec generation tooling so adding a third profile is a // one-place change rather than a shotgun edit. -func ValidDNSRecordStyles() []string { +func ValidDiscoveryProfiles() []string { return []string{ - string(DNSRecordStyleSVCB), - string(DNSRecordStyleTXT), + string(DiscoveryProfileANSSVCB), + string(DiscoveryProfileANSTXT), } } diff --git a/internal/domain/dnsrecords_test.go b/internal/domain/dnsrecords_test.go index 2ebc77d..03803bc 100644 --- a/internal/domain/dnsrecords_test.go +++ b/internal/domain/dnsrecords_test.go @@ -6,38 +6,38 @@ import ( "github.com/stretchr/testify/assert" ) -// TestValidDNSRecordStyles pins the canonical valid set of -// DNSRecordStyle values returned by the helper used in the V2 -// INVALID_DNS_RECORD_STYLE error message and (eventually) by spec +// TestValidDiscoveryProfiles pins the canonical valid set of +// DiscoveryProfile values returned by the helper used in the V2 +// INVALID_DISCOVERY_PROFILE error message and (eventually) by spec // generation tooling. Order and contents are stable so an external // client's error-message fixtures can match. -func TestValidDNSRecordStyles(t *testing.T) { - got := ValidDNSRecordStyles() +func TestValidDiscoveryProfiles(t *testing.T) { + got := ValidDiscoveryProfiles() want := []string{"ANS_SVCB", "ANS_TXT"} assert.Equal(t, want, got) } -// TestDefaultDNSRecordStyles pins the default set applied when a V2 -// register request omits dnsRecordStyles. {ANS_SVCB} per §4.4.2. -func TestDefaultDNSRecordStyles(t *testing.T) { - got := DefaultDNSRecordStyles() - want := []DNSRecordStyle{DNSRecordStyleSVCB} +// TestDefaultDiscoveryProfiles pins the default set applied when a V2 +// register request omits discoveryProfiles. {ANS_SVCB} per §4.4.2. +func TestDefaultDiscoveryProfiles(t *testing.T) { + got := DefaultDiscoveryProfiles() + want := []DiscoveryProfile{DiscoveryProfileANSSVCB} assert.Equal(t, want, got) } -// TestDNSRecordStyle_IsValid covers the typed-enum membership predicate -// applyDNSRecordStyles and the registry-coherence check both rely on. -func TestDNSRecordStyle_IsValid(t *testing.T) { +// TestDiscoveryProfile_IsValid covers the typed-enum membership predicate +// applyDiscoveryProfiles and the registry-coherence check both rely on. +func TestDiscoveryProfile_IsValid(t *testing.T) { tests := []struct { name string - s DNSRecordStyle + s DiscoveryProfile want bool }{ - {name: "ans_svcb_is_valid", s: DNSRecordStyleSVCB, want: true}, - {name: "ans_txt_is_valid", s: DNSRecordStyleTXT, want: true}, - {name: "empty_is_invalid", s: DNSRecordStyle(""), want: false}, - {name: "unknown_is_invalid", s: DNSRecordStyle("UNKNOWN_FAMILY"), want: false}, - {name: "lowercase_is_invalid", s: DNSRecordStyle("ans_svcb"), want: false}, + {name: "ans_svcb_is_valid", s: DiscoveryProfileANSSVCB, want: true}, + {name: "ans_txt_is_valid", s: DiscoveryProfileANSTXT, want: true}, + {name: "empty_is_invalid", s: DiscoveryProfile(""), want: false}, + {name: "unknown_is_invalid", s: DiscoveryProfile("UNKNOWN_FAMILY"), want: false}, + {name: "lowercase_is_invalid", s: DiscoveryProfile("ans_svcb"), want: false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/domain/endpoint.go b/internal/domain/endpoint.go index 565c19b..ecb25c7 100644 --- a/internal/domain/endpoint.go +++ b/internal/domain/endpoint.go @@ -4,6 +4,7 @@ import ( "fmt" "net/url" "regexp" + "strconv" "strings" ) @@ -208,5 +209,21 @@ func validateURL(rawURL, fieldName string) error { fmt.Sprintf("%s is not a valid URL: %q", fieldName, rawURL), ) } + // url.Parse accepts any positive integer port string. An + // out-of-range port (0, 99999, or a 64-bit overflow value) would + // flow into the SVCB `port=` SvcParam and the TLSA owner name + // (`_._tcp.`), producing a record set no DNS provider will + // accept — the operator would strand their own agent in + // PENDING_DNS with an unexplainable verify-dns mismatch. Reject it + // loudly here at the registration boundary instead. + if p := parsed.Port(); p != "" { + n, perr := strconv.Atoi(p) + if perr != nil || n < 1 || n > 65535 { + return NewValidationError( + "INVALID_ENDPOINT", + fmt.Sprintf("%s port %q is outside the valid range 1-65535", fieldName, p), + ) + } + } return nil } diff --git a/internal/domain/endpoint_test.go b/internal/domain/endpoint_test.go index 48f7bfa..6fe3aa0 100644 --- a/internal/domain/endpoint_test.go +++ b/internal/domain/endpoint_test.go @@ -68,6 +68,34 @@ func TestAgentEndpoint_Validate(t *testing.T) { assert.ErrorIs(t, e.Validate(), ErrValidation) }) + // Out-of-range ports parse fine through url.Parse but produce SVCB + // port= SvcParams and _._tcp. TLSA owner names no DNS + // provider accepts — the boundary must reject them loudly instead + // of stranding the operator at verify-dns. + t.Run("reject port above 65535", func(t *testing.T) { + e := valid + e.AgentURL = "https://agent.example.com:99999/mcp" + assert.ErrorIs(t, e.Validate(), ErrValidation) + }) + + t.Run("reject port zero", func(t *testing.T) { + e := valid + e.AgentURL = "https://agent.example.com:0/mcp" + assert.ErrorIs(t, e.Validate(), ErrValidation) + }) + + t.Run("reject overflowing port literal", func(t *testing.T) { + e := valid + e.AgentURL = "https://agent.example.com:443443443443/mcp" + assert.ErrorIs(t, e.Validate(), ErrValidation) + }) + + t.Run("accept explicit in-range port", func(t *testing.T) { + e := valid + e.AgentURL = "https://agent.example.com:8443/mcp" + assert.NoError(t, e.Validate()) + }) + t.Run("reject bad documentation url", func(t *testing.T) { e := valid e.DocumentationURL = "not a url" diff --git a/internal/port/discovery.go b/internal/port/discovery.go index be30671..9d756b6 100644 --- a/internal/port/discovery.go +++ b/internal/port/discovery.go @@ -4,53 +4,53 @@ import ( "github.com/godaddy/ans/internal/domain" ) -// DiscoveryStyle is one named DNS discovery family the RA can emit for an +// ProfileEmitter is one named DNS discovery family the RA can emit for an // agent registration. Implementations live under internal/adapter/discovery/ // /. Today the bundled set is the ANS family (ANS_SVCB, ANS_TXT); // additional families plug in as new vendor packages without touching the // service or domain layers. // // Records is a pure function: no I/O, no context, no error. The service -// layer composes per-style outputs by walking a DiscoveryRegistry and -// concatenating each style's records, deduping by (Name, Type, Value) so +// layer composes per-profile outputs by walking a ProfileRegistry and +// concatenating each profile's records, deduping by (Name, Type, Value) so // per-family trust records (e.g. _ans-badge, TLSA) emitted by multiple -// styles in the same family land once. -type DiscoveryStyle interface { +// profiles in the same family land once. +type ProfileEmitter interface { // ID returns the wire-format identifier (e.g. "ANS_SVCB", "ANS_TXT"). // Persisted on agent rows; surfaced on the V2 register schema; used // as the registry key. - ID() domain.DNSRecordStyle + ID() domain.DiscoveryProfile - // Records returns the DNS records this style needs an operator to - // publish for reg. Includes both per-style discovery records and any - // family-level trust attestation records the style requires (e.g. + // Records returns the DNS records this profile needs an operator to + // publish for reg. Includes both per-profile discovery records and any + // family-level trust attestation records the profile requires (e.g. // _ans-badge for the ANS family, TLSA for any HTTPS-endpoint binding). - // The service walker dedupes across styles, so a family's shared - // records emit once even when multiple sibling styles request them. + // The service walker dedupes across profiles, so a family's shared + // records emit once even when multiple sibling profiles request them. // - // A style that emits a transparency-log-relative trust record (the + // A profile that emits a transparency-log-relative trust record (the // ANS family's _ans-badge) is configured with the deployment TL URL - // at construction — see ans.NewTXTStyle / ans.NewSVCBStyle — so this + // at construction — see ans.NewTXTProfile / ans.NewSVCBProfile — so this // stays a pure function of reg with no per-call deployment input. Records(reg *domain.AgentRegistration) []domain.ExpectedDNSRecord } -// DiscoveryRegistry is the lookup surface the service uses to compose -// per-style outputs. Implementations are immutable post-construction so +// ProfileRegistry is the lookup surface the service uses to compose +// per-profile outputs. Implementations are immutable post-construction so // reads are safe under concurrent registration / verify-dns load without // locking. The bundled implementation lives at // internal/adapter/discovery/registry/registry.go. -type DiscoveryRegistry interface { - // Get returns the style registered under id, or (nil, false) when no - // such style is wired. A miss is informational, not an error: the - // service skips unknown stored styles (e.g. post-decommission rows) +type ProfileRegistry interface { + // Get returns the profile registered under id, or (nil, false) when no + // such profile is wired. A miss is informational, not an error: the + // service skips unknown stored profiles (e.g. post-decommission rows) // and logs a WARN, rather than failing the registration. - Get(id domain.DNSRecordStyle) (DiscoveryStyle, bool) + Get(id domain.DiscoveryProfile) (ProfileEmitter, bool) - // IDs returns every registered style's ID in registry-wired + // IDs returns every registered profile's ID in registry-wired // insertion order. Order is stable across calls and process restarts // for a given wiring — the service walker iterates IDs() and gates - // each by membership in reg.DNSRecordStyles, so wiring order + // each by membership in reg.DiscoveryProfiles, so wiring order // determines emission order on the wire (TL canonical bytes). - IDs() []domain.DNSRecordStyle + IDs() []domain.DiscoveryProfile } diff --git a/internal/port/dns.go b/internal/port/dns.go index 553612a..55f0743 100644 --- a/internal/port/dns.go +++ b/internal/port/dns.go @@ -10,7 +10,17 @@ import ( type RecordVerification struct { Record domain.ExpectedDNSRecord Found bool - Actual string // What was actually returned by DNS (empty if not found). + // Actual carries the live answer DNS returned for this record. When + // records exist for the name/type but none matched the expected + // value, Actual is the first live answer (so the verify-dns 422 can + // show the operator what is actually in their zone). When nothing + // answered at all, Actual is empty. The service layer partitions on + // exactly this: !Found && Actual == "" is MISSING, !Found && Actual + // != "" is a value MISMATCH. When Found is true, Actual MAY differ + // benignly from the expected value (e.g. an SVCB subset match where + // the live record carries coexistence extras) and is informational + // only — Found is the verdict. + Actual string Error string // Lookup error, if any. // DNSSECVerified is true when the response carried an // authenticated-data (AD) bit from a validating resolver. Set diff --git a/internal/ra/handler/lifecycle_test.go b/internal/ra/handler/lifecycle_test.go index 9844004..b183b23 100644 --- a/internal/ra/handler/lifecycle_test.go +++ b/internal/ra/handler/lifecycle_test.go @@ -774,7 +774,7 @@ func newHandlerFixture(t *testing.T) *handlerFixture { t.Fatal(err) } - discoveryReg, err := service.NewDefaultDiscoveryRegistry("") + discoveryReg, err := service.NewDefaultProfileRegistry("") if err != nil { t.Fatal(err) } diff --git a/internal/ra/handler/registration.go b/internal/ra/handler/registration.go index 375497f..efbcc4e 100644 --- a/internal/ra/handler/registration.go +++ b/internal/ra/handler/registration.go @@ -40,13 +40,13 @@ type registrationRequest struct { ServerCertificatePEM string `json:"serverCertificatePEM,omitempty"` ServerCertificateChainPEM string `json:"serverCertificateChainPEM,omitempty"` - // DNSRecordStyles is the set of DNS record families the RA emits + // DiscoveryProfiles is the set of DNS record families the RA emits // for this registration. Each element is one of "ANS_SVCB" or // "ANS_TXT". Typical values: ["ANS_SVCB"] (default, recommended), // ["ANS_TXT"], or ["ANS_SVCB", "ANS_TXT"] (transition union). // Empty/missing → ["ANS_SVCB"]. Any invalid element rejected - // with 422 INVALID_DNS_RECORD_STYLE. See ANS_SPEC.md §4.4.2. - DNSRecordStyles []string `json:"dnsRecordStyles,omitempty"` + // with 422 INVALID_DISCOVERY_PROFILE. See ANS_SPEC.md §4.4.2. + DiscoveryProfiles []string `json:"discoveryProfiles,omitempty"` } type endpointDTO struct { @@ -161,7 +161,7 @@ func (h *RegistrationHandler) Register(w http.ResponseWriter, r *http.Request) { ServerCsrPEM: req.ServerCsrPEM, ServerCertificatePEM: req.ServerCertificatePEM, ServerCertificateChainPEM: req.ServerCertificateChainPEM, - DNSRecordStyles: toDomainDNSRecordStyles(req.DNSRecordStyles), + DiscoveryProfiles: toDomainDiscoveryProfiles(req.DiscoveryProfiles), }) if err != nil { WriteError(w, err) @@ -171,22 +171,20 @@ func (h *RegistrationHandler) Register(w http.ResponseWriter, r *http.Request) { WriteJSON(w, http.StatusAccepted, mapRegistrationResponse(resp, r)) } -// toDomainDNSRecordStyles converts the wire []string into the typed -// domain slice while preserving the nil-vs-empty distinction. nil -// (field omitted in the JSON request) flows through as nil so the -// service layer applies DefaultDNSRecordStyles(); a non-nil empty -// slice (explicit `"dnsRecordStyles": []`) flows through as an -// empty non-nil []DNSRecordStyle so the service layer can reject it -// per the spec's `minItems: 1`. Per-element validity, duplicate -// rejection, and empty-array rejection all live in -// applyDNSRecordStyles. -func toDomainDNSRecordStyles(raw []string) []domain.DNSRecordStyle { +// toDomainDiscoveryProfiles converts the wire []string into the typed +// domain slice. nil (field omitted in the JSON request) flows through +// as nil and a non-nil empty slice (explicit `"discoveryProfiles": []`) +// flows through as an empty non-nil []DiscoveryProfile; the service +// layer normalizes both to DefaultDiscoveryProfiles(), so the +// distinction no longer changes the outcome. Per-element validity and +// duplicate deduplication live in applyDiscoveryProfiles. +func toDomainDiscoveryProfiles(raw []string) []domain.DiscoveryProfile { if raw == nil { return nil } - out := make([]domain.DNSRecordStyle, len(raw)) + out := make([]domain.DiscoveryProfile, len(raw)) for i, s := range raw { - out[i] = domain.DNSRecordStyle(s) + out[i] = domain.DiscoveryProfile(s) } return out } diff --git a/internal/ra/handler/v1lifecycle_direct_test.go b/internal/ra/handler/v1lifecycle_direct_test.go index 9c8e66d..8d45225 100644 --- a/internal/ra/handler/v1lifecycle_direct_test.go +++ b/internal/ra/handler/v1lifecycle_direct_test.go @@ -417,24 +417,42 @@ func TestV1SubmitServerCSR_BadJSON(t *testing.T) { } } -// fakeDNSVerifier always returns a single MISSING + a single -// MISMATCH record. Used to drive the DNS-mismatch arm of -// VerifyDNS in both V1 and V2 lanes. +// Fixed record identities the fakeDNSVerifier reports, so the 422-bucket +// assertions below can match by name without depending on what +// ComputeRequiredDNSRecords produced for the test agent. +const ( + fakeMissingName = "_missing.example.com" + fakeMismatchName = "_mismatch.example.com" + fakeMismatchLive = "wrong-value" +) + +// fakeDNSVerifier deterministically reports exactly one MISSING and one +// MISMATCH record regardless of the expected record set it is handed, +// exercising both 422 buckets under the Fix C classification: +// - MISSING: Found=false, Actual="" (nothing answered) +// - MISMATCH: Found=false, Actual="wrong-value" (present but wrong; +// the live value rides through to incorrectRecords[].found) +// +// Ignoring the input records keeps the two buckets stable across agents +// whose computed record counts differ between the V1 and V2 lanes. type fakeDNSVerifier struct{} -func (fakeDNSVerifier) VerifyRecords(_ context.Context, _ string, recs []domain.ExpectedDNSRecord) (*port.VerificationResult, error) { - out := &port.VerificationResult{Results: make([]port.RecordVerification, 0, len(recs))} - for i, rec := range recs { - v := port.RecordVerification{Record: rec} - if i == 0 { - // first record reported as found mismatch - v.Found = false - v.Actual = "wrong-value" - } - out.Results = append(out.Results, v) - } - out.AllRequired = false - return out, nil +func (fakeDNSVerifier) VerifyRecords(_ context.Context, _ string, _ []domain.ExpectedDNSRecord) (*port.VerificationResult, error) { + return &port.VerificationResult{ + AllRequired: false, + Results: []port.RecordVerification{ + { + Record: domain.ExpectedDNSRecord{Name: fakeMissingName, Type: domain.DNSRecordTXT, Value: "expected-missing", Required: true}, + Found: false, + Actual: "", + }, + { + Record: domain.ExpectedDNSRecord{Name: fakeMismatchName, Type: domain.DNSRecordTXT, Value: "expected-mismatch", Required: true}, + Found: false, + Actual: fakeMismatchLive, + }, + }, + }, nil } // TestVerifyDNS_MismatchReturns422 covers the V2 VerifyDNS @@ -459,6 +477,46 @@ func TestVerifyDNS_MismatchReturns422(t *testing.T) { if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("status: got %d want 422; body=%s", rec.Code, rec.Body) } + + // Fix C bucket membership: the MISSING record lands in missingRecords + // (by name only — no live value), the MISMATCH record lands in + // incorrectRecords carrying its live found value. V2 nests the + // incorrect record under .record. + var body struct { + MissingRecords []struct { + Name string `json:"name"` + } `json:"missingRecords"` + IncorrectRecords []struct { + Record struct { + Name string `json:"name"` + } `json:"record"` + Found string `json:"found"` + } `json:"incorrectRecords"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal 422 body: %v; body=%s", err, rec.Body) + } + missingSeen := false + for _, m := range body.MissingRecords { + if m.Name == fakeMissingName { + missingSeen = true + } + } + if !missingSeen { + t.Errorf("missingRecords missing %q; body=%s", fakeMissingName, rec.Body) + } + mismatchSeen := false + for _, ir := range body.IncorrectRecords { + if ir.Record.Name == fakeMismatchName { + mismatchSeen = true + if ir.Found != fakeMismatchLive { + t.Errorf("incorrectRecords[%q].found: got %q want %q", fakeMismatchName, ir.Found, fakeMismatchLive) + } + } + } + if !mismatchSeen { + t.Errorf("incorrectRecords missing %q; body=%s", fakeMismatchName, rec.Body) + } } // TestV1Revoke_ServiceErrorBranch covers the service-error arm @@ -710,6 +768,42 @@ func TestV1VerifyDNS_MismatchReturns422(t *testing.T) { if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("status: got %d want 422; body=%s", rec.Code, rec.Body) } + + // Fix C bucket membership, V1 shape: missingRecords by name, and the + // flat incorrectRecords entry carrying name + found live value. + var body struct { + MissingRecords []struct { + Name string `json:"name"` + } `json:"missingRecords"` + IncorrectRecords []struct { + Name string `json:"name"` + Found string `json:"found"` + } `json:"incorrectRecords"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal 422 body: %v; body=%s", err, rec.Body) + } + missingSeen := false + for _, m := range body.MissingRecords { + if m.Name == fakeMissingName { + missingSeen = true + } + } + if !missingSeen { + t.Errorf("missingRecords missing %q; body=%s", fakeMissingName, rec.Body) + } + mismatchSeen := false + for _, ir := range body.IncorrectRecords { + if ir.Name == fakeMismatchName { + mismatchSeen = true + if ir.Found != fakeMismatchLive { + t.Errorf("incorrectRecords[%q].found: got %q want %q", fakeMismatchName, ir.Found, fakeMismatchLive) + } + } + } + if !mismatchSeen { + t.Errorf("incorrectRecords missing %q; body=%s", fakeMismatchName, rec.Body) + } } // TestVerifyRenewalACME_BYOCSync covers the BYOC + verify-acme diff --git a/internal/ra/service/discovery_default.go b/internal/ra/service/discovery_default.go index 495c95a..ed956eb 100644 --- a/internal/ra/service/discovery_default.go +++ b/internal/ra/service/discovery_default.go @@ -6,8 +6,8 @@ import ( "github.com/godaddy/ans/internal/port" ) -// NewDefaultDiscoveryRegistry returns a registry pre-wired with the -// bundled ANS-family styles (TXTStyle, SVCBStyle) in the canonical +// NewDefaultProfileRegistry returns a registry pre-wired with the +// bundled ANS-family profiles (TXTProfile, SVCBProfile) in the canonical // emission order — TXT first, SVCB second — that the V2 TL canonical // bytes for the union case were established at. cmd/ans-ra/main.go // uses it for production wiring; tests across the RA layer use it @@ -17,8 +17,8 @@ import ( // Iteration order is the load-bearing part: the service walker // emits records in registry insertion order, and TL leaves carry // `dnsRecordsProvisioned[]` byte-for-byte from that ordering. Any -// future production deployment that swaps in a different style set -// MUST construct the registry with TXTStyle and SVCBStyle in this +// future production deployment that swaps in a different profile set +// MUST construct the registry with TXTProfile and SVCBProfile in this // same relative order to preserve canonical-bytes parity for // existing agents. // @@ -28,13 +28,13 @@ import ( // startup misconfig per the no-panic-in-request-paths rule. // // tlPublicBaseURL is the externally-reachable Transparency Log URL the -// ANS styles stamp into the family `_ans-badge` record's url= (see -// ans.NewTXTStyle / ans.NewSVCBStyle). Empty — tests, or a deployment +// ANS profiles stamp into the family `_ans-badge` record's url= (see +// ans.NewTXTProfile / ans.NewSVCBProfile). Empty — tests, or a deployment // without a public TL URL — falls the badge back to the agent's own // endpoint URL. -func NewDefaultDiscoveryRegistry(tlPublicBaseURL string) (port.DiscoveryRegistry, error) { +func NewDefaultProfileRegistry(tlPublicBaseURL string) (port.ProfileRegistry, error) { return registry.New( - ans.NewTXTStyle(tlPublicBaseURL), - ans.NewSVCBStyle(tlPublicBaseURL), + ans.NewTXTProfile(tlPublicBaseURL), + ans.NewSVCBProfile(tlPublicBaseURL), ) } diff --git a/internal/ra/service/dnsmismatch_test.go b/internal/ra/service/dnsmismatch_test.go new file mode 100644 index 0000000..51dc1b9 --- /dev/null +++ b/internal/ra/service/dnsmismatch_test.go @@ -0,0 +1,42 @@ +package service_test + +import ( + "testing" + + "github.com/godaddy/ans/internal/ra/service" +) + +// TestDNSMismatch_Classification pins the wire-code → predicate mapping +// the RA's 422 mappers depend on. verifyDNSRecords emits MISSING, +// MISMATCH, or a per-record-type "_DNSSEC_MISMATCH" code; +// IsMissing and IsIncorrect partition those so missingRecords and +// incorrectRecords stay complete. Codes are pinned as literals (not the +// unexported constants) so this also guards the wire contract — a TLSA-, +// SVCB-, or HTTPS-DNSSEC tampering code MUST classify as incorrect. +func TestDNSMismatch_Classification(t *testing.T) { + cases := []struct { + name string + code string + wantMissing bool + wantIncorrect bool + }{ + {"missing", "MISSING", true, false}, + {"plain_mismatch", "MISMATCH", false, true}, + {"tlsa_dnssec", "TLSA_DNSSEC_MISMATCH", false, true}, + {"svcb_dnssec", "SVCB_DNSSEC_MISMATCH", false, true}, + {"https_dnssec", "HTTPS_DNSSEC_MISMATCH", false, true}, + {"unknown_code_is_neither", "SOMETHING_ELSE", false, false}, + {"empty_code_is_neither", "", false, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := service.DNSMismatch{Code: tc.code} + if got := m.IsMissing(); got != tc.wantMissing { + t.Errorf("IsMissing(%q) = %v, want %v", tc.code, got, tc.wantMissing) + } + if got := m.IsIncorrect(); got != tc.wantIncorrect { + t.Errorf("IsIncorrect(%q) = %v, want %v", tc.code, got, tc.wantIncorrect) + } + }) + } +} diff --git a/internal/ra/service/dnsrecords.go b/internal/ra/service/dnsrecords.go index 2b03d84..75f75de 100644 --- a/internal/ra/service/dnsrecords.go +++ b/internal/ra/service/dnsrecords.go @@ -14,17 +14,17 @@ import ( // // Composition rules: // -// 1. The set of styles to emit is reg.DNSRecordStyles, filtered to +// 1. The set of profiles to emit is reg.DiscoveryProfiles, filtered to // those the registry actually has wired. Empty after filtering -// (operator omitted dnsRecordStyles, or every entry was unknown -// to the registry) normalizes to domain.DefaultDNSRecordStyles(). +// (operator omitted discoveryProfiles, or every entry was unknown +// to the registry) normalizes to domain.DefaultDiscoveryProfiles(). // 2. Iteration order is the registry's insertion order (cmd/main -// wires [TXTStyle, SVCBStyle], so emission proceeds TXT-first -// then SVCB). User-supplied order on reg.DNSRecordStyles has no -// effect — `dnsRecordStyles` is set semantics on the wire. -// 3. Each style's full record list (discovery + family trust records) +// wires [TXTProfile, SVCBProfile], so emission proceeds TXT-first +// then SVCB). User-supplied order on reg.DiscoveryProfiles has no +// effect — `discoveryProfiles` is set semantics on the wire. +// 3. Each profile's full record list (discovery + family trust records) // is collected and deduped by (Name, Type, Value). Family trust -// records that overlap across sibling styles in the same family +// records that overlap across sibling profiles in the same family // (e.g. `_ans-badge` from both ANS_SVCB and ANS_TXT) emit once. // 4. Records are reordered into discovery-then-trust groupings, // preserving within-group iteration order. This pins the V2 TL @@ -36,20 +36,21 @@ import ( // `_ans` TXT family carries the operator's required signal and // SVCB rides along as optional. // -// Returns nil when reg has no endpoints AND no server cert (nothing -// meaningful for the operator to publish), matching the pre-refactor -// domain function's empty-input contract. +// Returns an empty (non-nil) slice when reg has no endpoints AND no +// server cert — nothing meaningful for the operator to publish. The +// nil-vs-empty distinction never reaches the wire: the V2 event +// builder re-wraps into its own slice. // // s.discoveryRegistry is guaranteed non-nil by NewRegistrationService // (constructor panics on nil), so the walker dereferences it // unconditionally. func (s *RegistrationService) ComputeRequiredDNSRecords(reg *domain.AgentRegistration) []domain.ExpectedDNSRecord { - requested := s.resolveRequestedStyles(reg) + requested := s.resolveRequestedProfiles(reg) logger := log.Debug(). Str("agentId", reg.AgentID). - Strs("requestedStyles", styleStrings(reg.DNSRecordStyles)). - Strs("resolvedStyles", styleStrings(setToSlice(requested))) + Strs("requestedProfiles", profileStrings(reg.DiscoveryProfiles)). + Strs("resolvedProfiles", profileStrings(setToSlice(requested))) logger.Msg("computing required DNS records") collected, seen := []domain.ExpectedDNSRecord{}, make(map[string]bool) @@ -57,17 +58,23 @@ func (s *RegistrationService) ComputeRequiredDNSRecords(reg *domain.AgentRegistr if !requested[id] { continue } - style, ok := s.discoveryRegistry.Get(id) + profile, ok := s.discoveryRegistry.Get(id) if !ok { continue } - emitted := style.Records(reg) + emitted := profile.Records(reg) log.Debug(). Str("agentId", reg.AgentID). - Str("style", string(id)). + Str("profile", string(id)). Int("emittedCount", len(emitted)). - Msg("style emitted records") + Msg("profile emitted records") for _, r := range emitted { + // Dedup key deliberately omits Required: sibling profiles + // emitting the same family trust record (badge, TLSA) must + // agree on the flag (both adapters do — badge true, TLSA + // false), so first-seen wins. A profile that disagreed + // would have its flag silently dropped here — keep the + // flags aligned across adapters. key := r.Name + "|" + string(r.Type) + "|" + r.Value if seen[key] { continue @@ -94,7 +101,7 @@ func (s *RegistrationService) ComputeRequiredDNSRecords(reg *domain.AgentRegistr // SVCB Required-flag post-process: §4.4.2 says TXT carries the // required signal during the transition; SVCB stays optional // alongside. - if requested[domain.DNSRecordStyleTXT] { + if requested[domain.DiscoveryProfileANSTXT] { for i := range result { if result[i].Type == domain.DNSRecordSVCB { result[i].Required = false @@ -105,53 +112,53 @@ func (s *RegistrationService) ComputeRequiredDNSRecords(reg *domain.AgentRegistr if len(result) == 0 && len(reg.Endpoints) > 0 { log.Warn(). Str("agentId", reg.AgentID). - Strs("resolvedStyles", styleStrings(setToSlice(requested))). + Strs("resolvedProfiles", profileStrings(setToSlice(requested))). Msg("DNS record computation produced no records despite having endpoints; check discovery registry wiring") } return result } -// resolveRequestedStyles filters reg.DNSRecordStyles to those the +// resolveRequestedProfiles filters reg.DiscoveryProfiles to those the // registry has wired, normalizing empty/all-invalid to the default -// set. Unknown styles trigger a WARN log so an operator can spot a +// set. Unknown profiles trigger a WARN log so an operator can spot a // post-decommission row in their data without parsing verify-dns // failures. -func (s *RegistrationService) resolveRequestedStyles(reg *domain.AgentRegistration) map[domain.DNSRecordStyle]bool { - requested := make(map[domain.DNSRecordStyle]bool) - for _, id := range reg.DNSRecordStyles { +func (s *RegistrationService) resolveRequestedProfiles(reg *domain.AgentRegistration) map[domain.DiscoveryProfile]bool { + requested := make(map[domain.DiscoveryProfile]bool) + for _, id := range reg.DiscoveryProfiles { if _, ok := s.discoveryRegistry.Get(id); ok { requested[id] = true continue } log.Warn(). Str("agentId", reg.AgentID). - Str("style", string(id)). - Msg("registration carries DNS style unknown to the running registry; skipping") + Str("profile", string(id)). + Msg("registration carries discovery profile unknown to the running registry; skipping") } if len(requested) == 0 { - for _, id := range domain.DefaultDNSRecordStyles() { + for _, id := range domain.DefaultDiscoveryProfiles() { requested[id] = true } } return requested } -func styleStrings(styles []domain.DNSRecordStyle) []string { - out := make([]string, len(styles)) - for i, s := range styles { +func profileStrings(profiles []domain.DiscoveryProfile) []string { + out := make([]string, len(profiles)) + for i, s := range profiles { out[i] = string(s) } return out } // setToSlice converts the requested-set map to a deterministic slice -// for logging. Order tracks domain.ValidDNSRecordStyles() so logs are +// for logging. Order tracks domain.ValidDiscoveryProfiles() so logs are // stable across runs. -func setToSlice(set map[domain.DNSRecordStyle]bool) []domain.DNSRecordStyle { - var out []domain.DNSRecordStyle - for _, valid := range domain.ValidDNSRecordStyles() { - id := domain.DNSRecordStyle(valid) +func setToSlice(set map[domain.DiscoveryProfile]bool) []domain.DiscoveryProfile { + var out []domain.DiscoveryProfile + for _, valid := range domain.ValidDiscoveryProfiles() { + id := domain.DiscoveryProfile(valid) if set[id] { out = append(out, id) } diff --git a/internal/ra/service/dnsrecords_test.go b/internal/ra/service/dnsrecords_test.go index c0b68be..339c2be 100644 --- a/internal/ra/service/dnsrecords_test.go +++ b/internal/ra/service/dnsrecords_test.go @@ -20,9 +20,9 @@ import ( // newTestRegistry returns the bundled ANS-family registry every // service-level test uses. Mirrors cmd/ans-ra/main.go's wiring so // emission order in tests matches production. -func newTestRegistry(t *testing.T) port.DiscoveryRegistry { +func newTestRegistry(t *testing.T) port.ProfileRegistry { t.Helper() - r, err := service.NewDefaultDiscoveryRegistry("") + r, err := service.NewDefaultProfileRegistry("") require.NoError(t, err) return r } @@ -40,29 +40,29 @@ func newComputeOnlyService(t *testing.T) *service.RegistrationService { ) } -func mustReg(t *testing.T, host string, version string, eps []domain.AgentEndpoint, cert *domain.ByocServerCertificate, styles []domain.DNSRecordStyle) *domain.AgentRegistration { +func mustReg(t *testing.T, host string, version string, eps []domain.AgentEndpoint, cert *domain.ByocServerCertificate, profiles []domain.DiscoveryProfile) *domain.AgentRegistration { t.Helper() v, err := domain.ParseSemVer(version) require.NoError(t, err) ansName, err := domain.NewAnsName(v, host) require.NoError(t, err) return &domain.AgentRegistration{ - AnsName: ansName, - Endpoints: eps, - ServerCert: cert, - DNSRecordStyles: styles, + AnsName: ansName, + Endpoints: eps, + ServerCert: cert, + DiscoveryProfiles: profiles, } } // TestComputeRequiredDNSRecords_BadgeURLFromRegistryConstruction pins the -// end-to-end wiring: NewDefaultDiscoveryRegistry stamps the deployment TL +// end-to-end wiring: NewDefaultProfileRegistry stamps the deployment TL // URL into the ANS styles, so the family `_ans-badge` record points at the // TL's per-agent endpoint rather than the agent's own host. The per-adapter // ansbadge_test covers BadgeRecord directly; this guards the // registry→style→walker path end to end — without the URL reaching the // styles, the badge silently regresses to the agent's endpoint URL. func TestComputeRequiredDNSRecords_BadgeURLFromRegistryConstruction(t *testing.T) { - discoveryReg, err := service.NewDefaultDiscoveryRegistry("https://tl.example.org") + discoveryReg, err := service.NewDefaultProfileRegistry("https://tl.example.org") require.NoError(t, err) svc := service.NewRegistrationService( nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, @@ -71,7 +71,7 @@ func TestComputeRequiredDNSRecords_BadgeURLFromRegistryConstruction(t *testing.T reg := mustReg(t, "agent.example.com", "1.0.0", []domain.AgentEndpoint{{Protocol: domain.ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}}, - nil, []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}) + nil, []domain.DiscoveryProfile{domain.DiscoveryProfileANSSVCB}) reg.AgentID = "test-agent-id" records := svc.ComputeRequiredDNSRecords(reg) @@ -96,11 +96,11 @@ func TestComputeRequiredDNSRecords_BadgeURLFromRegistryConstruction(t *testing.T // testable across both adapters' output). func TestComputeRequiredDNSRecords_StyleMatrix_Integration(t *testing.T) { const sampleMetadataHash = "SHA256:098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27" - const wantSampleCardBase64 = "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc" + const wantSampleCapBase64 = "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc" tests := []struct { name string - styles []domain.DNSRecordStyle + styles []domain.DiscoveryProfile protocol domain.Protocol agentURL string metadataHash string // optional per-endpoint MetadataHash @@ -109,12 +109,12 @@ func TestComputeRequiredDNSRecords_StyleMatrix_Integration(t *testing.T) { wantSVCBRequired bool // applies only when wantSVCB is true wantLegacyTXT bool wantSVCBPort string // substring expected in SVCB value (e.g. "port=443") - wantSVCBWk string // "" means SVCB MUST NOT contain "wk=" - wantSVCBCard string // "" means SVCB MUST NOT contain "card-sha256" + wantSVCBWk string // "" means SVCB MUST NOT contain "key65280=" (well-known suffix) + wantSVCBCap string // "" means SVCB MUST NOT contain "key65281=" (capability digest) }{ { name: "ans_txt_only_emits_https_rr_no_svcb", - styles: []domain.DNSRecordStyle{domain.DNSRecordStyleTXT}, + styles: []domain.DiscoveryProfile{domain.DiscoveryProfileANSTXT}, protocol: domain.ProtocolA2A, agentURL: "https://agent.example.com", wantHTTPS: true, @@ -122,17 +122,17 @@ func TestComputeRequiredDNSRecords_StyleMatrix_Integration(t *testing.T) { }, { name: "ans_svcb_only_omits_https_rr", - styles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + styles: []domain.DiscoveryProfile{domain.DiscoveryProfileANSSVCB}, protocol: domain.ProtocolA2A, agentURL: "https://agent.example.com", wantSVCB: true, wantSVCBRequired: true, // SVCB-sole: only PurposeDiscovery record, must be required wantSVCBPort: "port=443", - wantSVCBWk: "wk=agent-card.json", + wantSVCBWk: "key65280=agent-card.json", }, { name: "union_emits_both_families", - styles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB, domain.DNSRecordStyleTXT}, + styles: []domain.DiscoveryProfile{domain.DiscoveryProfileANSSVCB, domain.DiscoveryProfileANSTXT}, protocol: domain.ProtocolA2A, agentURL: "https://agent.example.com", wantHTTPS: true, @@ -142,21 +142,21 @@ func TestComputeRequiredDNSRecords_StyleMatrix_Integration(t *testing.T) { // Required signal during the §4.4.2 transition; SVCB rides // along as optional. wantSVCBPort: "port=443", - wantSVCBWk: "wk=agent-card.json", + wantSVCBWk: "key65280=agent-card.json", }, { name: "svcb_mcp_wk_mcp_json", - styles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + styles: []domain.DiscoveryProfile{domain.DiscoveryProfileANSSVCB}, protocol: domain.ProtocolMCP, agentURL: "https://agent.example.com/mcp", wantSVCB: true, wantSVCBRequired: true, wantSVCBPort: "port=443", - wantSVCBWk: "wk=mcp.json", + wantSVCBWk: "key65280=mcp.json", }, { name: "svcb_http_api_omits_wk", - styles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + styles: []domain.DiscoveryProfile{domain.DiscoveryProfileANSSVCB}, protocol: domain.ProtocolHTTPAPI, agentURL: "https://agent.example.com", wantSVCB: true, @@ -164,36 +164,36 @@ func TestComputeRequiredDNSRecords_StyleMatrix_Integration(t *testing.T) { wantSVCBPort: "port=443", }, { - name: "svcb_card_sha256_from_endpoint_metadata_hash", - styles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + name: "svcb_cap_sha256_from_endpoint_metadata_hash", + styles: []domain.DiscoveryProfile{domain.DiscoveryProfileANSSVCB}, protocol: domain.ProtocolA2A, agentURL: "https://agent.example.com", metadataHash: sampleMetadataHash, wantSVCB: true, wantSVCBRequired: true, wantSVCBPort: "port=443", - wantSVCBWk: "wk=agent-card.json", - wantSVCBCard: "card-sha256=" + wantSampleCardBase64, + wantSVCBWk: "key65280=agent-card.json", + wantSVCBCap: "key65281=" + wantSampleCapBase64, }, { name: "svcb_non_443_port_from_url", - styles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + styles: []domain.DiscoveryProfile{domain.DiscoveryProfileANSSVCB}, protocol: domain.ProtocolA2A, agentURL: "https://agent.example.com:8443", wantSVCB: true, wantSVCBRequired: true, wantSVCBPort: "port=8443", - wantSVCBWk: "wk=agent-card.json", + wantSVCBWk: "key65280=agent-card.json", }, { name: "svcb_http_scheme_defaults_port_80", - styles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + styles: []domain.DiscoveryProfile{domain.DiscoveryProfileANSSVCB}, protocol: domain.ProtocolA2A, agentURL: "http://agent.example.com", wantSVCB: true, wantSVCBRequired: true, wantSVCBPort: "port=80", - wantSVCBWk: "wk=agent-card.json", + wantSVCBWk: "key65280=agent-card.json", }, { name: "empty_styles_coerces_to_default", @@ -203,17 +203,17 @@ func TestComputeRequiredDNSRecords_StyleMatrix_Integration(t *testing.T) { wantSVCB: true, wantSVCBRequired: true, // default ({ANS_SVCB}) is SVCB-sole wantSVCBPort: "port=443", - wantSVCBWk: "wk=agent-card.json", + wantSVCBWk: "key65280=agent-card.json", }, { name: "all_invalid_styles_falls_back_to_default", - styles: []domain.DNSRecordStyle{domain.DNSRecordStyle("garbage"), domain.DNSRecordStyle("nonsense")}, + styles: []domain.DiscoveryProfile{domain.DiscoveryProfile("garbage"), domain.DiscoveryProfile("nonsense")}, protocol: domain.ProtocolA2A, agentURL: "https://agent.example.com", wantSVCB: true, wantSVCBRequired: true, // fallback default ({ANS_SVCB}) is SVCB-sole wantSVCBPort: "port=443", - wantSVCBWk: "wk=agent-card.json", + wantSVCBWk: "key65280=agent-card.json", }, } @@ -259,17 +259,24 @@ func TestComputeRequiredDNSRecords_StyleMatrix_Integration(t *testing.T) { assert.Contains(t, svcbValue, tc.wantSVCBPort, "SVCB port SvcParam mismatch") if tc.wantSVCBWk != "" { - assert.Contains(t, svcbValue, tc.wantSVCBWk, "SVCB wk SvcParam mismatch") + assert.Contains(t, svcbValue, tc.wantSVCBWk, "SVCB well-known (key65280) SvcParam mismatch") } else { - assert.NotContains(t, svcbValue, "wk=", - "SVCB MUST NOT carry wk= when protocol has no metadata convention") + assert.NotContains(t, svcbValue, "key65280=", + "SVCB MUST NOT carry key65280 (well-known) when protocol has no metadata convention") } - if tc.wantSVCBCard != "" { - assert.Contains(t, svcbValue, tc.wantSVCBCard, "SVCB card-sha256 SvcParam mismatch") + if tc.wantSVCBCap != "" { + assert.Contains(t, svcbValue, tc.wantSVCBCap, "SVCB capability digest (key65281) SvcParam mismatch") } else { - assert.NotContains(t, svcbValue, "card-sha256", - "SVCB MUST NOT carry card-sha256 when endpoint MetadataHash is empty") + assert.NotContains(t, svcbValue, "key65281=", + "SVCB MUST NOT carry key65281 (capability digest) when endpoint MetadataHash is empty") } + // Named-form regression guards across the integration path. + assert.NotContains(t, svcbValue, "wk=", + "named `wk=` SvcParam MUST NOT appear; key65280 is the publishable form") + assert.NotContains(t, svcbValue, "cap-sha256", + "named `cap-sha256=` SvcParam MUST NOT appear; key65281 is the publishable form") + assert.NotContains(t, svcbValue, "card-sha256", + "legacy `card-sha256=` SvcParam MUST NOT appear; key65281 is the publishable form") } }) } @@ -285,7 +292,7 @@ func TestComputeRequiredDNSRecords_UnionDedupesFamilyTrustRecords(t *testing.T) reg := mustReg(t, "agent.example.com", "1.0.0", []domain.AgentEndpoint{{Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}}, &domain.ByocServerCertificate{Fingerprint: "abcdef"}, - []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB, domain.DNSRecordStyleTXT}) + []domain.DiscoveryProfile{domain.DiscoveryProfileANSSVCB, domain.DiscoveryProfileANSTXT}) records := svc.ComputeRequiredDNSRecords(reg) @@ -313,14 +320,14 @@ func TestComputeRequiredDNSRecords_NoEndpoints(t *testing.T) { assert.Empty(t, records) } -// TestNewRegistrationService_PanicsOnNilDiscoveryRegistry pins the +// TestNewRegistrationService_PanicsOnNilProfileRegistry pins the // fail-loud invariant the constructor enforces. A missing registry // would silently emit zero `dnsRecordsProvisioned[]` and accept any // DNS state at verify-dns — trust-root corruption masquerading as // graceful degradation. Construction is process-start-time, not a // request path, so the panic does not violate the no-panics-in- // request-paths rule. -func TestNewRegistrationService_PanicsOnNilDiscoveryRegistry(t *testing.T) { +func TestNewRegistrationService_PanicsOnNilProfileRegistry(t *testing.T) { defer func() { r := recover() require.NotNil(t, r, "constructor must panic when discoveryRegistry is nil") @@ -334,16 +341,16 @@ func TestNewRegistrationService_PanicsOnNilDiscoveryRegistry(t *testing.T) { } // TestComputeRequiredDNSRecords_UnknownStyleSkipped pins that a -// reg.DNSRecordStyles entry the registry doesn't have is silently +// reg.DiscoveryProfiles entry the registry doesn't have is silently // skipped (with a WARN log; not asserted in this test). The remaining -// valid styles still emit. If every entry is unknown, the walker -// falls back to DefaultDNSRecordStyles. +// valid profiles still emit. If every entry is unknown, the walker +// falls back to DefaultDiscoveryProfiles. func TestComputeRequiredDNSRecords_UnknownStyleSkipped(t *testing.T) { svc := newComputeOnlyService(t) reg := mustReg(t, "agent.example.com", "1.0.0", []domain.AgentEndpoint{{Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}}, nil, - []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB, domain.DNSRecordStyle("UNKNOWN_FUTURE")}) + []domain.DiscoveryProfile{domain.DiscoveryProfileANSSVCB, domain.DiscoveryProfile("UNKNOWN_FUTURE")}) records := svc.ComputeRequiredDNSRecords(reg) @@ -364,11 +371,19 @@ func TestComputeRequiredDNSRecords_UnknownStyleSkipped(t *testing.T) { // SHA-256, signal a wire-shape regression, and break offline-verifier // hashes for in-flight agents at deploy time. // -// The hex constant was captured against this exact input. Do NOT -// regenerate without explicit approval — a change here is a -// wire-format change, not a test fix. +// The hex constant was REGENERATED for the keyNNNNN/selector-0 change: +// the SVCB rows now carry `key65280=`/`key65281=` (Fix A — RFC 9460 +// §14.3.1 Private Use presentation of the draft wk/cap-sha256 params, +// replacing the unpublishable named forms) and the TLSA row now carries +// `3 0 1` over the full cert (Fix B2 — selector 0 matching what +// CertificateFingerprint actually hashes). This is the intentional +// canonical-bytes change of the PR. The slice ORDER and the 7-record +// SHAPE are unchanged (both endpoints are https/443) — only the SVCB +// SvcParam values and the TLSA value move the hash. A future drift that +// is NOT one of those two value changes is a regression: investigate +// before touching this constant. func TestComputeRequiredDNSRecords_UnionCanonicalBytesRegression(t *testing.T) { - const wantSHA256Hex = "ab1efc56fcc5dc088ff0f35d5ed1e0164b8ee70a11116e60f180a55fe794bf64" + const wantSHA256Hex = "0bc5f912c2a450dffd631b66d467ee6d5974e0cbea47e84fd676c6111387bda0" svc := newComputeOnlyService(t) reg := mustReg(t, "agent.example.com", "1.2.3", @@ -385,7 +400,7 @@ func TestComputeRequiredDNSRecords_UnionCanonicalBytesRegression(t *testing.T) { }, }, &domain.ByocServerCertificate{Fingerprint: "deadbeefcafe1234"}, - []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB, domain.DNSRecordStyleTXT}) + []domain.DiscoveryProfile{domain.DiscoveryProfileANSSVCB, domain.DiscoveryProfileANSTXT}) records := svc.ComputeRequiredDNSRecords(reg) @@ -424,15 +439,15 @@ func TestComputeRequiredDNSRecords_UnionCanonicalBytesRegression(t *testing.T) { "V2 union canonical-bytes SHA-256 drifted; investigate before changing the constant") } -// TestNewDefaultDiscoveryRegistry pins the default-wiring contract: +// TestNewDefaultProfileRegistry pins the default-wiring contract: // returns a registry containing both ANS-family styles in TXT-then-SVCB // insertion order. The order is the V2 canonical-bytes input. -func TestNewDefaultDiscoveryRegistry(t *testing.T) { - r, err := service.NewDefaultDiscoveryRegistry("") +func TestNewDefaultProfileRegistry(t *testing.T) { + r, err := service.NewDefaultProfileRegistry("") require.NoError(t, err) got := r.IDs() - want := []domain.DNSRecordStyle{domain.DNSRecordStyleTXT, domain.DNSRecordStyleSVCB} + want := []domain.DiscoveryProfile{domain.DiscoveryProfileANSTXT, domain.DiscoveryProfileANSSVCB} assert.Equal(t, want, got, "default registry must wire TXT before SVCB to preserve V2 union canonical bytes") } @@ -440,13 +455,13 @@ func TestNewDefaultDiscoveryRegistry(t *testing.T) { // pins that a non-default registry wiring (SVCB before TXT) actually // produces a different emission order — proving the walker honours // registry insertion order rather than user-supplied -// reg.DNSRecordStyles order. +// reg.DiscoveryProfiles order. func TestComputeRequiredDNSRecords_RegistryIterationOrderDeterminesEmission(t *testing.T) { // Build a "production" service (default registry wiring: TXT, SVCB) // and a custom one with SVCB before TXT. defaultSvc := newComputeOnlyService(t) - customReg, err := registry.New(svcStub{id: domain.DNSRecordStyleSVCB, marker: "S"}, svcStub{id: domain.DNSRecordStyleTXT, marker: "T"}) + customReg, err := registry.New(svcStub{id: domain.DiscoveryProfileANSSVCB, marker: "S"}, svcStub{id: domain.DiscoveryProfileANSTXT, marker: "T"}) require.NoError(t, err) customSvc := service.NewRegistrationService( nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, customReg) @@ -454,7 +469,7 @@ func TestComputeRequiredDNSRecords_RegistryIterationOrderDeterminesEmission(t *t reg := mustReg(t, "agent.example.com", "1.0.0", []domain.AgentEndpoint{{Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}}, nil, - []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB, domain.DNSRecordStyleTXT}) + []domain.DiscoveryProfile{domain.DiscoveryProfileANSSVCB, domain.DiscoveryProfileANSTXT}) defaultOut := defaultSvc.ComputeRequiredDNSRecords(reg) customOut := customSvc.ComputeRequiredDNSRecords(reg) @@ -472,14 +487,14 @@ func TestComputeRequiredDNSRecords_RegistryIterationOrderDeterminesEmission(t *t assert.Empty(t, customOut, "stub registry produces no records; default fallback is gated by registry presence, not adapter output") } -// svcStub is a minimal port.DiscoveryStyle for ordering tests; emits +// svcStub is a minimal port.ProfileEmitter for ordering tests; emits // no records so the test asserts purely on walker behavior. type svcStub struct { - id domain.DNSRecordStyle + id domain.DiscoveryProfile marker string } -func (s svcStub) ID() domain.DNSRecordStyle { return s.id } +func (s svcStub) ID() domain.DiscoveryProfile { return s.id } func (svcStub) Records(*domain.AgentRegistration) []domain.ExpectedDNSRecord { return nil } @@ -489,21 +504,21 @@ func (svcStub) Records(*domain.AgentRegistration) []domain.ExpectedDNSRecord { // defensive `if !ok { continue }` branch is the safety net for that // contract violation. The bundled registry maintains the contract by // construction, so this fake exercises a branch only a custom -// port.DiscoveryRegistry implementation could ever reach. +// port.ProfileRegistry implementation could ever reach. type inconsistentRegistry struct{} -func (inconsistentRegistry) IDs() []domain.DNSRecordStyle { - return []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB} +func (inconsistentRegistry) IDs() []domain.DiscoveryProfile { + return []domain.DiscoveryProfile{domain.DiscoveryProfileANSSVCB} } -func (inconsistentRegistry) Get(domain.DNSRecordStyle) (port.DiscoveryStyle, bool) { +func (inconsistentRegistry) Get(domain.DiscoveryProfile) (port.ProfileEmitter, bool) { return nil, false } // TestComputeRequiredDNSRecords_RegistryGetMissDoesNotPanic pins the // defensive branch the walker takes when registry.IDs() and Get fall // out of sync. The branch is unreachable in production wiring; it -// exists so a future custom port.DiscoveryRegistry implementation +// exists so a future custom port.ProfileRegistry implementation // (e.g. one that hot-reloads styles and races between IDs() and Get) // degrades to "skip the missing ID" instead of nil-dereferencing the // returned style. @@ -514,7 +529,7 @@ func TestComputeRequiredDNSRecords_RegistryGetMissDoesNotPanic(t *testing.T) { reg := mustReg(t, "agent.example.com", "1.0.0", []domain.AgentEndpoint{{Protocol: domain.ProtocolA2A, AgentURL: "https://agent.example.com"}}, nil, - []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}) + []domain.DiscoveryProfile{domain.DiscoveryProfileANSSVCB}) // IDs() returns SVCB; Get returns (nil, false). Walker must // continue without dereferencing style. Result: empty record set diff --git a/internal/ra/service/helpers.go b/internal/ra/service/helpers.go index b20abd2..c6de49e 100644 --- a/internal/ra/service/helpers.go +++ b/internal/ra/service/helpers.go @@ -12,75 +12,68 @@ import ( "github.com/godaddy/ans/internal/domain" ) -// applyDNSRecordStyles resolves the set of DNS record families the -// registration emits and stores it on the aggregate, enforcing the -// V2 spec's dnsRecordStyles validation rules at the API boundary. +// applyDiscoveryProfiles resolves the set of DNS record families the +// registration emits and stores it on the aggregate, normalizing the +// V2 request's discoveryProfiles field defensively at the API boundary. // // V1 lane is pinned to {ANS_TXT} regardless of the request: V1 // callers predate the Consolidated Approach and their tooling expects -// the original `_ans` TXT shape. V1 has no dnsRecordStyles field on +// the original `_ans` TXT shape. V1 has no discoveryProfiles field on // the wire, so this branch is the only path V1 registrations take. // -// V2 validation enforces the OpenAPI contract: -// - Field absent (nil slice) → defaults to DefaultDNSRecordStyles() -// ({ANS_SVCB}). The spec doesn't list dnsRecordStyles in +// V2 normalization: +// - Field absent (nil slice) → defaults to DefaultDiscoveryProfiles() +// ({ANS_SVCB}). The spec doesn't list discoveryProfiles in // `required`, so omission is legal and the server picks the // recommended Consolidated Approach. -// - Field present but empty (`"dnsRecordStyles": []`) → 422 -// INVALID_DNS_RECORD_STYLE. Matches `minItems: 1` in the spec — -// a caller who explicitly sends an empty list is signalling -// intent that the schema doesn't permit. -// - Duplicate elements → 422 INVALID_DNS_RECORD_STYLE. Matches -// `uniqueItems: true`. Silent dedup would let a malformed -// client request persist as state the caller didn't intend. -// - Invalid element (not in ValidDNSRecordStyles()) → 422 -// INVALID_DNS_RECORD_STYLE. +// - Field present but empty (`"discoveryProfiles": []`) → also +// normalizes to DefaultDiscoveryProfiles(), same as omission. The +// spec's `minItems: 1` is the canonical client contract; the server +// does not reject a client that sends an empty array anyway. +// - Duplicate elements → silently deduped, first occurrence wins. The +// spec's `uniqueItems: true` is the canonical client contract; a +// duplicate carries no extra meaning, so we normalize rather than +// reject. +// - Invalid element (not in ValidDiscoveryProfiles()) → 422 +// INVALID_DISCOVERY_PROFILE. An unrecognized value can't be +// normalized away — it names a family the RA can't emit, so the +// caller must fix it. // -// The handler-side conversion (toDomainDNSRecordStyles) preserves -// the nil-vs-empty distinction so this function can tell field -// omission from explicit empty. +// The handler-side conversion (toDomainDiscoveryProfiles) preserves +// the nil-vs-empty distinction, but both nil and empty normalize to the +// default here so the distinction no longer changes the outcome. // // V1 detection routes through isV1Lane (lifecycle.go) so a future // schema-version evolution updates one site, not several. The error -// messages reference ValidDNSRecordStyles() so adding a third style +// message references ValidDiscoveryProfiles() so adding a third profile // is a one-place change. -func applyDNSRecordStyles(reg *domain.AgentRegistration, req RegisterRequest) error { +func applyDiscoveryProfiles(reg *domain.AgentRegistration, req RegisterRequest) error { if isV1Lane(req.SchemaVersion) { - reg.DNSRecordStyles = []domain.DNSRecordStyle{domain.DNSRecordStyleTXT} + reg.DiscoveryProfiles = []domain.DiscoveryProfile{domain.DiscoveryProfileANSTXT} return nil } - if req.DNSRecordStyles == nil { - reg.DNSRecordStyles = domain.DefaultDNSRecordStyles() + if len(req.DiscoveryProfiles) == 0 { + reg.DiscoveryProfiles = domain.DefaultDiscoveryProfiles() return nil } - if len(req.DNSRecordStyles) == 0 { - return domain.NewValidationError( - "INVALID_DNS_RECORD_STYLE", - "dnsRecordStyles must contain at least one element when present (omit the field to default to ["+ - string(domain.DNSRecordStyleSVCB)+"])", - ) - } - seen := make(map[domain.DNSRecordStyle]struct{}, len(req.DNSRecordStyles)) - out := make([]domain.DNSRecordStyle, 0, len(req.DNSRecordStyles)) - for _, s := range req.DNSRecordStyles { + seen := make(map[domain.DiscoveryProfile]struct{}, len(req.DiscoveryProfiles)) + out := make([]domain.DiscoveryProfile, 0, len(req.DiscoveryProfiles)) + for _, s := range req.DiscoveryProfiles { if !s.IsValid() { return domain.NewValidationError( - "INVALID_DNS_RECORD_STYLE", - fmt.Sprintf("dnsRecordStyles element %q is not one of %s", + "INVALID_DISCOVERY_PROFILE", + fmt.Sprintf("discoveryProfiles element %q is not one of %s", string(s), - strings.Join(domain.ValidDNSRecordStyles(), ", ")), + strings.Join(domain.ValidDiscoveryProfiles(), ", ")), ) } if _, dup := seen[s]; dup { - return domain.NewValidationError( - "INVALID_DNS_RECORD_STYLE", - fmt.Sprintf("dnsRecordStyles must not contain duplicates (saw %q twice)", string(s)), - ) + continue } seen[s] = struct{}{} out = append(out, s) } - reg.DNSRecordStyles = out + reg.DiscoveryProfiles = out return nil } diff --git a/internal/ra/service/helpers_test.go b/internal/ra/service/helpers_test.go index cd0c28c..786ae06 100644 --- a/internal/ra/service/helpers_test.go +++ b/internal/ra/service/helpers_test.go @@ -203,111 +203,116 @@ func selfSignedCertPEM(t *testing.T) string { return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) } -// ----- applyDNSRecordStyles ----- +// ----- applyDiscoveryProfiles ----- -// TestApplyDNSRecordStyles covers the V1-pin / V2-default / V2-validate -// branches, including every INVALID_DNS_RECORD_STYLE rejection path -// the API guards: invalid element, duplicate, and explicit empty -// array. The integration tests follow happy paths through -// RegisterAgent and don't reach the rejection branches directly. -func TestApplyDNSRecordStyles(t *testing.T) { +// TestApplyDiscoveryProfiles covers the V1-pin / V2-default / +// V2-normalize branches. The server normalizes defensively: an explicit +// empty array and field omission both fall back to the default set, and +// duplicates are silently deduped (first occurrence wins). Only an +// unrecognized value still surfaces as INVALID_DISCOVERY_PROFILE — it +// names a family the RA can't emit, so it cannot be normalized away. The +// integration tests follow happy paths through RegisterAgent and don't +// reach the normalization/rejection branches directly. +func TestApplyDiscoveryProfiles(t *testing.T) { tests := []struct { - name string - req RegisterRequest - wantStyles []domain.DNSRecordStyle - wantErrCode string + name string + req RegisterRequest + wantProfiles []domain.DiscoveryProfile + wantErrCode string }{ { name: "v1_pins_to_ans_txt_ignoring_request_field", req: RegisterRequest{ - SchemaVersion: "V1", - DNSRecordStyles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + SchemaVersion: "V1", + DiscoveryProfiles: []domain.DiscoveryProfile{domain.DiscoveryProfileANSSVCB}, }, - wantStyles: []domain.DNSRecordStyle{domain.DNSRecordStyleTXT}, + wantProfiles: []domain.DiscoveryProfile{domain.DiscoveryProfileANSTXT}, }, { - name: "v2_nil_normalizes_to_default", - req: RegisterRequest{SchemaVersion: "V2"}, - wantStyles: domain.DefaultDNSRecordStyles(), + name: "v2_nil_normalizes_to_default", + req: RegisterRequest{SchemaVersion: "V2"}, + wantProfiles: domain.DefaultDiscoveryProfiles(), }, { - // minItems: 1 in the spec — an explicit empty array is a - // signal of intent the schema doesn't allow. Distinct from - // "field omitted", which still defaults. - name: "v2_explicit_empty_slice_rejected", - req: RegisterRequest{SchemaVersion: "V2", DNSRecordStyles: []domain.DNSRecordStyle{}}, - wantErrCode: "INVALID_DNS_RECORD_STYLE", + // minItems: 1 is the canonical client contract, but the + // server normalizes an explicit empty array to the default + // set rather than rejecting it — same outcome as omission. + name: "v2_explicit_empty_slice_normalizes_to_default", + req: RegisterRequest{SchemaVersion: "V2", DiscoveryProfiles: []domain.DiscoveryProfile{}}, + wantProfiles: domain.DefaultDiscoveryProfiles(), }, { - name: "unset_schema_treated_as_v2_default", - req: RegisterRequest{}, - wantStyles: domain.DefaultDNSRecordStyles(), + name: "unset_schema_treated_as_v2_default", + req: RegisterRequest{}, + wantProfiles: domain.DefaultDiscoveryProfiles(), }, { name: "v2_valid_ans_svcb_only", req: RegisterRequest{ - SchemaVersion: "V2", - DNSRecordStyles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + SchemaVersion: "V2", + DiscoveryProfiles: []domain.DiscoveryProfile{domain.DiscoveryProfileANSSVCB}, }, - wantStyles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + wantProfiles: []domain.DiscoveryProfile{domain.DiscoveryProfileANSSVCB}, }, { name: "v2_valid_ans_txt_only", req: RegisterRequest{ - SchemaVersion: "V2", - DNSRecordStyles: []domain.DNSRecordStyle{domain.DNSRecordStyleTXT}, + SchemaVersion: "V2", + DiscoveryProfiles: []domain.DiscoveryProfile{domain.DiscoveryProfileANSTXT}, }, - wantStyles: []domain.DNSRecordStyle{domain.DNSRecordStyleTXT}, + wantProfiles: []domain.DiscoveryProfile{domain.DiscoveryProfileANSTXT}, }, { name: "v2_valid_union_preserves_order", req: RegisterRequest{ SchemaVersion: "V2", - DNSRecordStyles: []domain.DNSRecordStyle{ - domain.DNSRecordStyleSVCB, - domain.DNSRecordStyleTXT, + DiscoveryProfiles: []domain.DiscoveryProfile{ + domain.DiscoveryProfileANSSVCB, + domain.DiscoveryProfileANSTXT, }, }, - wantStyles: []domain.DNSRecordStyle{ - domain.DNSRecordStyleSVCB, - domain.DNSRecordStyleTXT, + wantProfiles: []domain.DiscoveryProfile{ + domain.DiscoveryProfileANSSVCB, + domain.DiscoveryProfileANSTXT, }, }, { - // uniqueItems: true in the spec — duplicates are rejected - // rather than silently deduped. A caller sending the same - // style twice has either a client bug or an unclear - // intention; surfacing 422 forces the issue out into the - // open instead of letting a request the caller didn't - // quite mean to send become persisted state. - name: "v2_duplicate_elements_rejected", + // uniqueItems: true is the canonical client contract, but the + // server normalizes duplicates by deduping (first occurrence + // wins) rather than rejecting. A duplicate carries no extra + // meaning, so the persisted set drops the repeat and keeps + // the original order of first appearances. + name: "v2_duplicate_elements_deduped_first_wins", req: RegisterRequest{ SchemaVersion: "V2", - DNSRecordStyles: []domain.DNSRecordStyle{ - domain.DNSRecordStyleSVCB, - domain.DNSRecordStyleSVCB, - domain.DNSRecordStyleTXT, + DiscoveryProfiles: []domain.DiscoveryProfile{ + domain.DiscoveryProfileANSSVCB, + domain.DiscoveryProfileANSSVCB, + domain.DiscoveryProfileANSTXT, }, }, - wantErrCode: "INVALID_DNS_RECORD_STYLE", + wantProfiles: []domain.DiscoveryProfile{ + domain.DiscoveryProfileANSSVCB, + domain.DiscoveryProfileANSTXT, + }, }, { name: "v2_invalid_element_rejected", req: RegisterRequest{ - SchemaVersion: "V2", - DNSRecordStyles: []domain.DNSRecordStyle{domain.DNSRecordStyle("garbage")}, + SchemaVersion: "V2", + DiscoveryProfiles: []domain.DiscoveryProfile{domain.DiscoveryProfile("garbage")}, }, - wantErrCode: "INVALID_DNS_RECORD_STYLE", + wantErrCode: "INVALID_DISCOVERY_PROFILE", }, { // CONSTANT_CASE is the wire form. lowercase is rejected so the // V2 enum stays consistent with every other enum on the spec. name: "v2_lowercase_element_rejected_as_invalid", req: RegisterRequest{ - SchemaVersion: "V2", - DNSRecordStyles: []domain.DNSRecordStyle{domain.DNSRecordStyle("ans_svcb")}, + SchemaVersion: "V2", + DiscoveryProfiles: []domain.DiscoveryProfile{domain.DiscoveryProfile("ans_svcb")}, }, - wantErrCode: "INVALID_DNS_RECORD_STYLE", + wantErrCode: "INVALID_DISCOVERY_PROFILE", }, { // First valid, second invalid — error surfaces at the @@ -315,18 +320,18 @@ func TestApplyDNSRecordStyles(t *testing.T) { name: "v2_mixed_valid_then_invalid_rejected", req: RegisterRequest{ SchemaVersion: "V2", - DNSRecordStyles: []domain.DNSRecordStyle{ - domain.DNSRecordStyleSVCB, - domain.DNSRecordStyle("garbage"), + DiscoveryProfiles: []domain.DiscoveryProfile{ + domain.DiscoveryProfileANSSVCB, + domain.DiscoveryProfile("garbage"), }, }, - wantErrCode: "INVALID_DNS_RECORD_STYLE", + wantErrCode: "INVALID_DISCOVERY_PROFILE", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { reg := &domain.AgentRegistration{} - err := applyDNSRecordStyles(reg, tc.req) + err := applyDiscoveryProfiles(reg, tc.req) if tc.wantErrCode != "" { if err == nil { t.Fatalf("want error code %q, got nil", tc.wantErrCode) @@ -343,37 +348,37 @@ func TestApplyDNSRecordStyles(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if !sameStyles(reg.DNSRecordStyles, tc.wantStyles) { - t.Errorf("DNSRecordStyles: got %v want %v", reg.DNSRecordStyles, tc.wantStyles) + if !sameProfiles(reg.DiscoveryProfiles, tc.wantProfiles) { + t.Errorf("DiscoveryProfiles: got %v want %v", reg.DiscoveryProfiles, tc.wantProfiles) } }) } } -// TestApplyDNSRecordStyles_ErrorMessageListsValidValues confirms the +// TestApplyDiscoveryProfiles_ErrorMessageListsValidValues confirms the // error detail enumerates the canonical valid set so SDK authors get -// an actionable message. Sourced from domain.ValidDNSRecordStyles(). -func TestApplyDNSRecordStyles_ErrorMessageListsValidValues(t *testing.T) { +// an actionable message. Sourced from domain.ValidDiscoveryProfiles(). +func TestApplyDiscoveryProfiles_ErrorMessageListsValidValues(t *testing.T) { reg := &domain.AgentRegistration{} - err := applyDNSRecordStyles(reg, RegisterRequest{ - SchemaVersion: "V2", - DNSRecordStyles: []domain.DNSRecordStyle{domain.DNSRecordStyle("garbage")}, + err := applyDiscoveryProfiles(reg, RegisterRequest{ + SchemaVersion: "V2", + DiscoveryProfiles: []domain.DiscoveryProfile{domain.DiscoveryProfile("garbage")}, }) if err == nil { t.Fatal("expected error") } - for _, want := range domain.ValidDNSRecordStyles() { + for _, want := range domain.ValidDiscoveryProfiles() { if !strings.Contains(err.Error(), want) { t.Errorf("error message must list %q; got %q", want, err.Error()) } } } -// sameStyles compares two style slices for set-equal-with-order. -// Used by TestApplyDNSRecordStyles to assert ordering on the happy +// sameProfiles compares two profile slices for set-equal-with-order. +// Used by TestApplyDiscoveryProfiles to assert ordering on the happy // paths without pulling in reflect.DeepEqual semantics that // distinguish nil from empty. -func sameStyles(a, b []domain.DNSRecordStyle) bool { +func sameProfiles(a, b []domain.DiscoveryProfile) bool { if len(a) != len(b) { return false } diff --git a/internal/ra/service/lifecycle.go b/internal/ra/service/lifecycle.go index fa8f381..c870c19 100644 --- a/internal/ra/service/lifecycle.go +++ b/internal/ra/service/lifecycle.go @@ -8,6 +8,7 @@ import ( "time" "github.com/google/uuid" + "github.com/rs/zerolog/log" "github.com/godaddy/ans/internal/domain" "github.com/godaddy/ans/internal/port" @@ -537,9 +538,40 @@ func (s *RegistrationService) VerifyDNS(ctx context.Context, agentID string, in mismatches, perRecord, err := s.verifyDNSRecords(ctx, reg.FQDN(), expected) if err != nil { + // Systemic verifier failure (DNS unreachable, resolver error) — + // the caller sees a 500 with nothing in the body. WARN here so + // on-call can grep the agent and FQDN that wedged. agentID is in + // scope here but not inside verifyDNSRecords, so this is the one + // WARN site for the verifier error. + log.Warn(). + Str("agentId", agentID). + Str("fqdn", reg.FQDN()). + Err(err). + Msg("dns verification failed with a systemic error") return nil, fmt.Errorf("dns verify: %w", err) } if len(mismatches) > 0 { + // A 422 is an expected operator-side condition (their zone isn't + // published yet / is wrong), so INFO, not ERROR. Log names, types, + // and classification codes only — never the expected or live + // values — so on-call can distinguish "one bad operator zone" from + // "our emission regressed" without operators pasting 422 bodies. + recs := make([]struct { + Name string `json:"name"` + Type string `json:"type"` + Code string `json:"code"` + }, len(mismatches)) + for i, m := range mismatches { + recs[i].Name = m.Expected.Name + recs[i].Type = string(m.Expected.Type) + recs[i].Code = m.Code + } + log.Info(). + Str("agentId", agentID). + Str("fqdn", reg.FQDN()). + Int("mismatchCount", len(mismatches)). + Interface("mismatchRecords", recs). + Msg("verify-dns returning mismatches") return &VerifyDNSResult{Registration: reg, Now: now, DNSMismatches: mismatches}, nil } @@ -657,13 +689,38 @@ func (s *RegistrationService) verifyDNSRecords(ctx context.Context, fqdn string, // Required check below. } } + // Optional records that aren't a DNSSEC-validated tamper (handled + // above) are skipped: TLSA without DNSSEC, the HTTPS RR that + // CNAME-at-apex operators cannot publish (RFC 1034 §3.6.2), and + // union-mode SVCB rows the walker flipped Required=false during the + // §4.4.2 transition are all optional by design — blocking on them + // would 422 operators forever on records the design says they need + // not publish. The DNSSEC hard-fail above is the only path that + // bypasses Required; this keeps the documented two-condition + // blocking contract (Required miss/mismatch, or DNSSEC tamper). if !r.Record.Required { continue } + // For Required records, partition by what DNS actually answered, + // using r.Found as the verdict (the verifier already applied + // type-specific matching: SVCB subset-match, TLSA case-insensitive + // hex, etc.). The two not-found cases split on r.Actual per the + // port.RecordVerification contract — empty Actual means nothing + // answered (truly absent), a non-empty Actual means the zone has a + // live record that didn't match: + // + // !Found && Actual == "" → MISSING (operator hasn't published it) + // !Found && Actual != "" → MISMATCH carrying the live value, so + // the 422 shows the operator what's + // actually in their zone vs expected + // Found → OK; the verifier's match is the verdict. + // A benign Actual≠Value delta (coexistence + // extras on an SVCB subset match) is not a + // mismatch. switch { - case !r.Found: + case !r.Found && r.Actual == "": out = append(out, DNSMismatch{Expected: r.Record, Code: dnsCodeMissing}) - case r.Found && r.Actual != r.Record.Value: + case !r.Found: out = append(out, DNSMismatch{Expected: r.Record, Found: r.Actual, Code: dnsCodeMismatch}) } } diff --git a/internal/ra/service/lifecycle_verifydns_test.go b/internal/ra/service/lifecycle_verifydns_test.go new file mode 100644 index 0000000..26a2c0c --- /dev/null +++ b/internal/ra/service/lifecycle_verifydns_test.go @@ -0,0 +1,262 @@ +package service + +// White-box table tests for verifyDNSRecords' mismatch classification. +// These pin the Fix C partition directly against the documented +// port.RecordVerification.Actual contract (port/dns.go): "first live +// answer when records exist but none matched; empty when nothing +// answered". The classification consumes that contract, so the stub +// verifier below implements it exactly — a future DNSVerifier adapter +// author who honors the documented contract gets a green test, and one +// who reintroduces the old "MISSING regardless of Actual" behavior gets +// a loud failure here rather than a silent regression at the wire. +// +// verifyDNSRecords is unexported, so this is an internal-package test +// (package service, like helpers_test.go). It builds a RegistrationService +// with only dnsVerifier wired — no store, no registry — because the +// function under test reads nothing else. + +import ( + "context" + "errors" + "testing" + + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/port" +) + +// stubDNSVerifier returns a fixed VerificationResult (and optional error) +// regardless of the expected records it is handed. Tests set results to +// the exact (Found, Actual, DNSSECVerified, Record) shapes they want to +// classify, honoring the documented Actual contract. +type stubDNSVerifier struct { + results []port.RecordVerification + err error +} + +func (s stubDNSVerifier) VerifyRecords( + _ context.Context, + _ string, + _ []domain.ExpectedDNSRecord, +) (*port.VerificationResult, error) { + if s.err != nil { + return nil, s.err + } + return &port.VerificationResult{Results: s.results}, nil +} + +// rec is a tiny constructor for an ExpectedDNSRecord with the fields the +// classification cares about (Name/Type/Value/Required). +func rec(name string, typ domain.DNSRecordType, value string, required bool) domain.ExpectedDNSRecord { + return domain.ExpectedDNSRecord{Name: name, Type: typ, Value: value, Required: required} +} + +func TestVerifyDNSRecords_Classification(t *testing.T) { + t.Parallel() + + const ( + svcbName = "agent.example.com" + svcbVal = "1 . alpn=mcp port=443 key65280=mcp" + txtName = "_ans.agent.example.com" + txtVal = "v=ans1; ..." + ) + + cases := []struct { + name string + results []port.RecordVerification + // want is the expected (Code, Found) pairs, in order. + want []struct { + code string + found string + } + }{ + { + // Nothing answered for a present-but-absent required record: + // MISSING, Found stays empty (no live value to surface). + name: "absent_required_is_missing", + results: []port.RecordVerification{ + {Record: rec(svcbName, domain.DNSRecordSVCB, svcbVal, true), Found: false, Actual: ""}, + }, + want: []struct { + code string + found string + }{{dnsCodeMissing, ""}}, + }, + { + // Records exist but none matched: MISMATCH carrying the first + // live answer. Pre-Fix-C this was coded MISSING and the live + // value was dropped from the 422. + name: "present_but_wrong_is_mismatch_carrying_live_value", + results: []port.RecordVerification{ + {Record: rec(svcbName, domain.DNSRecordSVCB, svcbVal, true), Found: false, Actual: "wrong"}, + }, + want: []struct { + code string + found string + }{{dnsCodeMismatch, "wrong"}}, + }, + { + // SVCB coexistence shape: the verifier's type-specific subset + // match succeeded (Found=true) even though the live Actual + // string differs benignly from Value (extra SvcParams from a + // sibling family per RFC 9460 §8). Found=true is the verdict — + // no mismatch. Pre-Fix-C the `Actual != Value` arm coded this a + // spurious MISMATCH and defeated coexistence. + name: "found_with_benign_actual_delta_is_ok", + results: []port.RecordVerification{ + {Record: rec(svcbName, domain.DNSRecordSVCB, svcbVal, true), Found: true, Actual: svcbVal + " ipv4hint=192.0.2.1"}, + }, + want: nil, + }, + { + // The Required flag DOES gate classification. Optional records + // in an UNSIGNED zone (no DNSSEC) are skipped whether they are + // truly absent (Actual="") or present-but-wrong (Actual="wrong") + // — TLSA without DNSSEC, the CNAME-at-apex HTTPS RR, and + // union-mode SVCB rows are all Required=false by design and must + // not 422-block the operator. Only the DNSSEC hard-fail above + // bypasses Required (covered by the tamper cases below). Two + // records here, both optional+unsigned, expect ZERO mismatches. + name: "optional_unsigned_records_are_skipped", + results: []port.RecordVerification{ + {Record: rec("_443._tcp.agent.example.com", domain.DNSRecordTLSA, "3 0 1 ab", false), Found: false, Actual: ""}, + {Record: rec(svcbName, domain.DNSRecordHTTPS, "1 . alpn=h2", false), Found: false, Actual: "wrong"}, + }, + want: nil, + }, + { + // DNSSEC hard-fail (untouched block): a DNSSEC-authenticated + // SVCB response whose content disagrees is tampering — coded + // SVCB_DNSSEC_MISMATCH, carrying the live value, regardless of + // Required. Pins the block above the switch stays byte-identical. + name: "dnssec_svcb_tamper_is_hardfail", + results: []port.RecordVerification{ + {Record: rec(svcbName, domain.DNSRecordSVCB, svcbVal, false), Found: false, Actual: "tampered", DNSSECVerified: true}, + }, + want: []struct { + code string + found string + }{{"SVCB" + dnssecMismatchSuffix, "tampered"}}, + }, + { + // DNSSEC hard-fail also fires for TLSA and HTTPS. Pin all three + // cert/service-binding types route through the hard-fail arm. + name: "dnssec_tlsa_and_https_tamper_are_hardfail", + results: []port.RecordVerification{ + {Record: rec("_443._tcp.agent.example.com", domain.DNSRecordTLSA, "3 0 1 ab", false), Found: false, Actual: "3 0 1 cd", DNSSECVerified: true}, + {Record: rec(svcbName, domain.DNSRecordHTTPS, "1 . alpn=h2", false), Found: false, Actual: "1 . alpn=h3", DNSSECVerified: true}, + }, + want: []struct { + code string + found string + }{ + {"TLSA" + dnssecMismatchSuffix, "3 0 1 cd"}, + {"HTTPS" + dnssecMismatchSuffix, "1 . alpn=h3"}, + }, + }, + { + // TXT carries no cryptographic commitment, so a + // DNSSEC-authenticated TXT mismatch is NOT a hard fail — it + // falls through to the standard classification. Here the TXT + // record is absent (Found=false, Actual="") → MISSING, the same + // verdict a non-DNSSEC absent record gets. + name: "dnssec_txt_falls_through_to_missing", + results: []port.RecordVerification{ + {Record: rec(txtName, domain.DNSRecordTXT, txtVal, true), Found: false, Actual: "", DNSSECVerified: true}, + }, + want: []struct { + code string + found string + }{{dnsCodeMissing, ""}}, + }, + { + // TXT DNSSEC mismatch with a live wrong value falls through to + // the standard classification too: present-but-wrong → MISMATCH + // carrying the live value. + name: "dnssec_txt_present_but_wrong_falls_through_to_mismatch", + results: []port.RecordVerification{ + {Record: rec(txtName, domain.DNSRecordTXT, txtVal, true), Found: false, Actual: "v=ans1; stale", DNSSECVerified: true}, + }, + want: []struct { + code string + found string + }{{dnsCodeMismatch, "v=ans1; stale"}}, + }, + { + // Mixed batch: one absent (MISSING), one present-but-wrong + // (MISMATCH carrying live), one satisfied subset (Found=true, + // no mismatch). Order is preserved. + name: "mixed_batch_preserves_order_and_partitions", + results: []port.RecordVerification{ + {Record: rec("a.example.com", domain.DNSRecordSVCB, "v1", true), Found: false, Actual: ""}, + {Record: rec("b.example.com", domain.DNSRecordSVCB, "v2", true), Found: false, Actual: "live2"}, + {Record: rec("c.example.com", domain.DNSRecordSVCB, "v3", true), Found: true, Actual: "v3 extra"}, + }, + want: []struct { + code string + found string + }{ + {dnsCodeMissing, ""}, + {dnsCodeMismatch, "live2"}, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + svc := &RegistrationService{dnsVerifier: stubDNSVerifier{results: tc.results}} + got, perRecord, err := svc.verifyDNSRecords(context.Background(), "agent.example.com", nil) + if err != nil { + t.Fatalf("verifyDNSRecords: unexpected error %v", err) + } + // perRecord must echo the verifier's full result set so the + // attestation builder sees every record (matched or not). + if len(perRecord) != len(tc.results) { + t.Errorf("perRecord len: got %d want %d", len(perRecord), len(tc.results)) + } + if len(got) != len(tc.want) { + t.Fatalf("mismatch count: got %d want %d (%+v)", len(got), len(tc.want), got) + } + for i, w := range tc.want { + if got[i].Code != w.code { + t.Errorf("mismatch[%d] Code: got %q want %q", i, got[i].Code, w.code) + } + if got[i].Found != w.found { + t.Errorf("mismatch[%d] Found: got %q want %q", i, got[i].Found, w.found) + } + } + }) + } +} + +// TestVerifyDNSRecords_VerifierErrorIsReturned pins the WARN-path +// contract: when the underlying verifier returns a systemic error, +// verifyDNSRecords surfaces it (no panic, no swallow) so VerifyDNS can +// log a WARN and map it to a 500. The error value must propagate +// unchanged. +func TestVerifyDNSRecords_VerifierErrorIsReturned(t *testing.T) { + t.Parallel() + sentinel := errors.New("dns unreachable") + svc := &RegistrationService{dnsVerifier: stubDNSVerifier{err: sentinel}} + out, perRecord, err := svc.verifyDNSRecords(context.Background(), "agent.example.com", nil) + if !errors.Is(err, sentinel) { + t.Fatalf("error: got %v want %v", err, sentinel) + } + if out != nil || perRecord != nil { + t.Errorf("on error want nil slices, got out=%v perRecord=%v", out, perRecord) + } +} + +// TestVerifyDNSRecords_NilVerifierSkips pins the local-dev escape hatch: +// a nil dnsVerifier short-circuits to "DNS correct" (no mismatches, no +// error). Untouched by Fix C but exercised here so the early return +// stays covered alongside the classification. +func TestVerifyDNSRecords_NilVerifierSkips(t *testing.T) { + t.Parallel() + svc := &RegistrationService{} // dnsVerifier nil + out, perRecord, err := svc.verifyDNSRecords(context.Background(), "agent.example.com", + []domain.ExpectedDNSRecord{rec("a", domain.DNSRecordSVCB, "v", true)}) + if err != nil || out != nil || perRecord != nil { + t.Fatalf("nil verifier: want all-nil, got out=%v perRecord=%v err=%v", out, perRecord, err) + } +} diff --git a/internal/ra/service/registration.go b/internal/ra/service/registration.go index aba9486..387bb98 100644 --- a/internal/ra/service/registration.go +++ b/internal/ra/service/registration.go @@ -73,14 +73,14 @@ type RegisterRequest struct { ServerCertificateChainPEM string SchemaVersion string - // DNSRecordStyles is the set of DNS record families the RA emits + // DiscoveryProfiles is the set of DNS record families the RA emits // in dnsRecordsProvisioned and tells the operator to publish. - // Each element is one of domain.ValidDNSRecordStyles(); typical + // Each element is one of domain.ValidDiscoveryProfiles(); typical // values are {ANS_SVCB} (default), {ANS_TXT}, or the // {ANS_SVCB, ANS_TXT} transition union. Empty/nil normalizes to - // domain.DefaultDNSRecordStyles(); any invalid element surfaces - // as INVALID_DNS_RECORD_STYLE before the aggregate is created. - DNSRecordStyles []domain.DNSRecordStyle + // domain.DefaultDiscoveryProfiles(); any invalid element surfaces + // as INVALID_DISCOVERY_PROFILE before the aggregate is created. + DiscoveryProfiles []domain.DiscoveryProfile } // RegisterResponse is returned to the HTTP handler after a successful @@ -141,7 +141,7 @@ type RegistrationService struct { outbox OutboxEnqueuer uow port.UnitOfWork dnsVerifier port.DNSVerifier - discoveryRegistry port.DiscoveryRegistry + discoveryRegistry port.ProfileRegistry // signer is the KeyManager + keyID + raID tuple used to sign // outbox events. When nil, events are still persisted but without // a signature — this is only valid for tests; production configs @@ -169,8 +169,8 @@ type EventSigner struct { // runs at process start, never on a request path, so the no-panics-in- // request-paths rule (CLAUDE.md) is upheld. Production builds wire the // bundled ANS-family registry in cmd/ans-ra/main.go via -// registry.New(ans.TXTStyle{}, ans.SVCBStyle{}); tests build the same -// registry through service.NewDefaultDiscoveryRegistry. There is no +// registry.New(ans.TXTProfile{}, ans.SVCBProfile{}); tests build the same +// registry through service.NewDefaultProfileRegistry. There is no // optional builder. func NewRegistrationService( agents port.AgentStore, @@ -183,7 +183,7 @@ func NewRegistrationService( bus port.EventBus, outbox OutboxEnqueuer, uow port.UnitOfWork, - discoveryRegistry port.DiscoveryRegistry, + discoveryRegistry port.ProfileRegistry, ) *RegistrationService { if discoveryRegistry == nil { panic("service.NewRegistrationService: discoveryRegistry is required (nil interface — wire registry.New(...) at construction)") @@ -337,7 +337,7 @@ func (s *RegistrationService) RegisterAgent(ctx context.Context, req RegisterReq } reg.ServerCSR = pendingServerCSR - if err := applyDNSRecordStyles(reg, req); err != nil { + if err := applyDiscoveryProfiles(reg, req); err != nil { return nil, err } diff --git a/internal/ra/service/registration_test.go b/internal/ra/service/registration_test.go index 59f104e..d62f6f9 100644 --- a/internal/ra/service/registration_test.go +++ b/internal/ra/service/registration_test.go @@ -269,7 +269,7 @@ type regFixture struct { identityCA port.IdentityCertificateAuthority serverCA port.ServerCertificateAuthority bus port.EventBus - discoveryReg port.DiscoveryRegistry + discoveryReg port.ProfileRegistry signerPubPEM string } @@ -325,7 +325,7 @@ func newRegFixture(t *testing.T) *regFixture { t.Fatal(err) } - discoveryReg, err := service.NewDefaultDiscoveryRegistry("") + discoveryReg, err := service.NewDefaultProfileRegistry("") if err != nil { t.Fatal(err) } diff --git a/scripts/demo/run-lifecycle.sh b/scripts/demo/run-lifecycle.sh index f0a4437..bbc17cb 100755 --- a/scripts/demo/run-lifecycle.sh +++ b/scripts/demo/run-lifecycle.sh @@ -178,8 +178,27 @@ curl_json GET "/v2/ans/agents/$AGENT_ID" >/dev/null # identity + server CSRs once the operator has proven domain control # via the ACME DNS-01 challenge. This is also when production DNS # records (TRUST/BADGE/DISCOVERY/TLSA) become computable — TLSA's -# value is `3 1 1 SHA-256(server-cert-SPKI)`, so it can't exist before -# the server cert does. +# value is `3 0 1 SHA-256(full server-cert DER)`, so it can't exist +# before the server cert does. +# +# When ans-dns is bundled (start.sh --with-dns), publish the ACME +# DNS-01 challenge TXT into the local authoritative zone first — +# this is the operator action the RA's lookup verifier checks. +# `ans-dns install` cannot do this stage: it reads the pending +# dnsRecords[] block, which is intentionally empty during +# PENDING_VALIDATION (the challenge rides in challenges[] instead). +# Falls through silently in noop mode. +if [ -n "${ANS_DNS_ZONE:-}" ] && [ -x "$BIN/ans-dns" ]; then + note "publishing ACME challenge TXT into $ANS_DNS_ZONE" + PENDING_RESP=$(curl_json GET "/v2/ans/agents/$AGENT_ID") + CH_NAME=$(printf '%s' "$PENDING_RESP" | jq -r '.registrationPending.challenges[0].dnsRecord.name // empty') + CH_VALUE=$(printf '%s' "$PENDING_RESP" | jq -r '.registrationPending.challenges[0].dnsRecord.value // empty') + [ -n "$CH_NAME" ] || fail "no ACME challenge dnsRecord in pending block" + [ -f "$ANS_DNS_ZONE" ] || printf '{"records":{}}' >"$ANS_DNS_ZONE" + jq --arg id "$AGENT_ID-acme" --arg name "$CH_NAME" --arg value "$CH_VALUE" \ + '.records[$id] = [{name:$name, type:"TXT", value:$value, ttl:60}]' \ + "$ANS_DNS_ZONE" >"$ANS_DNS_ZONE.tmp" && mv "$ANS_DNS_ZONE.tmp" "$ANS_DNS_ZONE" +fi header "3. POST /v2/ans/agents/$AGENT_ID/verify-acme (→ PENDING_DNS, issues identity + server certs)" curl_json POST "/v2/ans/agents/$AGENT_ID/verify-acme" >/dev/null assert_2xx "verify-acme" diff --git a/spec/api-spec-v2.yaml b/spec/api-spec-v2.yaml index 13b38d7..9f7138b 100644 --- a/spec/api-spec-v2.yaml +++ b/spec/api-spec-v2.yaml @@ -1023,22 +1023,48 @@ components: type: string enum: [A2A, MCP, HTTP-API] - DNSRecordStyle: + DiscoveryProfile: type: string enum: [ANS_SVCB, ANS_TXT] description: | Names one DNS record family the RA can emit for an agent - registration. Used as the element type of dnsRecordStyles[]. - - - ANS_SVCB: Consolidated Approach SVCB rows at the bare FQDN - per RFC 9460. One row per protocol carrying alpn, port, wk, - and (when the endpoint has a metadataHash) card-sha256 - SvcParams. The recommended default for new integrations. - - ANS_TXT: original `_ans` TXT shape (one row per protocol), - supported indefinitely for operators with existing zone-edit - tooling that targets `_ans.{fqdn}`. Emits an HTTPS RR at - the bare FQDN alongside, since `_ans` TXT carries no - connection hints. + registration. Used as the element type of discoveryProfiles[]. + Each value provisions a complete record set, not a single + record — the bullets below enumerate exactly what an operator + must publish for that profile. + + - ANS_SVCB: the Consolidated Approach (RFC 9460), recommended + default for new integrations. Provisions: + 1. One SVCB row per protocol endpoint at the bare FQDN + (ServiceMode `1 .`), carrying `alpn` (the protocol + token: a2a / mcp / http-api), `port` (the endpoint's + TLS port), `key65280` (the well-known path suffix, e.g. + agent-card.json for A2A; omitted for protocols with no + metadata-file convention), and `key65281` (the + base64url capability digest, present only when the + endpoint declared a metaDataHash). `key65280` and + `key65281` are the RFC 9460 §14.3.1 Private Use + presentation of the draft `wk` / `cap-sha256` SvcParams, + which have no IANA code point yet; the named forms are + unpublishable (strict RFC 9460 parsers reject them), so + the keyNNNNN forms are what reaches DNS. They switch + back to named forms if/when IANA registers the keys. + 2. One `_ans-badge` TXT at `_ans-badge.{fqdn}` (the + transparency-log discovery hint). + 3. One TLSA per distinct TLS port at `_._tcp.{fqdn}` + binding the server certificate (DANE-EE, full cert, + SHA-256), emitted only when a server certificate exists. + - ANS_TXT: original `_ans` TXT shape (one row per protocol at + `_ans.{fqdn}`), supported indefinitely for operators with + existing zone-edit tooling that targets `_ans.{fqdn}`. Emits + an HTTPS RR at the bare FQDN alongside carrying only + `alpn=h2` (an IANA-registered SvcParam — no keyNNNNN form is + needed here; the asymmetry with ANS_SVCB is intentional), + since `_ans` TXT carries no connection hints. The HTTPS RR is + best-effort: operators on CNAME-fronted apexes cannot publish + a record at that name (RFC 1034 §3.6.2) and verification does + not require it. Provisions the same `_ans-badge` TXT and + per-port TLSA records as ANS_SVCB. RevocationReason: type: string @@ -1082,33 +1108,42 @@ components: type: string identityCsrPEM: type: string - dnsRecordStyles: + discoveryProfiles: type: array items: - $ref: '#/components/schemas/DNSRecordStyle' + $ref: '#/components/schemas/DiscoveryProfile' uniqueItems: true minItems: 1 description: | - Set of DNS record families the RA emits in the 202 register - response's dnsRecords[] and in the AGENT_REGISTERED TL - event's attestations.dnsRecordsProvisioned[]. Not echoed on - GET /v2/ans/agents/{agentId}. + Set of DNS record families the RA tells the operator to + publish and emits in the AGENT_REGISTERED TL event's + attestations.dnsRecordsProvisioned[]. The computed records + surface to the client on GET /v2/ans/agents/{agentId} as + registrationPending.dnsRecords[] once the agent reaches + PENDING_DNS — not on the 202 register response, which + returns only the ACME challenge (production records are + deferred until verify-acme proves domain control and issues + the certificates the TLSA binding depends on). Each value names one record family; an operator publishing the union (Consolidated Approach SVCB plus the original - `_ans` TXT shape) sends both. Order is not significant and - duplicates are rejected (`uniqueItems: true`). - - Omitted/missing normalizes to ["ANS_SVCB"] server-side - (the recommended default per RFC 9460). An explicit empty - array is rejected (`minItems: 1`). + `_ans` TXT shape) sends both. Order is not significant. + + Omitted normalizes to ["ANS_SVCB"] server-side (the + recommended default per RFC 9460). The `minItems`/ + `uniqueItems` schema constraints are the canonical client + contract — validate before sending. A non-conformant + request (an explicit empty array, or duplicate values) is + handled defensively rather than rejected: empty is treated + as omitted and duplicates are ignored, but conformant + clients never send either. example: ["ANS_SVCB"] required: - agentDisplayName - - version - agentHost - endpoints - identityCsrPEM + - version AgentRevocationRequest: type: object @@ -1618,5 +1653,10 @@ components: $ref: '#/components/schemas/DnsRecord' found: type: string + description: | + The live record value observed when a record exists + but does not match the required value. expected: - type: string \ No newline at end of file + type: string + description: | + The required value; equals record.value. \ No newline at end of file