Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions internal/adapter/dns/dns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
56 changes: 56 additions & 0 deletions internal/adapter/dns/lookup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Comment on lines +299 to +303
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 {
Expand Down
39 changes: 38 additions & 1 deletion internal/adapter/docsui/openapi/ra.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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}.
Comment on lines +1122 to +1125

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
Expand Down Expand Up @@ -1313,7 +1350,7 @@ components:
type: string
type:
type: string
enum: [HTTPS, TLSA, TXT]
enum: [HTTPS, SVCB, TLSA, TXT]
value:
type: string
priority:
Expand Down
56 changes: 55 additions & 1 deletion internal/adapter/store/sqlite/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -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,
)
Expand Down
Loading