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
80 changes: 80 additions & 0 deletions internal/adapter/dns/dns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
48 changes: 48 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 @@ -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 {
Expand Down
52 changes: 52 additions & 0 deletions internal/adapter/docsui/openapi/ra.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1065,6 +1065,58 @@ components:
type: string
identityCsrPEM:
type: string
agentCardContent:
type: object
additionalProperties: true
description: |
Optional JSON object containing the ANS Trust Card body
(ANS_SPEC.md §A.2 — the document hosted at
/.well-known/ans/trust-card.json). When present, the RA
computes SHA-256 over the JCS-canonical bytes
(RFC 8785) of the object and seals the hex-lowercase
digest into the AGENT_REGISTERED Transparency Log
event under attestations.metadataHashes.capabilitiesHash.

The RA does not validate the object's schema; it hashes
and forgets. Operators that want their hosted Trust Card
to be tamper-evident submit the same JSON body they will
host at their canonical Trust Card URL.

Distinct from the per-endpoint metaDataHash on AgentEndpoint:
that field carries a hash of the protocol-native metadata
(e.g., the A2A AgentCard at /.well-known/agent-card.json).
agentCardContent is the agent-level ANS Trust Card body
and produces the agent-level capabilitiesHash.
example:
ansName: "ans://v1.5.0.support.example.com"
agentDisplayName: "Acme Support Agent"
version: "1.5.0"
endpoints:
- 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
Expand Down
27 changes: 26 additions & 1 deletion internal/adapter/docsui/openapi/tl.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -716,7 +716,32 @@ components:
version: { type: string }
attestations:
type: object
description: Event-type specific attestation payload.
description: |
Event-type specific attestation payload.

For AGENT_REGISTERED events, the optional
`metadataHashes` map carries SHA-256 hex-lowercase
digests of artifacts the operator submitted at
registration. The well-known map key
`capabilitiesHash` is the SHA-256(JCS(agentCardContent))
sealed at activation per ANS_SPEC.md §A.1; the AIM
uses it to verify the live ANS Trust Card content
against what was registered. When `agentCardContent`
was omitted on registration, the `capabilitiesHash`
key is absent.
properties:
metadataHashes:
type: object
description: |
Map of well-known hash names to SHA-256 hex-lowercase
digests. Reserved keys: `capabilitiesHash`
(SHA-256(JCS(agentCardContent)) per ANS_SPEC.md §A.1).
Additional keys reserved for future expansion.
additionalProperties:
type: string
pattern: '^[0-9a-f]{64}$'
example:
capabilitiesHash: "9f3a2b1c4d5e6f7890abcdef0123456789abcdef0123456789abcdef01234567"

AppendResponse:
type: object
Expand Down
82 changes: 70 additions & 12 deletions internal/adapter/store/sqlite/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type agentRow struct {
ID int64 `db:"id"`
AgentID string `db:"agent_id"`
OwnerID string `db:"owner_id"`
AnsName string `db:"ans_name"`
AnsName sql.NullString `db:"ans_name"`
AgentHost string `db:"agent_host"`
Version string `db:"version"`
Status string `db:"status"`
Expand All @@ -39,22 +39,31 @@ type agentRow struct {
SupersedesRegistrationID sql.NullInt64 `db:"supersedes_registration_id"`
ACMEDNS01Token sql.NullString `db:"acme_dns01_token"`
ACMEChallengeExpiresAtMs sql.NullInt64 `db:"acme_challenge_expires_at_ms"`
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"`
}

func (r agentRow) toDomain() (*domain.AgentRegistration, error) {
ansName, err := domain.ParseAnsName(r.AnsName)
if err != nil {
return nil, fmt.Errorf("sqlite: decode ans_name: %w", err)
// ans_name is NULL for §3.2.0 base-only registrations; toDomain
// surfaces that as the zero-value AnsName rather than an error.
var ansName domain.AnsName
if r.AnsName.Valid && r.AnsName.String != "" {
parsed, err := domain.ParseAnsName(r.AnsName.String)
if err != nil {
return nil, fmt.Errorf("sqlite: decode ans_name: %w", err)
}
ansName = parsed
}

reg := &domain.AgentRegistration{
ID: r.ID,
AgentID: r.AgentID,
OwnerID: r.OwnerID,
AnsName: ansName,
Status: domain.RegistrationStatus(r.Status),
ID: r.ID,
AgentID: r.AgentID,
OwnerID: r.OwnerID,
AnsName: ansName,
AgentHost: r.AgentHost,
Status: domain.RegistrationStatus(r.Status),
Details: domain.RegistrationDetails{
RegistrationTimestamp: msToTime(r.RegistrationTimestampMs),
DisplayName: r.DisplayName,
Expand All @@ -73,6 +82,12 @@ func (r agentRow) toDomain() (*domain.AgentRegistration, error) {
if r.ACMEChallengeExpiresAtMs.Valid {
reg.ACMEChallenge.ExpiresAt = msToTime(r.ACMEChallengeExpiresAtMs.Int64)
}
if r.CapabilitiesHash.Valid {
reg.CapabilitiesHash = r.CapabilitiesHash.String
}
if r.DNSRecordStyle.Valid {
reg.DNSRecordStyle = domain.DNSRecordStyle(r.DNSRecordStyle.String)
}
return reg, nil
}

Expand All @@ -93,13 +108,22 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration)
registration_timestamp_ms, last_renewal_timestamp_ms,
supersedes_registration_id,
acme_dns01_token, acme_challenge_expires_at_ms,
capabilities_hash,
dns_record_style,
created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
// AnsName is the zero value for §3.2.0 base-only registrations.
// Persist it as NULL so the UNIQUE constraint allows multiple
// base-only rows (SQLite treats each NULL as distinct under
// UNIQUE), and read the FQDN from agent.FQDN() which falls back
// to agent.AgentHost when AnsName is zero. AnsName.String()
// returns "" for the zero value; nullableString promotes it to
// SQL NULL.
res, err := s.db.extx(ctx).ExecContext(ctx, q,
agent.AgentID,
agent.OwnerID,
agent.AnsName.String(),
agent.AnsName.FQDN(),
nullableString(agent.AnsName.String()),
agent.FQDN(),
agent.AnsName.Version().String(),
string(agent.Status),
agent.Details.DisplayName,
Expand All @@ -109,6 +133,8 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration)
nullableInt64(agent.SupersedesRegistrationID),
nullableString(agent.ACMEChallenge.DNS01Token),
nullableMs(agent.ACMEChallenge.ExpiresAt),
nullableString(agent.CapabilitiesHash),
nullableString(string(agent.DNSRecordStyle)),
now, now,
)
if err != nil {
Expand All @@ -131,6 +157,8 @@ 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 = ?,
updated_at_ms = ?
WHERE id = ?`
_, err := s.db.extx(ctx).ExecContext(ctx, q,
Expand All @@ -141,6 +169,8 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration)
nullableInt64(agent.SupersedesRegistrationID),
nullableString(agent.ACMEChallenge.DNS01Token),
nullableMs(agent.ACMEChallenge.ExpiresAt),
nullableString(agent.CapabilitiesHash),
nullableString(string(agent.DNSRecordStyle)),
now,
agent.ID,
)
Expand Down Expand Up @@ -178,7 +208,15 @@ func (s *AgentStore) FindByAnsName(ctx context.Context, ansName domain.AnsName)
}

// ExistsByAnsName returns true if any row uses the given ANS name.
// Returns false for the zero-value AnsName: §3.2.0 base-only registrations
// store an empty ans_name string, and treating empty as "exists" would
// reject every base-only registration after the first one. Base-only
// uniqueness is enforced via ExistsActiveBaseOnlyByAgentHost instead,
// which scopes the conflict to the FQDN of the registration in question.
func (s *AgentStore) ExistsByAnsName(ctx context.Context, ansName domain.AnsName) (bool, error) {
if ansName.IsZero() {
return false, nil
}
var n int
const q = `SELECT COUNT(1) FROM agent_registrations WHERE ans_name = ?`
if err := s.db.extx(ctx).GetContext(ctx, &n, q, ansName.String()); err != nil {
Expand All @@ -187,6 +225,26 @@ func (s *AgentStore) ExistsByAnsName(ctx context.Context, ansName domain.AnsName
return n > 0, nil
}

// ExistsActiveBaseOnlyByAgentHost returns true if a non-revoked,
// non-failed base-only registration already claims the given FQDN.
// Migration 008 stores ans_name as NULL for base-only rows, but the
// pre-008 path could leave an empty string in place; the predicate
// matches both shapes so the check stays correct across an in-place
// upgrade. The §3.2.0 path lets the same FQDN host multiple
// registration rows over time (a base-only registration revoked, then
// re-registered) but only one can be live at any moment.
func (s *AgentStore) ExistsActiveBaseOnlyByAgentHost(ctx context.Context, host string) (bool, error) {
var n int
const q = `SELECT COUNT(1) FROM agent_registrations
WHERE agent_host = ?
AND (ans_name IS NULL OR ans_name = '')
AND status NOT IN ('REVOKED', 'FAILED', 'EXPIRED')`
if err := s.db.extx(ctx).GetContext(ctx, &n, q, host); err != nil {
return false, err
}
return n > 0, nil
}

// FindAllByAgentHost returns all registrations for a given FQDN, newest first.
func (s *AgentStore) FindAllByAgentHost(ctx context.Context, host string) ([]*domain.AgentRegistration, error) {
var rows []agentRow
Expand Down
Loading