From 1741b6703ab3c55297e7a0adf731db285b31646b Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Sat, 16 May 2026 10:10:09 -0400 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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/10] 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/10] =?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/10] 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/10] 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/10] =?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/10] 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