diff --git a/internal/adapter/dns/dns_test.go b/internal/adapter/dns/dns_test.go index 0cb0347..84f9741 100644 --- a/internal/adapter/dns/dns_test.go +++ b/internal/adapter/dns/dns_test.go @@ -252,6 +252,129 @@ func TestLookupVerifier_HTTPSMatch(t *testing.T) { } } +// TestLookupVerifier_SVCB exercises the Consolidated Approach SVCB +// verifier across match, missing, and shape-mismatch paths. The match +// case tests the same presentation form the RA's +// ComputeRequiredDNSRecords emits (see internal/domain/dnsrecords.go). +// +// Restricted to IANA-registered SvcParamKeys (alpn + port) because the +// miekg/dns zone-file parser used by the test fixture rejects symbolic +// names for the still-provisional Consolidated Approach SvcParams (`wk`, +// `card-sha256`, `cap`, etc.). Until those keys are IANA-registered per +// RFC 9460 §6, the verifier-side test exercises the dispatch and +// 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_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) + 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.DNSRecordHTTPS, + Value: `1 . alpn=h2`, + Required: false, + }} + got := s.verifyAgainst(t, recs) + 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_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) + s.setAD(true) + 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]) + } + if !got[0].dnssec { + t.Error("DNSSECVerified must surface true for SVCB when the response carried AD=1") + } +} + 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..9426ed1 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) } @@ -211,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) @@ -222,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) @@ -253,6 +263,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/adapter/docsui/openapi/ra.yaml b/internal/adapter/docsui/openapi/ra.yaml index 7212267..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,6 +1112,26 @@ components: - protocol: "A2A" agentUrl: "https://support.example.com" metadataUrl: "https://support.example.com/.well-known/agent-card.json" + dnsRecordStyles: + type: array + items: + $ref: '#/components/schemas/DNSRecordStyle' + uniqueItems: true + minItems: 1 + description: | + Set of DNS record families the RA emits in the 202 register + response's dnsRecords[] and in the AGENT_REGISTERED TL + event's attestations.dnsRecordsProvisioned[]. Not echoed on + GET /v2/ans/agents/{agentId}. + + 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 @@ -1313,7 +1350,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/agent.go b/internal/adapter/store/sqlite/agent.go index 16fa81b..0f0fd74 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"` + DNSRecordStyles sql.NullString `db:"dns_record_styles"` CreatedAtMs int64 `db:"created_at_ms"` UpdatedAtMs int64 `db:"updated_at_ms"` } @@ -77,9 +78,58 @@ func (r agentRow) toDomain() (*domain.AgentRegistration, error) { if r.CapabilitiesHash.Valid { reg.CapabilitiesHash = r.CapabilitiesHash.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. @@ -98,8 +148,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_styles, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` res, err := s.db.extx(ctx).ExecContext(ctx, q, agent.AgentID, agent.OwnerID, @@ -115,6 +166,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) nullableString(agent.ACMEChallenge.DNS01Token), nullableMs(agent.ACMEChallenge.ExpiresAt), nullableString(agent.CapabilitiesHash), + nullableString(encodeDNSRecordStyles(agent.DNSRecordStyles)), now, now, ) if err != nil { @@ -138,6 +190,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) acme_dns01_token = ?, acme_challenge_expires_at_ms = ?, capabilities_hash = ?, + dns_record_styles = ?, updated_at_ms = ? WHERE id = ?` _, err := s.db.extx(ctx).ExecContext(ctx, q, @@ -149,6 +202,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) nullableString(agent.ACMEChallenge.DNS01Token), nullableMs(agent.ACMEChallenge.ExpiresAt), nullableString(agent.CapabilitiesHash), + 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 new file mode 100644 index 0000000..5bc298f --- /dev/null +++ b/internal/adapter/store/sqlite/migrations/007_agent_dns_record_style.sql @@ -0,0 +1,40 @@ +-- 007_agent_dns_record_style.sql +-- Persist the operator's chosen set of DNS record families on the +-- registration row so verify-acme / verify-dns / badge responses +-- carry the same shape the operator chose at registration time. +-- +-- Stored as a JSON array of CONSTANT_CASE strings matching the V2 +-- register schema's DNSRecordStyle enum: +-- "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 ["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_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 ["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_styles = '["ANS_TXT"]' + WHERE dns_record_styles IS NULL; diff --git a/internal/domain/agent.go b/internal/domain/agent.go index 675df4f..2d64441 100644 --- a/internal/domain/agent.go +++ b/internal/domain/agent.go @@ -116,6 +116,14 @@ type AgentRegistration struct { // matches the AIM's verification expectation directly. CapabilitiesHash string `json:"capabilitiesHash,omitempty"` + // DNSRecordStyles is the set of DNS record families the RA emits + // for this registration. Each value names one family — typically + // {ANS_SVCB} (Consolidated Approach), {ANS_TXT} (original `_ans` + // 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. PendingEvents []Event `json:"-"` diff --git a/internal/domain/dnsrecords.go b/internal/domain/dnsrecords.go index e1d1794..a96e525 100644 --- a/internal/domain/dnsrecords.go +++ b/internal/domain/dnsrecords.go @@ -1,6 +1,101 @@ package domain -import "fmt" +import ( + "encoding/base64" + "encoding/hex" + "fmt" + "net/url" + "strconv" +) + +// DNSRecordStyle names one DNS record family the RA can emit for an +// agent registration. A registration carries a *set* of styles +// (AgentRegistration.DNSRecordStyles); operators publishing the union +// 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.). The +// `ANS_` prefix anchors the namespace so a future second agentic spec +// adding its own SVCB family doesn't collide. +type DNSRecordStyle string + +const ( + // DNSRecordStyleSVCB emits Consolidated Approach SVCB records per + // RFC 9460 — one row per protocol at the bare FQDN, carrying alpn, + // port, wk, and card-sha256 SvcParams. + DNSRecordStyleSVCB DNSRecordStyle = "ANS_SVCB" + + // 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" +) + +// 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 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 DNSRecordStyleSVCB, DNSRecordStyleTXT: + return true + } + return false +} + +// 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(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. type DNSRecordType string @@ -9,6 +104,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. @@ -34,6 +137,22 @@ 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 is keyed off reg.DNSRecordStyles: +// +// - {ANS_SVCB} (default, recommended): Consolidated Approach SVCB +// rows (one per protocol) plus the shared `_ans-`-prefixed records +// plus the server-cert TLSA. No legacy `_ans` TXT rows. +// - {ANS_TXT}: the original `_ans` TXT shape (one row per protocol) +// plus the same shared records. No SVCB rows. Backwards-compatible +// with operators who registered before the Consolidated Approach +// landed and have existing zone-edit tooling for `_ans` TXT. +// - {ANS_SVCB, ANS_TXT}: the §4.4.2 transition shape; operators run +// both record families on the same zone for a defined window. +// +// Empty/missing reg.DNSRecordStyles is normalized to +// DefaultDNSRecordStyles(); invalid elements are dropped (the +// service layer rejects bad inputs at the boundary). func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { fqdn := reg.FQDN() // Version is emitted as a bare semver string ("1.2.0"). The @@ -44,20 +163,104 @@ func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { version := reg.AnsName.Version().String() 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) + emitTXT, emitSVCB := resolveEmissionFlags(reg.DNSRecordStyles) + + // _ans TXT record for each protocol endpoint — legacy discovery. + if emitTXT { + for _, ep := range reg.Endpoints { + value := fmt.Sprintf("v=ans1; version=%s; p=%s; mode=direct; url=%s", + version, protocolToANSValue(ep.Protocol), ep.AgentURL) + records = append(records, ExpectedDNSRecord{ + Name: fmt.Sprintf("_ans.%s", fqdn), + Type: DNSRecordTXT, + Value: value, + Purpose: PurposeDiscovery, + Required: true, + TTL: 3600, + }) + } + + // HTTPS RR (RFC 9460 type 65) at the agent FQDN — service + // binding for HTTP/2 (and Encrypted Client Hello when the + // AHP provides an ECH config out-of-band). Per §A.8.1 the + // RA generates the content; the AHP decides whether to + // publish based on whether their apex is aliased via CNAME + // (CNAME at the agent FQDN blocks HTTPS RR at the same name + // per RFC 1034 §3.6.2). + // + // Skipped for the consolidated form: the SVCB rows already + // carry alpn / port / ECH SvcParams, so an HTTPS RR + // alongside duplicates content (§A.8.2). Legacy keeps it + // because the `_ans` TXT family does not carry connection + // hints — clients without ANS-protocol awareness rely on + // HTTPS RR for ALPN signalling. + // + // Required=false: operators on CNAME-fronted apex zones + // cannot publish this record at the same name; the spec + // does not block them on its absence. records = append(records, ExpectedDNSRecord{ - Name: fmt.Sprintf("_ans.%s", fqdn), - Type: DNSRecordTXT, - Value: value, + Name: fqdn, + Type: DNSRecordHTTPS, + Value: `1 . alpn=h2`, Purpose: PurposeDiscovery, - Required: true, + Required: false, TTL: 3600, }) } + // Consolidated Approach SVCB record at the bare FQDN — one per + // protocol endpoint. RFC 9460 ServiceMode (SvcPriority 1) with + // TargetName "." (same name) so address resolution stays at the + // agent's FQDN. SvcParams from DNS-AID, ANS, and other agentic + // specs coexist via RFC 9460 §8 unknown-key ignore. card-sha256 + // carries base64url(reg.CapabilitiesHash) when the operator + // submitted agentCardContent; otherwise the SvcParam is absent + // and a verifier falls back to TOFU on first Trust Card fetch. + // + // Provisional-key note: `wk` and `card-sha256` are not yet + // IANA-registered SvcParamKeys per RFC 9460 §6. The Consolidated + // Approach draft emits them by symbolic name; production + // deployments using strict-RFC parsers MAY need to publish them + // in keyNNNNN form until registration completes. The expected + // value the RA writes here uses the symbolic form to match the + // draft's worked examples; the verifier compares post- + // normalization, and operators whose authoritative DNS only + // emits keyNNNNN form will see a mismatch the RA reports as a + // non-blocking integrity finding (Required=false below). + // + // Required=false: §4.4.2 marks the Consolidated Approach as MAY, + // opt-in alongside the `_ans` TXT family during the transition. + if emitSVCB { + cardSHA := capabilitiesHashBase64URL(reg.CapabilitiesHash) + for _, ep := range reg.Endpoints { + alpn := protocolToANSValue(ep.Protocol) + wk := wkPathFor(ep.Protocol) + port := svcbPortFor(ep.AgentURL) + // RFC 9460 §2.1 presentation form: unquoted SvcParamValue when + // the value has no characters special to the presentation + // format. alpn tokens (a2a, mcp), port digits, well-known path + // suffixes (agent-card.json), and base64url digests all qualify. + // The resolver-side formatter (formatHTTPSValue) also emits + // unquoted, so the verifier's normalize+compare matches without + // quote-stripping. + value := fmt.Sprintf(`1 . alpn=%s port=%d`, alpn, port) + if wk != "" { + value += fmt.Sprintf(` wk=%s`, wk) + } + if cardSHA != "" { + value += fmt.Sprintf(` card-sha256=%s`, cardSHA) + } + records = append(records, ExpectedDNSRecord{ + Name: fqdn, + Type: DNSRecordSVCB, + Value: value, + Purpose: PurposeDiscovery, + Required: 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 +321,72 @@ 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 "" + } +} + +// svcbPortFor returns the TCP port to advertise in the SVCB SvcParam +// `port=`. Reads it from the endpoint URL's authority. Falls back to +// 443 (https) / 80 (http) when the URL omits a port. Empty input or +// unparseable URL returns 443 — the §4.4.2 default for agent endpoints. +// +// Without this, every endpoint emitted a hardcoded port=443 SvcParam, +// silently breaking verify-dns for agents on non-443 endpoints +// (operators would publish their actual port; the RA's expected +// record would say 443; the records would mismatch). +func svcbPortFor(agentURL string) int { + if agentURL == "" { + return 443 + } + u, err := url.Parse(agentURL) + if err != nil { + return 443 + } + if p := u.Port(); p != "" { + if n, err := strconv.Atoi(p); err == nil { + return n + } + } + if u.Scheme == "http" { + return 80 + } + return 443 +} + +// capabilitiesHashBase64URL re-encodes a hex-lowercase SHA-256 digest +// (the form `AgentRegistration.CapabilitiesHash` carries) into the +// base64url form (RFC 4648 §5, no padding) the SVCB `card-sha256` +// SvcParam expects. Empty input returns empty output, which the caller +// SHOULD treat as "omit the SvcParam entirely" — agents registered +// without `agentCardContent` have no committed value to publish. +func capabilitiesHashBase64URL(hexDigest string) string { + if hexDigest == "" { + return "" + } + raw, err := hex.DecodeString(hexDigest) + if err != nil || len(raw) == 0 { + // Malformed input is logically equivalent to absence; the RA + // stores well-formed hex by construction (helpers.go: + // hashAgentCardContent), but defensive on the boundary. + return "" + } + return base64.RawURLEncoding.EncodeToString(raw) +} diff --git a/internal/domain/dnsrecords_test.go b/internal/domain/dnsrecords_test.go index 9929dec..fd4c2c9 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 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"}, @@ -21,21 +25,44 @@ 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 + 1 HTTPS + 2 Consolidated Approach SVCB + + // 1 badge TXT (no TLSA: no cert). + var ansTxtCount, httpsCount, svcbCount, badgeCount, tlsaCount int for _, r := range records { switch r.Purpose { case PurposeDiscovery: - 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") + case DNSRecordHTTPS: + httpsCount++ + assert.Equal(t, "agent.example.com", r.Name, + "HTTPS RR at the bare FQDN per §A.8.1") + assert.False(t, r.Required, + "HTTPS RR is opt-in: blocked by CNAME at @ when AHP fronts the apex") + assert.Contains(t, r.Value, "alpn=h2") + default: + t.Errorf("unexpected discovery record type %q", r.Type) + } case PurposeBadge: badgeCount++ assert.Equal(t, DNSRecordTXT, r.Type) @@ -48,11 +75,290 @@ func TestComputeRequiredDNSRecords_WithoutCert(t *testing.T) { } } - assert.Equal(t, 2, anxCount) + assert.Equal(t, 2, ansTxtCount) + assert.Equal(t, 1, httpsCount, "one HTTPS RR at the bare FQDN per §A.8.1") + assert.Equal(t, 2, svcbCount, "one SVCB row per protocol at the bare FQDN") assert.Equal(t, 1, badgeCount) assert.Equal(t, 0, tlsaCount, "no cert → no TLSA record") } +// TestComputeRequiredDNSRecords_StyleMatrix exercises every emission +// rule in one table. Each row pins the per-record-type shape the +// operator is asked to publish given a (styles, protocol, +// capabilitiesHash, agentURL) tuple. The matrix covers: +// +// - {ANS_TXT} emits _ans TXT + HTTPS RR; no SVCB. +// - {ANS_SVCB} emits SVCB only (no HTTPS RR — duplicate signalling). +// - {ANS_SVCB, ANS_TXT} emits the union. +// - SVCB SvcParam composition: wk= (per-protocol), port= (from URL), +// card-sha256= (only when CapabilitiesHash is set). +// - svcbPortFor: explicit non-443 port flows through, default https +// URLs fall back to 443. +// - Empty styles (nil slice) coerces to the default ({ANS_SVCB}). +// - All-invalid styles set still produces records (defensive +// fallback in the domain layer; the service rejects bad inputs). +func TestComputeRequiredDNSRecords_StyleMatrix(t *testing.T) { + const cardHex = "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27" + const wantCardBase64 = "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc" + + tests := []struct { + name string + styles []DNSRecordStyle + protocol Protocol + agentURL string + capabilitiesHash string + wantHTTPS bool + wantSVCB bool + wantLegacyTXT bool + wantSVCBPort string // substring expected in SVCB value (e.g. "port=443") + wantSVCBWk string // "" means SVCB MUST NOT contain "wk=" + wantSVCBCard string // "" means SVCB MUST NOT contain "card-sha256" + }{ + { + name: "ans_txt_only_emits_https_rr_no_svcb", + styles: []DNSRecordStyle{DNSRecordStyleTXT}, + protocol: ProtocolA2A, + agentURL: "https://agent.example.com", + wantHTTPS: true, + wantLegacyTXT: true, + }, + { + name: "ans_svcb_only_omits_https_rr", + styles: []DNSRecordStyle{DNSRecordStyleSVCB}, + protocol: ProtocolA2A, + agentURL: "https://agent.example.com", + wantSVCB: true, + wantSVCBPort: "port=443", + wantSVCBWk: "wk=agent-card.json", + }, + { + name: "union_emits_both_families", + styles: []DNSRecordStyle{DNSRecordStyleSVCB, DNSRecordStyleTXT}, + protocol: ProtocolA2A, + agentURL: "https://agent.example.com", + wantHTTPS: true, + wantLegacyTXT: true, + wantSVCB: true, + wantSVCBPort: "port=443", + wantSVCBWk: "wk=agent-card.json", + }, + { + name: "svcb_mcp_wk_mcp_json", + styles: []DNSRecordStyle{DNSRecordStyleSVCB}, + protocol: ProtocolMCP, + agentURL: "https://agent.example.com/mcp", + wantSVCB: true, + wantSVCBPort: "port=443", + wantSVCBWk: "wk=mcp.json", + }, + { + name: "svcb_http_api_omits_wk", + styles: []DNSRecordStyle{DNSRecordStyleSVCB}, + 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", + styles: []DNSRecordStyle{DNSRecordStyleSVCB}, + 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", + styles: []DNSRecordStyle{DNSRecordStyleSVCB}, + 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", + styles: []DNSRecordStyle{DNSRecordStyleSVCB}, + protocol: ProtocolA2A, + agentURL: "http://agent.example.com", + wantSVCB: true, + wantSVCBPort: "port=80", + wantSVCBWk: "wk=agent-card.json", + }, + { + name: "empty_styles_coerces_to_default", + styles: nil, + protocol: ProtocolA2A, + agentURL: "https://agent.example.com", + wantSVCB: true, + wantSVCBPort: "port=443", + wantSVCBWk: "wk=agent-card.json", + }, + { + name: "all_invalid_styles_falls_back_to_default", + styles: []DNSRecordStyle{DNSRecordStyle("garbage"), DNSRecordStyle("nonsense")}, + protocol: ProtocolA2A, + agentURL: "https://agent.example.com", + wantSVCB: true, + wantSVCBPort: "port=443", + wantSVCBWk: "wk=agent-card.json", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") + reg := &AgentRegistration{ + AnsName: ansName, + DNSRecordStyles: tc.styles, + CapabilitiesHash: tc.capabilitiesHash, + Endpoints: []AgentEndpoint{ + {Protocol: tc.protocol, AgentURL: tc.agentURL}, + }, + } + records := ComputeRequiredDNSRecords(reg) + + var sawHTTPS, sawSVCB, sawLegacyTXT bool + var svcbValue string + 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 + } + } + } + + assert.Equal(t, tc.wantHTTPS, sawHTTPS, "HTTPS RR presence") + assert.Equal(t, tc.wantSVCB, sawSVCB, "SVCB row presence") + assert.Equal(t, tc.wantLegacyTXT, sawLegacyTXT, "_ans TXT presence") + + if tc.wantSVCB { + assert.Contains(t, svcbValue, tc.wantSVCBPort, + "SVCB port SvcParam mismatch") + if tc.wantSVCBWk != "" { + assert.Contains(t, svcbValue, tc.wantSVCBWk, "SVCB wk SvcParam mismatch") + } else { + assert.NotContains(t, svcbValue, "wk=", + "SVCB MUST NOT carry wk= when protocol has no metadata convention") + } + if tc.wantSVCBCard != "" { + assert.Contains(t, svcbValue, tc.wantSVCBCard, "SVCB card-sha256 SvcParam mismatch") + } else { + assert.NotContains(t, svcbValue, "card-sha256", + "SVCB MUST NOT carry card-sha256 when CapabilitiesHash is empty") + } + } + }) + } +} + +// TestCapabilitiesHashBase64URL pins the hex→base64url conversion. +func TestCapabilitiesHashBase64URL(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "live_webmesh_trust_card_digest", + in: "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27", + want: "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc", + }, + { + name: "all_zeros", + in: "0000000000000000000000000000000000000000000000000000000000000000", + want: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }, + { + name: "empty_input_empty_output", + in: "", + want: "", + }, + { + name: "malformed_hex_returns_empty", + in: "not hex", + want: "", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := capabilitiesHashBase64URL(tc.in) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestWkPathFor pins the per-protocol well-known suffix mapping. +func TestWkPathFor(t *testing.T) { + tests := []struct { + p Protocol + want string + }{ + {ProtocolA2A, "agent-card.json"}, + {ProtocolMCP, "mcp.json"}, + {ProtocolHTTPAPI, ""}, + {Protocol("UNKNOWN"), ""}, + } + for _, tc := range tests { + t.Run(string(tc.p), func(t *testing.T) { + assert.Equal(t, tc.want, wkPathFor(tc.p)) + }) + } +} + +// TestValidDNSRecordStyles pins the canonical valid set of +// DNSRecordStyle values returned by the helper used in the V2 +// INVALID_DNS_RECORD_STYLE error message and (eventually) by spec +// 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) +} + +// 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) { ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") reg := &AgentRegistration{ 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/handler/registration.go b/internal/ra/handler/registration.go index cf7a2f1..b85ede1 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"` + + // 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 { @@ -162,6 +170,7 @@ func (h *RegistrationHandler) Register(w http.ResponseWriter, r *http.Request) { ServerCertificatePEM: req.ServerCertificatePEM, ServerCertificateChainPEM: req.ServerCertificateChainPEM, AgentCardContent: []byte(req.AgentCardContent), + DNSRecordStyles: toDomainDNSRecordStyles(req.DNSRecordStyles), }) if err != nil { WriteError(w, err) @@ -171,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 277187a..823b666 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" @@ -28,6 +29,52 @@ func hashAgentCardContent(content []byte) (string, error) { return hex.EncodeToString(sum[:]), nil } +// applyDNSRecordStyles resolves the set of DNS record families the +// registration emits and stores it on the aggregate. +// +// 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.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 +} + // 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/helpers_test.go b/internal/ra/service/helpers_test.go index 7eb0204..3ca60ff 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,247 @@ func selfSignedCertPEM(t *testing.T) string { } return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) } + +// ----- applyDNSRecordStyles ----- + +// TestApplyDNSRecordStyles covers the V1-pin / V2-default / V2-validate +// branches, including the INVALID_DNS_RECORD_STYLE error path and +// duplicate-element deduplication. The integration tests follow happy +// paths through RegisterAgent and don't reach the invalid-element +// branch directly. +func TestApplyDNSRecordStyles(t *testing.T) { + tests := []struct { + name string + req RegisterRequest + wantStyles []domain.DNSRecordStyle + wantErrCode string + }{ + { + name: "v1_pins_to_ans_txt_ignoring_request_field", + req: RegisterRequest{ + SchemaVersion: "V1", + DNSRecordStyles: []domain.DNSRecordStyle{domain.DNSRecordStyleSVCB}, + }, + wantStyles: []domain.DNSRecordStyle{domain.DNSRecordStyleTXT}, + }, + { + name: "v2_nil_normalizes_to_default", + req: RegisterRequest{SchemaVersion: "V2"}, + wantStyles: domain.DefaultDNSRecordStyles(), + }, + { + name: "v2_empty_slice_normalizes_to_default", + req: RegisterRequest{SchemaVersion: "V2", DNSRecordStyles: []domain.DNSRecordStyle{}}, + wantStyles: domain.DefaultDNSRecordStyles(), + }, + { + name: "unset_schema_treated_as_v2_default", + req: RegisterRequest{}, + wantStyles: domain.DefaultDNSRecordStyles(), + }, + { + 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_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_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_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 := applyDNSRecordStyles(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 !sameStyles(reg.DNSRecordStyles, tc.wantStyles) { + t.Errorf("DNSRecordStyles: got %v want %v", reg.DNSRecordStyles, tc.wantStyles) + } + }) + } +} + +// TestApplyDNSRecordStyles_ErrorMessageListsValidValues confirms the +// error detail enumerates the canonical valid set so SDK authors get +// an actionable message. Sourced from domain.ValidDNSRecordStyles(). +func TestApplyDNSRecordStyles_ErrorMessageListsValidValues(t *testing.T) { + reg := &domain.AgentRegistration{} + err := applyDNSRecordStyles(reg, RegisterRequest{ + SchemaVersion: "V2", + DNSRecordStyles: []domain.DNSRecordStyle{domain.DNSRecordStyle("garbage")}, + }) + if err == nil { + t.Fatal("expected error") + } + 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 +// 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/internal/ra/service/registration.go b/internal/ra/service/registration.go index 6171a68..3311b51 100644 --- a/internal/ra/service/registration.go +++ b/internal/ra/service/registration.go @@ -86,6 +86,15 @@ type RegisterRequest struct { // which hashes the protocol-native metadata (e.g., A2A AgentCard). // Empty when omitted on the registration request. AgentCardContent []byte + + // DNSRecordStyles is the set of DNS record families the RA emits + // in dnsRecordsProvisioned and tells the operator to publish. + // Each element is one of domain.ValidDNSRecordStyles(); typical + // 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 @@ -328,6 +337,10 @@ func (s *RegistrationService) RegisterAgent(ctx context.Context, req RegisterReq return nil, err } + if err := applyDNSRecordStyles(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..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,6 +1112,26 @@ components: - protocol: "A2A" agentUrl: "https://support.example.com" metadataUrl: "https://support.example.com/.well-known/agent-card.json" + dnsRecordStyles: + type: array + items: + $ref: '#/components/schemas/DNSRecordStyle' + uniqueItems: true + minItems: 1 + description: | + Set of DNS record families the RA emits in the 202 register + response's dnsRecords[] and in the AGENT_REGISTERED TL + event's attestations.dnsRecordsProvisioned[]. Not echoed on + GET /v2/ans/agents/{agentId}. + + 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 @@ -1313,7 +1350,7 @@ components: type: string type: type: string - enum: [HTTPS, TLSA, TXT] + enum: [HTTPS, SVCB, TLSA, TXT] value: type: string priority: