From b3e7adfd8cdde1a2eca149230c475afae02b256b Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Sat, 16 May 2026 10:10:09 -0400 Subject: [PATCH 1/5] 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. Co-Authored-By: Claude Opus 4.7 (1M context) --- 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..011a58d 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 ANS_SPEC.md + // §4.4.2 in github.com/gdcorp-engineering/ans-registry-poc. + 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 c676530e3d13789c4dd734070f85a46dbb235a2f Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Sat, 16 May 2026 10:35:58 -0400 Subject: [PATCH 2/5] 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. Co-Authored-By: Claude Opus 4.7 (1M context) --- 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 | 7 + 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, 249 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 7212267..08e3284 100644 --- a/internal/adapter/docsui/openapi/ra.yaml +++ b/internal/adapter/docsui/openapi/ra.yaml @@ -1095,6 +1095,28 @@ components: - protocol: "A2A" agentUrl: "https://support.example.com" metadataUrl: "https://support.example.com/.well-known/agent-card.json" + 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 16fa81b..99aad57 100644 --- a/internal/adapter/store/sqlite/agent.go +++ b/internal/adapter/store/sqlite/agent.go @@ -40,6 +40,7 @@ type agentRow struct { ACMEDNS01Token sql.NullString `db:"acme_dns01_token"` ACMEChallengeExpiresAtMs sql.NullInt64 `db:"acme_challenge_expires_at_ms"` CapabilitiesHash sql.NullString `db:"capabilities_hash"` + DNSRecordStyle sql.NullString `db:"dns_record_style"` CreatedAtMs int64 `db:"created_at_ms"` UpdatedAtMs int64 `db:"updated_at_ms"` } @@ -77,6 +78,9 @@ func (r agentRow) toDomain() (*domain.AgentRegistration, error) { if r.CapabilitiesHash.Valid { reg.CapabilitiesHash = r.CapabilitiesHash.String } + if r.DNSRecordStyle.Valid { + reg.DNSRecordStyle = domain.DNSRecordStyle(r.DNSRecordStyle.String) + } return reg, nil } @@ -98,8 +102,9 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) supersedes_registration_id, acme_dns01_token, acme_challenge_expires_at_ms, capabilities_hash, + dns_record_style, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` res, err := s.db.extx(ctx).ExecContext(ctx, q, agent.AgentID, agent.OwnerID, @@ -115,6 +120,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) nullableString(agent.ACMEChallenge.DNS01Token), nullableMs(agent.ACMEChallenge.ExpiresAt), nullableString(agent.CapabilitiesHash), + nullableString(string(agent.DNSRecordStyle)), now, now, ) if err != nil { @@ -138,6 +144,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) acme_dns01_token = ?, acme_challenge_expires_at_ms = ?, capabilities_hash = ?, + dns_record_style = ?, updated_at_ms = ? WHERE id = ?` _, err := s.db.extx(ctx).ExecContext(ctx, q, @@ -149,6 +156,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) nullableString(agent.ACMEChallenge.DNS01Token), nullableMs(agent.ACMEChallenge.ExpiresAt), nullableString(agent.CapabilitiesHash), + 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 675df4f..f5f4ea6 100644 --- a/internal/domain/agent.go +++ b/internal/domain/agent.go @@ -116,6 +116,13 @@ type AgentRegistration struct { // matches the AIM's verification expectation directly. 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 011a58d..ba88f74 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 cf7a2f1..5b60472 100644 --- a/internal/ra/handler/registration.go +++ b/internal/ra/handler/registration.go @@ -47,6 +47,14 @@ type registrationRequest struct { // round-trip through map[string]any would risk reordering or // number normalization that would shift the resulting digest. AgentCardContent json.RawMessage `json:"agentCardContent,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 { @@ -162,6 +170,7 @@ func (h *RegistrationHandler) Register(w http.ResponseWriter, r *http.Request) { ServerCertificatePEM: req.ServerCertificatePEM, ServerCertificateChainPEM: req.ServerCertificateChainPEM, AgentCardContent: []byte(req.AgentCardContent), + 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 277187a..74fc5c5 100644 --- a/internal/ra/service/helpers.go +++ b/internal/ra/service/helpers.go @@ -28,6 +28,33 @@ func hashAgentCardContent(content []byte) (string, error) { return hex.EncodeToString(sum[:]), nil } +// 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 +} + // applyAgentCardContentHash hashes the optional agentCardContent // the operator submitted on the V2 registration request and stores // the digest on the aggregate per ANS_SPEC.md §A.1. Empty content diff --git a/internal/ra/service/registration.go b/internal/ra/service/registration.go index 6171a68..6c1f080 100644 --- a/internal/ra/service/registration.go +++ b/internal/ra/service/registration.go @@ -86,6 +86,14 @@ type RegisterRequest struct { // which hashes the protocol-native metadata (e.g., A2A AgentCard). // Empty when omitted on the registration request. AgentCardContent []byte + + // 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 @@ -328,6 +336,10 @@ func (s *RegistrationService) RegisterAgent(ctx context.Context, req RegisterReq return nil, err } + 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 7212267..08e3284 100644 --- a/spec/api-spec-v2.yaml +++ b/spec/api-spec-v2.yaml @@ -1095,6 +1095,28 @@ components: - protocol: "A2A" agentUrl: "https://support.example.com" metadataUrl: "https://support.example.com/.well-known/agent-card.json" + 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 c72a97ed97eeed035c92e6b005f3777789680db9 Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Sat, 16 May 2026 10:51:37 -0400 Subject: [PATCH 3/5] 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. Co-Authored-By: Claude Opus 4.7 (1M context) --- 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 ba88f74..e758e81 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 74e1e150be5823f9a47ec0d59853d9594c9cf545 Mon Sep 17 00:00:00 2001 From: kperry Date: Wed, 20 May 2026 15:29:14 -0500 Subject: [PATCH 4/5] feat(dns): update DNS record style to use CONSTANT_CASE and enhance validation --- internal/adapter/dns/dns_test.go | 145 +++++--- internal/adapter/dns/lookup.go | 8 + internal/adapter/docsui/openapi/ra.yaml | 37 +- .../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 | 173 +++++++++ internal/ra/service/lifecycle.go | 39 +- spec/api-spec-v2.yaml | 37 +- 11 files changed, 642 insertions(+), 259 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 08e3284..7049733 100644 --- a/internal/adapter/docsui/openapi/ra.yaml +++ b/internal/adapter/docsui/openapi/ra.yaml @@ -1097,26 +1097,25 @@ components: metadataUrl: "https://support.example.com/.well-known/agent-card.json" 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[]. - - 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" + 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" required: - agentDisplayName - version @@ -1335,7 +1334,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 e758e81..d61b182 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 74fc5c5..69b47ef 100644 --- a/internal/ra/service/helpers.go +++ b/internal/ra/service/helpers.go @@ -6,6 +6,7 @@ import ( "encoding/pem" "errors" "fmt" + "strings" "time" anscrypto "github.com/godaddy/ans/internal/crypto" @@ -31,23 +32,30 @@ func hashAgentCardContent(content []byte) (string, error) { // 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..f36a1e2 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,175 @@ 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()) + } + } +} + +// ----- applyAgentCardContentHash ----- + +// TestApplyAgentCardContentHash covers the empty / valid / malformed +// branches. Empty is the spec-conformant "no Trust Card body submitted" +// no-op; valid stamps a 64-char SHA-256 hex digest; malformed surfaces +// INVALID_AGENT_CARD_CONTENT. +func TestApplyAgentCardContentHash(t *testing.T) { + tests := []struct { + name string + content []byte + wantHash bool + wantErrCode string + }{ + {name: "empty_is_noop", content: nil}, + {name: "valid_json_sets_hash", content: []byte(`{"name":"agent","version":"1.0.0"}`), wantHash: true}, + {name: "malformed_json_rejected", content: []byte(`{not json`), wantErrCode: "INVALID_AGENT_CARD_CONTENT"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + reg := &domain.AgentRegistration{} + err := applyAgentCardContentHash(reg, tc.content) + 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 tc.wantHash && len(reg.CapabilitiesHash) != 64 { + t.Errorf("expected 64-char hex digest, got len %d: %q", + len(reg.CapabilitiesHash), reg.CapabilitiesHash) + } + if !tc.wantHash && reg.CapabilitiesHash != "" { + t.Errorf("expected empty hash, got %q", reg.CapabilitiesHash) + } + }) + } +} + +// TestHashAgentCardContent_DeterministicAcrossKeyOrder pins the JCS +// canonicalization invariant: two JSON objects with the same fields in +// different orders produce the same digest. This is what makes the +// cross-channel guarantee (DNS card-sha256 ↔ TL capabilities_hash ↔ +// live Trust Card body) work — the wire form can vary, the canonical +// form cannot. +func TestHashAgentCardContent_DeterministicAcrossKeyOrder(t *testing.T) { + a, errA := hashAgentCardContent([]byte(`{"a":1,"b":2}`)) + if errA != nil { + t.Fatalf("hash a: %v", errA) + } + b, errB := hashAgentCardContent([]byte(`{"b":2,"a":1}`)) + if errB != nil { + t.Fatalf("hash b: %v", errB) + } + if a != b { + t.Errorf("JCS canonicalization should produce key-order-invariant digests: %q != %q", a, b) + } +} diff --git a/internal/ra/service/lifecycle.go b/internal/ra/service/lifecycle.go index 2f34919..69110b0 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 08e3284..7049733 100644 --- a/spec/api-spec-v2.yaml +++ b/spec/api-spec-v2.yaml @@ -1097,26 +1097,25 @@ components: metadataUrl: "https://support.example.com/.well-known/agent-card.json" 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[]. - - 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" + 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" required: - agentDisplayName - version @@ -1335,7 +1334,7 @@ components: type: string type: type: string - enum: [HTTPS, TLSA, TXT] + enum: [HTTPS, SVCB, TLSA, TXT] value: type: string priority: From 4d2b2a09026a49f273a4e8cfdc2ecf18ff1fc7ec Mon Sep 17 00:00:00 2001 From: kperry Date: Thu, 21 May 2026 12:25:30 -0500 Subject: [PATCH 5/5] feat(dns): update DNS record style to support multiple families and enhance validation --- 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 | 150 +++++++++++++----- internal/ra/service/registration.go | 15 +- spec/api-spec-v2.yaml | 52 +++--- 11 files changed, 475 insertions(+), 256 deletions(-) diff --git a/internal/adapter/docsui/openapi/ra.yaml b/internal/adapter/docsui/openapi/ra.yaml index 7049733..c1fd972 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: @@ -1095,27 +1112,26 @@ components: - protocol: "A2A" agentUrl: "https://support.example.com" metadataUrl: "https://support.example.com/.well-known/agent-card.json" - 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 99aad57..0f0fd74 100644 --- a/internal/adapter/store/sqlite/agent.go +++ b/internal/adapter/store/sqlite/agent.go @@ -40,7 +40,7 @@ type agentRow struct { ACMEDNS01Token sql.NullString `db:"acme_dns01_token"` ACMEChallengeExpiresAtMs sql.NullInt64 `db:"acme_challenge_expires_at_ms"` CapabilitiesHash sql.NullString `db:"capabilities_hash"` - 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"` } @@ -78,12 +78,58 @@ func (r agentRow) toDomain() (*domain.AgentRegistration, error) { if r.CapabilitiesHash.Valid { reg.CapabilitiesHash = r.CapabilitiesHash.String } - 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. @@ -102,7 +148,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) supersedes_registration_id, acme_dns01_token, acme_challenge_expires_at_ms, capabilities_hash, - dns_record_style, + dns_record_styles, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` res, err := s.db.extx(ctx).ExecContext(ctx, q, @@ -120,7 +166,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) nullableString(agent.ACMEChallenge.DNS01Token), nullableMs(agent.ACMEChallenge.ExpiresAt), nullableString(agent.CapabilitiesHash), - nullableString(string(agent.DNSRecordStyle)), + nullableString(encodeDNSRecordStyles(agent.DNSRecordStyles)), now, now, ) if err != nil { @@ -144,7 +190,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) acme_dns01_token = ?, acme_challenge_expires_at_ms = ?, capabilities_hash = ?, - dns_record_style = ?, + dns_record_styles = ?, updated_at_ms = ? WHERE id = ?` _, err := s.db.extx(ctx).ExecContext(ctx, q, @@ -156,7 +202,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) nullableString(agent.ACMEChallenge.DNS01Token), nullableMs(agent.ACMEChallenge.ExpiresAt), nullableString(agent.CapabilitiesHash), - 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 f5f4ea6..2d64441 100644 --- a/internal/domain/agent.go +++ b/internal/domain/agent.go @@ -116,12 +116,13 @@ type AgentRegistration struct { // matches the AIM's verification expectation directly. 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 d61b182..a96e525 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 5b60472..b85ede1 100644 --- a/internal/ra/handler/registration.go +++ b/internal/ra/handler/registration.go @@ -48,13 +48,13 @@ type registrationRequest struct { // number normalization that would shift the resulting digest. AgentCardContent json.RawMessage `json:"agentCardContent,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 { @@ -170,7 +170,7 @@ func (h *RegistrationHandler) Register(w http.ResponseWriter, r *http.Request) { ServerCertificatePEM: req.ServerCertificatePEM, ServerCertificateChainPEM: req.ServerCertificateChainPEM, AgentCardContent: []byte(req.AgentCardContent), - DNSRecordStyle: domain.DNSRecordStyle(req.DNSRecordStyle), + DNSRecordStyles: toDomainDNSRecordStyles(req.DNSRecordStyles), }) if err != nil { WriteError(w, err) @@ -180,6 +180,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 69b47ef..823b666 100644 --- a/internal/ra/service/helpers.go +++ b/internal/ra/service/helpers.go @@ -29,37 +29,49 @@ func hashAgentCardContent(content []byte) (string, error) { return hex.EncodeToString(sum[:]), nil } -// 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 f36a1e2..3ca60ff 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_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_valid_both", - req: RegisterRequest{SchemaVersion: "V2", DNSRecordStyle: domain.DNSRecordStyleBoth}, - wantStyle: domain.DNSRecordStyleBoth, + 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,31 +337,48 @@ 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 +} + // ----- applyAgentCardContentHash ----- // TestApplyAgentCardContentHash covers the empty / valid / malformed diff --git a/internal/ra/service/registration.go b/internal/ra/service/registration.go index 6c1f080..3311b51 100644 --- a/internal/ra/service/registration.go +++ b/internal/ra/service/registration.go @@ -87,13 +87,14 @@ type RegisterRequest struct { // Empty when omitted on the registration request. AgentCardContent []byte - // 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 @@ -336,7 +337,7 @@ func (s *RegistrationService) RegisterAgent(ctx context.Context, req RegisterReq return nil, err } - 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 7049733..c1fd972 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: @@ -1095,27 +1112,26 @@ components: - protocol: "A2A" agentUrl: "https://support.example.com" metadataUrl: "https://support.example.com/.well-known/agent-card.json" - 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