diff --git a/internal/adapter/dns/dns_test.go b/internal/adapter/dns/dns_test.go index 0cb0347..7193388 100644 --- a/internal/adapter/dns/dns_test.go +++ b/internal/adapter/dns/dns_test.go @@ -252,6 +252,86 @@ func TestLookupVerifier_HTTPSMatch(t *testing.T) { } } +// TestLookupVerifier_SVCBMatch covers the Consolidated Approach SVCB +// record at the bare agent FQDN. The expected value is the same +// presentation form the RA's ComputeRequiredDNSRecords emits (see +// internal/domain/dnsrecords.go), and the verifier matches after +// whitespace normalization mirroring verifyHTTPS. +// +// Restricted to IANA-registered SvcParamKeys (alpn + port) because the +// miekg/dns zone-file parser used by the test fixture rejects symbolic +// names for the still-provisional Consolidated Approach SvcParams (`wk`, +// `card-sha256`, `cap`, etc.). Until those keys are IANA-registered per +// RFC 9460 §6, the test exercises the verifier dispatch and matching +// path with registered keys; the unregistered keys are unit-tested at +// the domain layer (internal/domain/dnsrecords_test.go). +func TestLookupVerifier_SVCBMatch(t *testing.T) { + t.Parallel() + s := newTestServer(t) + s.add("agent.example.com.", "SVCB", + `agent.example.com. 3600 IN SVCB 1 . alpn=a2a port=443`) + + recs := []domain.ExpectedDNSRecord{{ + Name: "agent.example.com", + Type: domain.DNSRecordSVCB, + Value: `1 . alpn=a2a port=443`, + Required: false, + }} + got := s.verifyAgainst(t, recs) + if !got[0].found { + t.Errorf("SVCB should match; got=%+v", got[0]) + } +} + +// TestLookupVerifier_SVCBMissing covers the absent-record path. The +// agent's zone never published the SVCB record (or it was removed). +// Verifier reports not-found without an error (NXDOMAIN-style empty +// answer). +func TestLookupVerifier_SVCBMissing(t *testing.T) { + t.Parallel() + s := newTestServer(t) + // Different name in the zone — query for the agent's FQDN returns + // no SVCB answers. + s.add("other.example.com.", "SVCB", + `other.example.com. 3600 IN SVCB 1 . alpn=a2a`) + + recs := []domain.ExpectedDNSRecord{{ + Name: "agent.example.com", + Type: domain.DNSRecordSVCB, + Value: `1 . alpn=a2a`, + Required: false, + }} + got := s.verifyAgainst(t, recs) + if got[0].found { + t.Error("SVCB must not be Found when the zone has no matching record") + } +} + +// TestLookupVerifier_SVCBWrongTargetMissesMatch confirms that a record +// with the right alpn but a different SvcPriority/TargetName does not +// satisfy the expectation. Matching is on the full normalized +// presentation form, so a TargetName mismatch fails the comparison. +func TestLookupVerifier_SVCBWrongTargetMissesMatch(t *testing.T) { + t.Parallel() + s := newTestServer(t) + // AliasMode (priority 0) at agent.example.com pointing at a + // hosting target — different shape than what the RA expects in + // ServiceMode (priority 1). + s.add("agent.example.com.", "SVCB", + `agent.example.com. 3600 IN SVCB 0 host.provider.example.`) + + recs := []domain.ExpectedDNSRecord{{ + Name: "agent.example.com", + Type: domain.DNSRecordSVCB, + Value: `1 . alpn=a2a`, + Required: false, + }} + got := s.verifyAgainst(t, recs) + if got[0].found { + t.Error("ServiceMode expectation should not match an AliasMode record") + } +} + func TestLookupVerifier_NXDOMAINSurfacedAsError(t *testing.T) { t.Parallel() s := newTestServer(t) diff --git a/internal/adapter/dns/lookup.go b/internal/adapter/dns/lookup.go index ed5cc33..2c695d7 100644 --- a/internal/adapter/dns/lookup.go +++ b/internal/adapter/dns/lookup.go @@ -86,6 +86,8 @@ func (v *LookupVerifier) VerifyRecords( r = v.verifyTLSA(lookupCtx, server, rec) case domain.DNSRecordHTTPS: r = v.verifyHTTPS(lookupCtx, server, rec) + case domain.DNSRecordSVCB: + r = v.verifySVCB(lookupCtx, server, rec) default: r.Error = fmt.Sprintf("unsupported record type: %s", rec.Type) } @@ -253,6 +255,52 @@ func formatHTTPSValue(s *dns.SVCB) string { return sb.String() } +// verifySVCB checks for a Consolidated Approach SVCB record (RFC 9460) +// at the agent's bare FQDN. Multiple SVCB records can share one RRset +// name distinguished by alpn, so verification iterates the answer +// section, normalizes each record's wire form, and matches against +// the expected SvcParams. The matching strategy mirrors verifyHTTPS: +// the expected value carries every SvcParam the RA computed (alpn, +// port, wk, card-sha256), and the live record MUST carry the same +// SvcParams in the same alpn-keyed form. +// +// SvcParam unknown-key ignore semantics (RFC 9460 §8) apply at the +// client, not at this verifier — we only check that the SvcParams +// the RA committed are present, not that the live record is free of +// extra SvcParams from other ecosystems. Other agentic specs adding +// their own SvcParams alongside ours is the entire point of the +// Consolidated Approach. +func (v *LookupVerifier) verifySVCB(ctx context.Context, server string, rec domain.ExpectedDNSRecord) port.RecordVerification { + r := port.RecordVerification{Record: rec} + resp, err := v.exchange(ctx, server, rec.Name, dns.TypeSVCB) + if err != nil { + r.Error = err.Error() + return r + } + if resp.Rcode != dns.RcodeSuccess { + r.Error = fmt.Sprintf("rcode %s", dns.RcodeToString[resp.Rcode]) + return r + } + r.DNSSECVerified = resp.AuthenticatedData + wantNorm := normalizeHTTPS(rec.Value) + for _, rr := range resp.Answer { + svcb, ok := rr.(*dns.SVCB) + if !ok { + continue + } + got := formatHTTPSValue(svcb) + if r.Actual == "" { + r.Actual = got + } + if normalizeHTTPS(got) == wantNorm { + r.Found = true + r.Actual = got + return r + } + } + return r +} + // normalizeTLSA collapses whitespace and lowercases the hex so // "3 1 1 abcd..." matches "3 1 1 ABCD...". func normalizeTLSA(s string) string { diff --git a/internal/adapter/docsui/openapi/ra.yaml b/internal/adapter/docsui/openapi/ra.yaml index 4e3c49a..08e3284 100644 --- a/internal/adapter/docsui/openapi/ra.yaml +++ b/internal/adapter/docsui/openapi/ra.yaml @@ -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 diff --git a/internal/adapter/docsui/openapi/tl.yaml b/internal/adapter/docsui/openapi/tl.yaml index 6dfbd9a..ed84cff 100644 --- a/internal/adapter/docsui/openapi/tl.yaml +++ b/internal/adapter/docsui/openapi/tl.yaml @@ -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 diff --git a/internal/adapter/store/sqlite/agent.go b/internal/adapter/store/sqlite/agent.go index a06be3f..2d7c623 100644 --- a/internal/adapter/store/sqlite/agent.go +++ b/internal/adapter/store/sqlite/agent.go @@ -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"` @@ -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, @@ -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 } @@ -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, @@ -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 { @@ -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, @@ -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, ) @@ -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 { @@ -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 diff --git a/internal/adapter/store/sqlite/agent_test.go b/internal/adapter/store/sqlite/agent_test.go index 5915bbf..736b2b8 100644 --- a/internal/adapter/store/sqlite/agent_test.go +++ b/internal/adapter/store/sqlite/agent_test.go @@ -424,3 +424,100 @@ func TestNullableMsAndInt64(t *testing.T) { t.Error("non-zero should map to non-nil") } } + +// newBaseOnlyFixture builds a §3.2.0 base-only registration: zero +// AnsName, AgentHost set explicitly. Status starts PENDING_VALIDATION +// so ExistsActiveBaseOnlyByAgentHost returns it as "claimed." +func newBaseOnlyFixture(t *testing.T, agentID, host string) *domain.AgentRegistration { + t.Helper() + return &domain.AgentRegistration{ + AgentID: agentID, + OwnerID: "owner-1", + AgentHost: host, + Status: domain.StatusPendingValidation, + Details: domain.RegistrationDetails{ + RegistrationTimestamp: time.Now().UTC().Truncate(time.Millisecond), + DisplayName: "Base-only " + agentID, + }, + } +} + +func TestAgentStore_ExistsActiveBaseOnlyByAgentHost(t *testing.T) { + db := newTestDB(t) + store := NewAgentStore(db) + ctx := context.Background() + + // Empty store → false. + exists, err := store.ExistsActiveBaseOnlyByAgentHost(ctx, "skill-a.example.com") + if err != nil { + t.Fatalf("empty: %v", err) + } + if exists { + t.Error("empty store should return false") + } + + // Persist a base-only registration in PENDING_VALIDATION. + pending := newBaseOnlyFixture(t, "agent-base-1", "skill-a.example.com") + if err := store.Save(ctx, pending); err != nil { + t.Fatalf("save pending: %v", err) + } + + exists, err = store.ExistsActiveBaseOnlyByAgentHost(ctx, "skill-a.example.com") + if err != nil { + t.Fatalf("after pending: %v", err) + } + if !exists { + t.Error("PENDING_VALIDATION base-only should count as claimed") + } + + // Different FQDN → still false. + exists, _ = store.ExistsActiveBaseOnlyByAgentHost(ctx, "skill-b.example.com") + if exists { + t.Error("unrelated FQDN should not match") + } + + // Versioned registration on the same FQDN should NOT trigger the + // base-only conflict — the two paths track distinct namespaces. + versioned := newAgentFixture(t, "agent-versioned", "skill-c.example.com") + if err := store.Save(ctx, versioned); err != nil { + t.Fatalf("save versioned: %v", err) + } + exists, _ = store.ExistsActiveBaseOnlyByAgentHost(ctx, "skill-c.example.com") + if exists { + t.Error("versioned row must not count as a base-only claim") + } + + // Revoke the base-only row → conflict releases. + pending.Status = domain.StatusRevoked + if err := store.Save(ctx, pending); err != nil { + t.Fatalf("revoke: %v", err) + } + exists, _ = store.ExistsActiveBaseOnlyByAgentHost(ctx, "skill-a.example.com") + if exists { + t.Error("revoked base-only row should release the FQDN") + } +} + +// TestAgentStore_ExistsByAnsName_ZeroIsFalse pins the contract +// guarding base-only registrations: looking up the zero-value AnsName +// must short-circuit to false rather than match every empty ans_name +// row in the table (the pre-Plan-F shape would have collided every +// base-only registration). +func TestAgentStore_ExistsByAnsName_ZeroIsFalse(t *testing.T) { + db := newTestDB(t) + store := NewAgentStore(db) + ctx := context.Background() + + pending := newBaseOnlyFixture(t, "agent-base-1", "skill-a.example.com") + if err := store.Save(ctx, pending); err != nil { + t.Fatalf("save: %v", err) + } + + exists, err := store.ExistsByAnsName(ctx, domain.AnsName{}) + if err != nil { + t.Fatalf("ExistsByAnsName(zero): %v", err) + } + if exists { + t.Error("ExistsByAnsName must return false for the zero value") + } +} diff --git a/internal/adapter/store/sqlite/migrations/006_agent_capabilities_hash.sql b/internal/adapter/store/sqlite/migrations/006_agent_capabilities_hash.sql new file mode 100644 index 0000000..a2ab247 --- /dev/null +++ b/internal/adapter/store/sqlite/migrations/006_agent_capabilities_hash.sql @@ -0,0 +1,20 @@ +-- 006_agent_capabilities_hash.sql +-- Persist the SHA-256(JCS(agentCardContent)) hash on the +-- agent_registrations row so the activation flow can seal it into +-- attestations.metadataHashes.capabilitiesHash without re-hashing +-- (or re-storing) the full agentCardContent body. +-- +-- ANS_SPEC.md §A.1 prescribes the "hash and forget" semantic: the RA +-- accepts the operator's ANS Trust Card body on the V2 registration +-- request, hashes it, and persists only the digest. The body itself +-- is the operator's to host (or not). The AIM later verifies the +-- live hosted Trust Card content against this digest. +-- +-- The column is nullable for backwards compatibility with agents +-- registered before this migration, and for the spec-conformant +-- "agent registered without agentCardContent" path. When NULL, the +-- AGENT_REGISTERED event omits the metadataHashes.capabilitiesHash +-- key (the existing omitempty path on the struct). + +ALTER TABLE agent_registrations + ADD COLUMN capabilities_hash TEXT; diff --git a/internal/adapter/store/sqlite/migrations/007_agent_dns_record_style.sql b/internal/adapter/store/sqlite/migrations/007_agent_dns_record_style.sql new file mode 100644 index 0000000..422b86c --- /dev/null +++ b/internal/adapter/store/sqlite/migrations/007_agent_dns_record_style.sql @@ -0,0 +1,21 @@ +-- 007_agent_dns_record_style.sql +-- Persist the operator's chosen DNS-record-style on the registration +-- row so the verify-acme/verify-dns flow and the badge response carry +-- the same shape the operator chose at registration time. +-- +-- One of: +-- "consolidated" — Consolidated Approach SVCB rows + shared records +-- (default; recommended; aligned with §4.4.2). +-- "legacy" — original `_ans` TXT shape + shared records. +-- Backwards-compatible with operators registered +-- before the Consolidated Approach landed. +-- "both" — union; the §4.4.2 transition shape for operators +-- running both record families during migration. +-- +-- Nullable for backwards compatibility with agents registered before +-- this migration. The domain helper ComputeRequiredDNSRecords treats +-- empty value as the default ("consolidated") via DefaultDNSRecordStyle, +-- so old agents do not lose attestation behavior. + +ALTER TABLE agent_registrations + ADD COLUMN dns_record_style TEXT; diff --git a/internal/adapter/store/sqlite/migrations/008_agent_ans_name_nullable.sql b/internal/adapter/store/sqlite/migrations/008_agent_ans_name_nullable.sql new file mode 100644 index 0000000..dfcb632 --- /dev/null +++ b/internal/adapter/store/sqlite/migrations/008_agent_ans_name_nullable.sql @@ -0,0 +1,54 @@ +-- 008_agent_ans_name_nullable.sql +-- +-- ANS_SPEC.md §3.2.0 (base-only registrations) introduces a path where +-- the registrant submits NEITHER a version nor an Identity CSR — the +-- resulting registration has no ANSName at all and is identified by +-- FQDN alone. The pre-Plan-F schema declared ans_name as +-- "TEXT NOT NULL UNIQUE", which forced empty-string for base-only and +-- collided on the second base-only insert via the UNIQUE constraint +-- (two empty strings → 2067 UNIQUE violation). +-- +-- This migration relaxes ans_name to NULLable. SQLite's default UNIQUE +-- behavior treats each NULL as distinct, so multiple base-only rows +-- coexist while versioned rows still get the uniqueness guarantee. +-- +-- SQLite cannot ALTER COLUMN; the rebuild ceremony copies all data +-- through a temp table, swaps it in, and rebuilds dependent indexes. +-- The trailing UPDATE migrates any existing empty-string ans_name rows +-- to NULL so the new constraint is consistent. + +CREATE TABLE agent_registrations_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id TEXT NOT NULL UNIQUE, + owner_id TEXT NOT NULL, + ans_name TEXT UNIQUE, + agent_host TEXT NOT NULL, + version TEXT NOT NULL, + status TEXT NOT NULL, + display_name TEXT, + description TEXT, + registration_timestamp_ms INTEGER NOT NULL, + last_renewal_timestamp_ms INTEGER, + supersedes_registration_id INTEGER, + acme_dns01_token TEXT, + acme_challenge_expires_at_ms INTEGER, + capabilities_hash TEXT, + dns_record_style TEXT, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL +); + +INSERT INTO agent_registrations_new +SELECT * FROM agent_registrations; + +DROP TABLE agent_registrations; +ALTER TABLE agent_registrations_new RENAME TO agent_registrations; + +UPDATE agent_registrations SET ans_name = NULL WHERE ans_name = ''; + +CREATE INDEX IF NOT EXISTS idx_agent_registrations_owner + ON agent_registrations(owner_id); +CREATE INDEX IF NOT EXISTS idx_agent_registrations_host + ON agent_registrations(agent_host); +CREATE INDEX IF NOT EXISTS idx_agent_registrations_status + ON agent_registrations(status); diff --git a/internal/domain/agent.go b/internal/domain/agent.go index 71efd91..f09814c 100644 --- a/internal/domain/agent.go +++ b/internal/domain/agent.go @@ -2,6 +2,7 @@ package domain import ( "fmt" + "strings" "time" ) @@ -64,9 +65,20 @@ type AgentRegistration struct { // OwnerID identifies the authenticated user who owns this registration. OwnerID string `json:"ownerId"` - // AnsName is the versioned agent name (ans://v1.0.0.agent.example.com). + // AnsName is the versioned agent name (ans://v1.0.0.agent.example.com) + // when the registrant submitted both a version and an Identity CSR + // (the versioned path per §3.2.0). Zero-value AnsName indicates a + // base-only registration; the AgentHost field carries the FQDN + // identity in that case. AnsName AnsName `json:"ansName"` + // AgentHost is the FQDN identity. Always non-empty post-validation. + // For versioned registrations, derived from AnsName.FQDN(). For + // base-only registrations, supplied explicitly because AnsName is + // zero. Read this field rather than AnsName when an emission path + // needs the FQDN regardless of registration variant. + AgentHost string `json:"agentHost"` + // Status is the current lifecycle state. Status RegistrationStatus `json:"status"` @@ -103,16 +115,53 @@ type AgentRegistration struct { // deviation. ACMEChallenge ACMEChallenge `json:"acmeChallenge,omitzero"` + // CapabilitiesHash is the SHA-256(JCS(agentCardContent)) digest + // (hex-lowercase) the RA computed when the operator submitted + // agentCardContent on the V2 registration request, per + // ANS_SPEC.md §A.1. Empty when the operator did not submit + // content. The activation flow seals this value into the + // AGENT_REGISTERED event's attestations.metadataHashes under the + // well-known key event.MetadataHashKeyCapabilitiesHash. + // + // Stored as a hex string rather than the raw 32-byte digest so + // the storage column is human-readable and the wire format + // matches the AIM's verification expectation directly. + CapabilitiesHash string `json:"capabilitiesHash,omitempty"` + + // DNSRecordStyle selects which DNS record family the RA emits + // for this registration: "consolidated" (Consolidated Approach + // SVCB rows, default), "legacy" (the original `_ans` TXT shape), + // or "both" (the transition union). Empty at the domain layer + // is treated as DefaultDNSRecordStyle by ComputeRequiredDNSRecords. + DNSRecordStyle DNSRecordStyle `json:"dnsRecordStyle,omitempty"` + // PendingEvents holds domain events raised during this aggregate operation. // They are cleared after being published. PendingEvents []Event `json:"-"` } // NewRegistration creates a new agent registration in PENDING_VALIDATION state. +// +// Two paths per ANS_SPEC.md §3.2.0 / §1.8: +// +// Versioned (default): registrant submits both a version (carried +// in ansName) and an Identity CSR. Aggregate ends with non-zero +// AnsName + non-nil identityCSR; an Identity Certificate is issued. +// +// Base-only: registrant submits NEITHER a version nor an Identity +// CSR. Caller passes a zero-value ansName and identityCSR=nil; +// agentHost MUST be non-empty (it carries the FQDN identity in +// place of the ANSName). No Identity Certificate is issued; the +// AGENT_REGISTERED event omits the ANSName field. +// +// Mixed forms are rejected: a version requires an Identity CSR (and +// vice versa) — the Identity Certificate's URI SAN encodes the +// ANSName, so the two artifacts are coupled. func NewRegistration( agentID string, ownerID string, ansName AnsName, + agentHost string, displayName string, description string, endpoints []AgentEndpoint, @@ -126,9 +175,42 @@ func NewRegistration( if ownerID == "" { return nil, NewValidationError("MISSING_OWNER_ID", "ownerId is required") } - if ansName.IsZero() { - return nil, NewValidationError("MISSING_ANS_NAME", "ansName is required") + + baseOnly := ansName.IsZero() + if baseOnly && identityCSR != nil { + return nil, NewValidationError( + "BASE_ONLY_REJECTS_IDENTITY_CSR", + "identity CSR submitted without a version: base-only registrations cannot have an Identity Certificate", + ) + } + if !baseOnly && identityCSR == nil { + return nil, NewValidationError( + "VERSIONED_REQUIRES_IDENTITY_CSR", + "version submitted without an identity CSR: versioned registrations require both", + ) } + + // Resolve canonical FQDN. Versioned: ansName carries it. Base-only: + // caller passes agentHost explicitly. + fqdn := strings.ToLower(strings.TrimSpace(agentHost)) + if !baseOnly && fqdn == "" { + fqdn = ansName.FQDN() + } + if fqdn == "" { + return nil, NewValidationError("MISSING_AGENT_HOST", "agentHost is required (versioned: derived from ansName; base-only: explicit)") + } + if err := validateAgentHost(fqdn); err != nil { + return nil, err + } + // Catch operator-side mismatch when both ansName and agentHost are + // supplied for the versioned path. + if !baseOnly && agentHost != "" && !strings.EqualFold(strings.TrimSpace(agentHost), ansName.FQDN()) { + return nil, NewValidationError( + "AGENT_HOST_ANSNAME_MISMATCH", + fmt.Sprintf("agentHost %q does not match ansName host %q", agentHost, ansName.FQDN()), + ) + } + if len(displayName) > maxDisplayNameLength { return nil, NewValidationError( "DISPLAY_NAME_TOO_LONG", @@ -144,29 +226,27 @@ func NewRegistration( if len(endpoints) == 0 { return nil, NewValidationError("MISSING_ENDPOINTS", "at least one endpoint is required") } - if identityCSR == nil { - return nil, NewValidationError("MISSING_IDENTITY_CSR", "identityCsrPEM is required") - } - // Validate endpoints against the agent host. + // Validate endpoints against the FQDN. eps := AgentEndpoints{AgentID: agentID, Endpoints: endpoints} - if err := eps.Validate(ansName.FQDN()); err != nil { + if err := eps.Validate(fqdn); err != nil { return nil, err } // Validate server cert matches FQDN if provided. - if serverCert != nil && !serverCert.MatchesFQDN(ansName.FQDN()) { + if serverCert != nil && !serverCert.MatchesFQDN(fqdn) { return nil, NewCertificateError( "SERVER_CERT_FQDN_MISMATCH", - fmt.Sprintf("server certificate does not match agent FQDN %q", ansName.FQDN()), + fmt.Sprintf("server certificate does not match agent FQDN %q", fqdn), ) } reg := &AgentRegistration{ - AgentID: agentID, - OwnerID: ownerID, - AnsName: ansName, - Status: StatusPendingValidation, + AgentID: agentID, + OwnerID: ownerID, + AnsName: ansName, + AgentHost: fqdn, + Status: StatusPendingValidation, Details: RegistrationDetails{ RegistrationTimestamp: now, DisplayName: displayName, @@ -326,11 +406,29 @@ func (r *AgentRegistration) AllowsSupersede(newVersion SimplifiedSemVer) bool { return newVersion.GreaterThan(r.AnsName.Version()) } -// FQDN returns the lowercase FQDN from the ANS name. +// FQDN returns the lowercase FQDN identity for this registration. +// Reads from AgentHost (always set post-validation), so works for +// both versioned and base-only registrations. The previous +// implementation read from AnsName.FQDN(), which returned "" for +// base-only registrations and broke any caller that needed the FQDN +// regardless of registration variant. func (r *AgentRegistration) FQDN() string { + if r.AgentHost != "" { + return r.AgentHost + } + // Fall back to AnsName for any aggregate constructed before the + // AgentHost field landed (loaded from a pre-Plan-F DB row). return r.AnsName.FQDN() } +// IsBaseOnly reports whether this registration was made without a +// version + Identity CSR (§3.2.0). Base-only agents have no +// ANSName, no Identity Certificate, and emit DNS records without +// the version= field. +func (r *AgentRegistration) IsBaseOnly() bool { + return r.AnsName.IsZero() +} + // ClearEvents returns and clears the pending domain events. func (r *AgentRegistration) ClearEvents() []Event { events := r.PendingEvents diff --git a/internal/domain/agent_test.go b/internal/domain/agent_test.go index 4d4d672..b8d9ab3 100644 --- a/internal/domain/agent_test.go +++ b/internal/domain/agent_test.go @@ -28,7 +28,7 @@ func newValidRegistration(t *testing.T) *AgentRegistration { } reg, err := NewRegistration( - "agent-uuid", "owner-1", ansName, "My Agent", "desc", + "agent-uuid", "owner-1", ansName, "", "My Agent", "desc", endpoints, cert, &csr, time.Now(), ) require.NoError(t, err) @@ -64,15 +64,18 @@ func TestNewRegistration_Validations(t *testing.T) { }{ {"missing agent id", "", "o", validName, "", "", validEndpoints, nil, &validCSR, "MISSING_AGENT_ID"}, {"missing owner", "a", "", validName, "", "", validEndpoints, nil, &validCSR, "MISSING_OWNER_ID"}, - {"missing ans name", "a", "o", AnsName{}, "", "", validEndpoints, nil, &validCSR, "MISSING_ANS_NAME"}, + // AnsName empty + Identity CSR present is incoherent under §3.2.0: + // base-only registrations cannot have an Identity Certificate + // because the cert's URI SAN encodes the ANSName. + {"base_only_with_csr_rejected", "a", "o", AnsName{}, "", "", validEndpoints, nil, &validCSR, "BASE_ONLY_REJECTS_IDENTITY_CSR"}, {"display name too long", "a", "o", validName, strings.Repeat("x", 65), "", validEndpoints, nil, &validCSR, "DISPLAY_NAME_TOO_LONG"}, {"description too long", "a", "o", validName, "", strings.Repeat("x", 151), validEndpoints, nil, &validCSR, "DESCRIPTION_TOO_LONG"}, {"no endpoints", "a", "o", validName, "", "", nil, nil, &validCSR, "MISSING_ENDPOINTS"}, - {"missing csr", "a", "o", validName, "", "", validEndpoints, nil, nil, "MISSING_IDENTITY_CSR"}, + {"missing csr (versioned)", "a", "o", validName, "", "", validEndpoints, nil, nil, "VERSIONED_REQUIRES_IDENTITY_CSR"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - _, err := NewRegistration(tc.agentID, tc.ownerID, tc.ansName, tc.displayName, tc.description, tc.endpoints, tc.cert, tc.csr, time.Now()) + _, err := NewRegistration(tc.agentID, tc.ownerID, tc.ansName, "", tc.displayName, tc.description, tc.endpoints, tc.cert, tc.csr, time.Now()) require.Error(t, err) var de *Error require.ErrorAs(t, err, &de) @@ -87,7 +90,7 @@ func TestNewRegistration_CertFQDNMismatch(t *testing.T) { ep := []AgentEndpoint{{Protocol: ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}} cert := &ByocServerCertificate{SubjectCommonName: "other.example.com"} - _, err := NewRegistration("a", "o", ansName, "", "", ep, cert, &csr, time.Now()) + _, err := NewRegistration("a", "o", ansName, "", "", "", ep, cert, &csr, time.Now()) assert.ErrorIs(t, err, ErrCertificate) } @@ -220,7 +223,7 @@ func TestNewRegistration_InvalidEndpoint(t *testing.T) { badEndpoints := []AgentEndpoint{ {Protocol: ProtocolMCP, AgentURL: "https://other.example.com/mcp"}, } - _, err := NewRegistration("a", "o", ansName, "", "", badEndpoints, nil, &csr, time.Now()) + _, err := NewRegistration("a", "o", ansName, "", "", "", badEndpoints, nil, &csr, time.Now()) require.Error(t, err) assert.ErrorIs(t, err, ErrValidation) } diff --git a/internal/domain/ansname.go b/internal/domain/ansname.go index 5a9ec89..9b4951c 100644 --- a/internal/domain/ansname.go +++ b/internal/domain/ansname.go @@ -101,7 +101,13 @@ func (n AnsName) AgentHost() string { return n.agentHost } func (n AnsName) FQDN() string { return n.agentHost } // String returns the full ANS name: ans://v1.2.0.myagent.example.com. +// Returns "" for the zero value rather than a malformed "ans://v0.0.0." +// — base-only registrations (§3.2.0) carry a zero AnsName and the +// emitted JSON should reflect "no ANSName" as an absent string. func (n AnsName) String() string { + if n.IsZero() { + return "" + } return fmt.Sprintf("%sv%s.%s", ansProtocolPrefix, n.version.String(), n.agentHost) } diff --git a/internal/domain/dnsrecords.go b/internal/domain/dnsrecords.go index e1d1794..e087f85 100644 --- a/internal/domain/dnsrecords.go +++ b/internal/domain/dnsrecords.go @@ -1,6 +1,63 @@ package domain -import "fmt" +import ( + "encoding/base64" + "encoding/hex" + "fmt" +) + +// DNSRecordStyle selects which DNS record family the RA emits in its +// dnsRecordsProvisioned attestation and in the records it tells the +// operator to publish at registration time. +// +// Default is "consolidated": one SVCB record per protocol at the +// agent's bare FQDN per the cross-draft Consolidated Approach (§4.4.2). +// Operators on infrastructure that already publishes the legacy +// `_ans` TXT family pick "legacy". Migration operators pick "both" +// for a defined window, then flip back to "consolidated". +// +// Legacy MUST stay supported indefinitely. Operators picking "legacy" +// will continue to receive the original `_ans` TXT shape this RA has +// emitted since v0.1.x. The cross-channel hash consistency check +// (§4.4.2) only applies when the SVCB record is present, so "legacy" +// agents do not benefit from the card-sha256 ↔ capabilities_hash +// guarantee — that is a property of the chosen style, not a defect. +type DNSRecordStyle string + +const ( + // DNSRecordStyleConsolidated emits Consolidated Approach SVCB + // records (one per protocol, bare-FQDN owner) plus the + // `_ans-prefixed` records that no SvcParam covers (badge, + // identity DANE) plus the server-cert TLSA. The default. + DNSRecordStyleConsolidated DNSRecordStyle = "consolidated" + + // DNSRecordStyleLegacy emits the original `_ans` TXT family + // (one per protocol) plus the same `_ans-`-prefixed records + // plus the server-cert TLSA. No SVCB rows. + DNSRecordStyleLegacy DNSRecordStyle = "legacy" + + // DNSRecordStyleBoth emits the union of Consolidated Approach + // SVCB and legacy `_ans` TXT — the transition shape per §4.4.2 + // where the two record families coexist on the same agent's zone. + DNSRecordStyleBoth DNSRecordStyle = "both" +) + +// DefaultDNSRecordStyle is the style applied when the registration +// request omits dnsRecordStyle entirely. Pinned to "consolidated" so +// new integrations follow §4.4.2's "publish one SVCB record... rather +// than parallel per-ecosystem record trees" SHOULD by default. +const DefaultDNSRecordStyle = DNSRecordStyleConsolidated + +// IsValid reports whether s is one of the three defined styles. +// Empty string is treated as invalid; callers normalize empty to +// DefaultDNSRecordStyle before validation. +func (s DNSRecordStyle) IsValid() bool { + switch s { + case DNSRecordStyleConsolidated, DNSRecordStyleLegacy, DNSRecordStyleBoth: + return true + } + return false +} // DNSRecordType represents a DNS record type. type DNSRecordType string @@ -9,6 +66,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,37 +99,163 @@ type ExpectedDNSRecord struct { // ComputeRequiredDNSRecords generates the DNS records an operator must create // for a given agent registration. The RA does not create these records — the // operator manages their own DNS. The RA only verifies they exist. +// +// The set of records emitted depends on reg.DNSRecordStyle: +// +// - "consolidated" (default, recommended): Consolidated Approach SVCB +// rows (one per protocol) plus the shared `_ans-`-prefixed records +// plus the server-cert TLSA. No legacy `_ans` TXT rows. +// - "legacy": the original `_ans` TXT shape (one row per protocol) +// plus the same shared records. No SVCB rows. Backwards-compatible +// with operators who registered before the Consolidated Approach +// landed and have existing zone-edit tooling for `_ans` TXT. +// - "both": union of consolidated + legacy. The §4.4.2 transition +// shape; operators run both record families on the same zone for +// a defined window, then flip back to "consolidated". +// +// Empty reg.DNSRecordStyle is normalized to DefaultDNSRecordStyle. func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { fqdn := reg.FQDN() // Version is emitted as a bare semver string ("1.2.0"). The // `v`-prefixed form only appears inside the ANS name's hostname // label — TXT record payloads carry the machine-readable semver // directly, matching the shape a client would parse with any - // semver library. - version := reg.AnsName.Version().String() + // semver library. Empty for base-only registrations (§3.2.0); + // the version= field is omitted from emitted records in that case + // per §4.4.1 record syntax ("version" is "Yes when the registrant + // declared a version; otherwise omitted"). + baseOnly := reg.IsBaseOnly() + version := "" + if !baseOnly { + version = reg.AnsName.Version().String() + } + style := reg.DNSRecordStyle + if !style.IsValid() { + style = DefaultDNSRecordStyle + } var records []ExpectedDNSRecord - // _ans TXT record for each protocol endpoint — agent discovery. - for _, ep := range reg.Endpoints { - value := fmt.Sprintf("v=ans1; version=%s; p=%s; mode=direct; url=%s", - version, protocolToANSValue(ep.Protocol), ep.AgentURL) + emitLegacy := style == DNSRecordStyleLegacy || style == DNSRecordStyleBoth + emitConsolidated := style == DNSRecordStyleConsolidated || style == DNSRecordStyleBoth + + // _ans TXT record for each protocol endpoint — legacy discovery. + // Base-only registrations omit the `version=` field per §4.4.1. + if emitLegacy { + for _, ep := range reg.Endpoints { + var value string + if baseOnly { + value = fmt.Sprintf("v=ans1; p=%s; mode=direct; url=%s", + protocolToANSValue(ep.Protocol), ep.AgentURL) + } else { + 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 emitConsolidated { + cardSHA := capabilitiesHashBase64URL(reg.CapabilitiesHash) + for _, ep := range reg.Endpoints { + alpn := protocolToANSValue(ep.Protocol) + wk := wkPathFor(ep.Protocol) + // RFC 9460 §2.1 presentation form: unquoted SvcParamValue when + // the value has no characters special to the presentation + // format. alpn tokens (a2a, mcp), port digits, well-known path + // suffixes (agent-card.json), and base64url digests all qualify. + // The resolver-side formatter (formatHTTPSValue) also emits + // unquoted, so the verifier's normalize+compare matches without + // quote-stripping. + value := fmt.Sprintf(`1 . alpn=%s port=443`, alpn) + if wk != "" { + value += fmt.Sprintf(` wk=%s`, wk) + } + if cardSHA != "" { + value += fmt.Sprintf(` card-sha256=%s`, cardSHA) + } + records = append(records, ExpectedDNSRecord{ + Name: fqdn, + Type: DNSRecordSVCB, + Value: value, + Purpose: PurposeDiscovery, + Required: false, + TTL: 3600, + }) + } + } + // _ans-badge TXT record — trust badge. Required alongside _ans: // resolvers and badge-verifying clients expect to find both, and // publishing _ans without _ans-badge would advertise an agent - // that fails the public discovery handshake. + // that fails the public discovery handshake. Base-only + // registrations omit the `version=` field per §4.4 record syntax + // for base-only badges; the ANSName field on the registration + // itself is absent so the badge records the registration without + // version pinning. if len(reg.Endpoints) > 0 { - badgeValue := fmt.Sprintf("v=ans-badge1; version=%s; url=%s", - version, reg.Endpoints[0].AgentURL) + var badgeValue string + if baseOnly { + badgeValue = fmt.Sprintf("v=ans-badge1; url=%s", reg.Endpoints[0].AgentURL) + } else { + badgeValue = fmt.Sprintf("v=ans-badge1; version=%s; url=%s", + version, reg.Endpoints[0].AgentURL) + } records = append(records, ExpectedDNSRecord{ Name: fmt.Sprintf("_ans-badge.%s", fqdn), Type: DNSRecordTXT, @@ -118,3 +309,44 @@ func protocolToANSValue(p Protocol) string { return string(p) } } + +// wkPathFor returns the suffix-only well-known path published in the +// Consolidated Approach SVCB record's `wk=` SvcParam. Suffix-only matches +// the consolidated-draft examples (§4 line 134); clients prepend +// `/.well-known/` to construct the full path. Empty result means the +// caller SHOULD omit `wk=` entirely (e.g., direct-mode agents that +// expose no canonical metadata file). +// +// A2A: `agent-card.json` (IANA-registered well-known per A2A spec). +// MCP: `mcp.json` (de-facto convention; see SEP-1649 progress). +// HTTP-API: empty (no per-protocol metadata file convention). +func wkPathFor(p Protocol) string { + switch p { + case ProtocolA2A: + return "agent-card.json" + case ProtocolMCP: + return "mcp.json" + default: + return "" + } +} + +// capabilitiesHashBase64URL re-encodes a hex-lowercase SHA-256 digest +// (the form `AgentRegistration.CapabilitiesHash` carries) into the +// base64url form (RFC 4648 §5, no padding) the SVCB `card-sha256` +// SvcParam expects. Empty input returns empty output, which the caller +// SHOULD treat as "omit the SvcParam entirely" — agents registered +// without `agentCardContent` have no committed value to publish. +func capabilitiesHashBase64URL(hexDigest string) string { + if hexDigest == "" { + return "" + } + raw, err := hex.DecodeString(hexDigest) + if err != nil || len(raw) == 0 { + // Malformed input is logically equivalent to absence; the RA + // stores well-formed hex by construction (helpers.go: + // hashAgentCardContent), but defensive on the boundary. + return "" + } + return base64.RawURLEncoding.EncodeToString(raw) +} diff --git a/internal/domain/dnsrecords_test.go b/internal/domain/dnsrecords_test.go index 9929dec..7831b9a 100644 --- a/internal/domain/dnsrecords_test.go +++ b/internal/domain/dnsrecords_test.go @@ -12,6 +12,10 @@ func TestComputeRequiredDNSRecords_WithoutCert(t *testing.T) { ansName, _ := NewAnsName(mustSemVer(1, 2, 3), "agent.example.com") reg := &AgentRegistration{ AnsName: ansName, + // Force "both" style so this fixture exercises the union path: + // _ans TXT + Consolidated Approach SVCB. Tests below cover the + // single-style emission paths. + DNSRecordStyle: DNSRecordStyleBoth, Endpoints: []AgentEndpoint{ {Protocol: ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com/a2a"}, @@ -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,193 @@ 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_LegacyOnlyEmitsHTTPSRR pins the legacy +// shape: HTTPS RR is generated alongside the `_ans` TXT family, NOT +// alongside the consolidated SVCB rows (which would duplicate the +// alpn/port SvcParams). §A.8.1 lists the HTTPS RR as RA-generated +// content the AHP provisions when the apex isn't aliased via CNAME. +func TestComputeRequiredDNSRecords_LegacyOnlyEmitsHTTPSRR(t *testing.T) { + ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") + reg := &AgentRegistration{ + AnsName: ansName, + DNSRecordStyle: DNSRecordStyleLegacy, + Endpoints: []AgentEndpoint{ + {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com"}, + }, + } + records := ComputeRequiredDNSRecords(reg) + + var sawHTTPS, sawSVCB bool + for _, r := range records { + switch r.Type { + case DNSRecordHTTPS: + sawHTTPS = true + case DNSRecordSVCB: + sawSVCB = true + } + } + assert.True(t, sawHTTPS, "legacy style must include an HTTPS RR") + assert.False(t, sawSVCB, "legacy style must NOT include SVCB rows") +} + +// TestComputeRequiredDNSRecords_ConsolidatedOmitsHTTPSRR pins the +// consolidated form's lean shape: HTTPS RR is omitted because the +// SVCB rows already carry equivalent SvcParams (alpn, port, ECH). +// Publishing both would duplicate content and risk drift between +// the two records. §A.8.2 calls this out explicitly. +func TestComputeRequiredDNSRecords_ConsolidatedOmitsHTTPSRR(t *testing.T) { + ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") + reg := &AgentRegistration{ + AnsName: ansName, + DNSRecordStyle: DNSRecordStyleConsolidated, + Endpoints: []AgentEndpoint{ + {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com"}, + }, + } + records := ComputeRequiredDNSRecords(reg) + for _, r := range records { + assert.NotEqual(t, DNSRecordHTTPS, r.Type, + "consolidated style omits HTTPS RR (SVCB SvcParams subsume it)") + } +} + +// TestComputeRequiredDNSRecords_SVCBWkPath pins the per-protocol `wk=` +// SvcParam value the Consolidated Approach SVCB carries. A2A maps to +// `agent-card.json` (IANA-registered); MCP maps to `mcp.json` (de-facto +// convention). Suffix-only — the consolidated draft's primary examples +// use the suffix and clients prepend `/.well-known/`. +func TestComputeRequiredDNSRecords_SVCBWkPath(t *testing.T) { + ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") + reg := &AgentRegistration{ + AnsName: ansName, + Endpoints: []AgentEndpoint{ + {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com"}, + {Protocol: ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, + }, + } + records := ComputeRequiredDNSRecords(reg) + + for _, r := range records { + if r.Type != DNSRecordSVCB { + continue + } + switch { + case strings.Contains(r.Value, `alpn=a2a`): + assert.Contains(t, r.Value, `wk=agent-card.json`) + case strings.Contains(r.Value, `alpn=mcp`): + assert.Contains(t, r.Value, `wk=mcp.json`) + default: + t.Errorf("SVCB row missing recognized alpn: %q", r.Value) + } + } +} + +// TestComputeRequiredDNSRecords_SVCBCardSHA256_PresentWhenSet verifies +// that an agent registered with agentCardContent emits SVCB rows whose +// card-sha256 SvcParam is the base64url form of reg.CapabilitiesHash. +// This is the DNS half of §4.4.2's three-way cross-check (the live +// Trust Card body, the TL-sealed capabilities_hash, and the SVCB +// card-sha256 all commit to the same SHA-256). +func TestComputeRequiredDNSRecords_SVCBCardSHA256_PresentWhenSet(t *testing.T) { + ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") + // Fixture digest used across the cross-check — the same hex appears + // in the TL event's attestations.metadataHashes.capabilitiesHash. + hexDigest := "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27" + wantBase64 := "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc" + reg := &AgentRegistration{ + AnsName: ansName, + CapabilitiesHash: hexDigest, + Endpoints: []AgentEndpoint{ + {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com"}, + }, + } + records := ComputeRequiredDNSRecords(reg) + + var sawSVCB bool + for _, r := range records { + if r.Type != DNSRecordSVCB { + continue + } + sawSVCB = true + assert.Contains(t, r.Value, `card-sha256=`+wantBase64, + "SVCB card-sha256 must be base64url(decoded hex of reg.CapabilitiesHash)") + } + assert.True(t, sawSVCB, "expected at least one SVCB row") +} + +// TestComputeRequiredDNSRecords_SVCBCardSHA256_AbsentWhenUnset verifies +// the spec-conformant "no agentCardContent submitted" path: the SVCB +// row omits the card-sha256 SvcParam entirely. A verifier seeing no +// SvcParam falls back to TOFU on first Trust Card fetch (§4.4.2). +func TestComputeRequiredDNSRecords_SVCBCardSHA256_AbsentWhenUnset(t *testing.T) { + ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") + reg := &AgentRegistration{ + AnsName: ansName, + Endpoints: []AgentEndpoint{ + {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com"}, + }, + } + records := ComputeRequiredDNSRecords(reg) + for _, r := range records { + if r.Type == DNSRecordSVCB { + assert.NotContains(t, r.Value, "card-sha256", + "no agentCardContent → SVCB has no card-sha256 SvcParam") + } + } +} + +// TestCapabilitiesHashBase64URL pins the hex→base64url conversion. +func TestCapabilitiesHashBase64URL(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "live_webmesh_trust_card_digest", + in: "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27", + want: "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc", + }, + { + name: "all_zeros", + in: "0000000000000000000000000000000000000000000000000000000000000000", + want: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }, + { + name: "empty_input_empty_output", + in: "", + want: "", + }, + { + name: "malformed_hex_returns_empty", + in: "not hex", + want: "", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := capabilitiesHashBase64URL(tc.in) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestWkPathFor pins the per-protocol well-known suffix mapping. +func TestWkPathFor(t *testing.T) { + assert.Equal(t, "agent-card.json", wkPathFor(ProtocolA2A)) + assert.Equal(t, "mcp.json", wkPathFor(ProtocolMCP)) + assert.Equal(t, "", wkPathFor(ProtocolHTTPAPI), + "HTTP-API has no per-protocol metadata file convention") + assert.Equal(t, "", wkPathFor(Protocol("UNKNOWN"))) +} + func TestComputeRequiredDNSRecords_WithCert(t *testing.T) { ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") reg := &AgentRegistration{ diff --git a/internal/port/store.go b/internal/port/store.go index 38c79d9..3898864 100644 --- a/internal/port/store.go +++ b/internal/port/store.go @@ -43,8 +43,16 @@ type AgentStore interface { FindByAnsName(ctx context.Context, ansName domain.AnsName) (*domain.AgentRegistration, error) // ExistsByAnsName returns true if any registration uses the given name. + // Returns false for the zero-value AnsName — base-only (§3.2.0) + // uniqueness is enforced via ExistsActiveBaseOnlyByAgentHost instead. ExistsByAnsName(ctx context.Context, ansName domain.AnsName) (bool, error) + // ExistsActiveBaseOnlyByAgentHost returns true if a base-only + // registration (no ANSName) is currently active or pending for + // the given FQDN. Versioned registrations on the same FQDN do NOT + // trigger this check. + ExistsActiveBaseOnlyByAgentHost(ctx context.Context, host string) (bool, error) + // FindAllByAgentHost returns every registration (any version, any status) // for the given FQDN, newest first. FindAllByAgentHost(ctx context.Context, host string) ([]*domain.AgentRegistration, error) diff --git a/internal/ra/handler/dto.go b/internal/ra/handler/dto.go index f124b9b..80fa70b 100644 --- a/internal/ra/handler/dto.go +++ b/internal/ra/handler/dto.go @@ -40,12 +40,21 @@ func mapListResponse(res *service.ListResult) listResponse { if eps, ok := res.Endpoints[reg.AgentID]; ok && eps != nil { epSlice = eps.Endpoints } + // AgentHost reads from reg.FQDN() so base-only registrations + // (zero AnsName) emit their stored AgentHost rather than an + // empty AnsName.FQDN(). Version stays empty for base-only — + // AnsName.Version() on a zero value would surface "0.0.0", + // which a client would mistake for a real version string. + version := "" + if !reg.IsBaseOnly() { + version = reg.AnsName.Version().String() + } items = append(items, listItem{ AgentID: reg.AgentID, AgentDisplayName: reg.Details.DisplayName, AgentDescription: reg.Details.Description, - Version: reg.AnsName.Version().String(), - AgentHost: reg.AnsName.FQDN(), + Version: version, + AgentHost: reg.FQDN(), AnsName: reg.AnsName.String(), Status: string(reg.Status), TTL: 300, @@ -94,12 +103,21 @@ func mapAgentDetails(res *service.DetailResult, r *http.Request) agentDetails { // set (endpoints live in their own table and are returned as a // sibling slice by the service layer). reg.Endpoints = res.Endpoints + // AgentHost reads from reg.FQDN() so base-only registrations + // (zero AnsName) emit their stored AgentHost rather than an empty + // AnsName.FQDN(). Version stays empty for base-only — emitting + // "0.0.0" would let a client mistake the absence of a version for + // a real one. + version := "" + if !reg.IsBaseOnly() { + version = reg.AnsName.Version().String() + } d := agentDetails{ AgentID: reg.AgentID, AgentDisplayName: reg.Details.DisplayName, AgentDescription: reg.Details.Description, - Version: reg.AnsName.Version().String(), - AgentHost: reg.AnsName.FQDN(), + Version: version, + AgentHost: reg.FQDN(), AnsName: reg.AnsName.String(), AgentStatus: string(reg.Status), Endpoints: mapEndpointsToDTO(res.Endpoints), diff --git a/internal/ra/handler/lifecycle_test.go b/internal/ra/handler/lifecycle_test.go index e73b23b..8a23818 100644 --- a/internal/ra/handler/lifecycle_test.go +++ b/internal/ra/handler/lifecycle_test.go @@ -732,6 +732,8 @@ type handlerFixture struct { router chi.Router outbox *sqlite.OutboxStore svc *service.RegistrationService // exposed for direct-handler tests + agents *sqlite.AgentStore // exposed for tests that assert on stored-aggregate state + ctx context.Context } func newHandlerFixture(t *testing.T) *handlerFixture { @@ -830,7 +832,13 @@ func newHandlerFixture(t *testing.T) *handlerFixture { r.With(writeOwn).Delete("/v1/agents/{agentId}/certificates/server/renewal", v1renH.CancelServerCertRenewal) r.With(writeOwn).Post("/v1/agents/{agentId}/certificates/server/renewal/verify-acme", v1renH.VerifyRenewalACME) - return &handlerFixture{router: r, outbox: outbox, svc: svc} + return &handlerFixture{ + router: r, + outbox: outbox, + svc: svc, + agents: agents, + ctx: context.Background(), + } } // asOwner wraps a request with a synthetic Identity matching the diff --git a/internal/ra/handler/registration.go b/internal/ra/handler/registration.go index 51656bc..df79891 100644 --- a/internal/ra/handler/registration.go +++ b/internal/ra/handler/registration.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "net/http" + "strings" "github.com/godaddy/ans/internal/adapter/auth" "github.com/godaddy/ans/internal/domain" @@ -30,15 +31,36 @@ func NewRegistrationHandler(svc *service.RegistrationService) *RegistrationHandl // `ServerCertificateAuthority` port; the BYOC path routes through // the certificate validator. type registrationRequest struct { - AgentDisplayName string `json:"agentDisplayName"` - AgentDescription string `json:"agentDescription,omitempty"` - Version string `json:"version"` - AgentHost string `json:"agentHost"` - Endpoints []endpointDTO `json:"endpoints"` - IdentityCSRPEM string `json:"identityCsrPEM"` - ServerCsrPEM string `json:"serverCsrPEM,omitempty"` - ServerCertificatePEM string `json:"serverCertificatePEM,omitempty"` - ServerCertificateChainPEM string `json:"serverCertificateChainPEM,omitempty"` + AgentDisplayName string `json:"agentDisplayName"` + AgentDescription string `json:"agentDescription,omitempty"` + // Version is optional under §3.2.0 (base-only registrations). + // Required when identityCsrPEM is supplied; rejected when + // identityCsrPEM is absent. The handler enforces both-or-neither. + Version string `json:"version,omitempty"` + AgentHost string `json:"agentHost"` + Endpoints []endpointDTO `json:"endpoints"` + // IdentityCSRPEM is optional under §3.2.0. Required when Version + // is supplied; rejected when Version is absent. + IdentityCSRPEM string `json:"identityCsrPEM,omitempty"` + ServerCsrPEM string `json:"serverCsrPEM,omitempty"` + ServerCertificatePEM string `json:"serverCertificatePEM,omitempty"` + ServerCertificateChainPEM string `json:"serverCertificateChainPEM,omitempty"` + + // AgentCardContent is the optional ANS Trust Card body the + // operator submits per ANS_SPEC.md §A.1. Modeled as + // json.RawMessage so the bytes reach the service layer without + // re-marshaling — JCS canonicalization is byte-precise, and any + // round-trip through map[string]any would risk reordering or + // number normalization that would shift the resulting digest. + AgentCardContent json.RawMessage `json:"agentCardContent,omitempty"` + + // DNSRecordStyle selects which DNS record family the RA emits + // for this registration. One of "consolidated" (default, + // recommended), "legacy" (original `_ans` TXT shape), "both" + // (transition union). Empty/missing → consolidated. Invalid + // value rejected with 422 INVALID_DNS_RECORD_STYLE. See + // ANS_SPEC.md §4.4.2 for record-shape semantics. + DNSRecordStyle string `json:"dnsRecordStyle,omitempty"` } type endpointDTO struct { @@ -125,13 +147,7 @@ func (h *RegistrationHandler) Register(w http.ResponseWriter, r *http.Request) { return } - // Parse version + host into an AnsName. - semver, err := domain.ParseSemVer(req.Version) - if err != nil { - WriteError(w, err) - return - } - ansName, err := domain.NewAnsName(semver, req.AgentHost) + ansName, err := resolveAnsNameForRegister(&req) if err != nil { WriteError(w, err) return @@ -146,6 +162,7 @@ func (h *RegistrationHandler) Register(w http.ResponseWriter, r *http.Request) { resp, err := h.svc.RegisterAgent(r.Context(), service.RegisterRequest{ OwnerID: id.Subject, AnsName: ansName, + AgentHost: req.AgentHost, DisplayName: req.AgentDisplayName, Description: req.AgentDescription, Endpoints: eps, @@ -153,6 +170,8 @@ func (h *RegistrationHandler) Register(w http.ResponseWriter, r *http.Request) { ServerCsrPEM: req.ServerCsrPEM, ServerCertificatePEM: req.ServerCertificatePEM, ServerCertificateChainPEM: req.ServerCertificateChainPEM, + AgentCardContent: []byte(req.AgentCardContent), + DNSRecordStyle: domain.DNSRecordStyle(req.DNSRecordStyle), }) if err != nil { WriteError(w, err) @@ -275,3 +294,38 @@ func schemeOf(r *http.Request) string { // silence "imported and not used" if handlers evolve. var _ = errors.New + +// resolveAnsNameForRegister translates the request's optional version +// and Identity CSR fields into a canonical AnsName per ANS_SPEC.md +// §3.2.0 + §1.8. +// +// Returns: +// - non-zero AnsName when both version + identityCsrPEM are +// supplied (versioned registration); +// - zero-value AnsName when both are absent (base-only); +// - validation error when one is supplied without the other +// (VERSIONED_REQUIRES_IDENTITY_CSR or BASE_ONLY_REJECTS_IDENTITY_CSR). +func resolveAnsNameForRegister(req *registrationRequest) (domain.AnsName, error) { + versionGiven := strings.TrimSpace(req.Version) != "" + csrGiven := strings.TrimSpace(req.IdentityCSRPEM) != "" + switch { + case versionGiven && !csrGiven: + return domain.AnsName{}, domain.NewValidationError( + "VERSIONED_REQUIRES_IDENTITY_CSR", + "version submitted without identityCsrPEM: versioned registrations require both", + ) + case !versionGiven && csrGiven: + return domain.AnsName{}, domain.NewValidationError( + "BASE_ONLY_REJECTS_IDENTITY_CSR", + "identityCsrPEM submitted without a version: base-only registrations cannot have an Identity Certificate", + ) + case !versionGiven && !csrGiven: + // Base-only path: zero-value AnsName signals to the service. + return domain.AnsName{}, nil + } + semver, err := domain.ParseSemVer(req.Version) + if err != nil { + return domain.AnsName{}, err + } + return domain.NewAnsName(semver, req.AgentHost) +} diff --git a/internal/ra/handler/registration_capabilitieshash_e2e_test.go b/internal/ra/handler/registration_capabilitieshash_e2e_test.go new file mode 100644 index 0000000..02337dc --- /dev/null +++ b/internal/ra/handler/registration_capabilitieshash_e2e_test.go @@ -0,0 +1,130 @@ +package handler_test + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "testing" + + anscrypto "github.com/godaddy/ans/internal/crypto" +) + +// TestRegister_AgentCardContent_E2E_HashSealedAtActivation drives the +// full §A.1 flow and asserts the outbox-enqueued AGENT_REGISTERED +// event carries the expected hash: +// +// 1. POST /v2/ans/agents with agentCardContent → 202 +// 2. POST /v2/ans/agents/{id}/verify-acme → 202 (PENDING_DNS) +// 3. POST /v2/ans/agents/{id}/verify-dns → 202 (ACTIVE) +// 4. Outbox.Claim → AGENT_REGISTERED row whose payload contains +// attestations.metadataHashes.capabilitiesHash equal to +// SHA-256(JCS(agentCardContent)). +// +// This is the closing assertion the AIM relies on: the badge response +// the TL eventually serves carries the same capabilitiesHash the +// operator's submitted Trust Card body produces under JCS+SHA-256. +func TestRegister_AgentCardContent_E2E_HashSealedAtActivation(t *testing.T) { + t.Parallel() + fx := newHandlerFixture(t) + + cardBody := map[string]any{ + "ansName": "ans://v1.0.0.e2ecard.example.com", + "version": "1.0.0", + "agentDisplayName": "E2E Card", + "endpoints": []map[string]any{ + { + "protocol": "MCP", + "agentUrl": "https://e2ecard.example.com/mcp", + "transports": []string{"SSE"}, + "metaDataUrl": "https://e2ecard.example.com/.well-known/agent-card.json", + }, + }, + } + cardJSON, err := json.Marshal(cardBody) + if err != nil { + t.Fatalf("marshal card body: %v", err) + } + + // Compute the expected hash here so the assertion is independent + // of the production code path. JCS canonicalization (RFC 8785) is + // the same package the production hashAgentCardContent uses, but + // invoking it in the test instead of importing the unexported + // helper keeps the dependency boundary explicit. + canonical, err := anscrypto.Canonicalize(cardJSON) + if err != nil { + t.Fatalf("canonicalize: %v", err) + } + digest := sha256.Sum256(canonical) + wantHash := hex.EncodeToString(digest[:]) + + registerBody, _ := json.Marshal(map[string]any{ + "agentDisplayName": "E2E", + "version": "1.0.0", + "agentHost": "e2ecard.example.com", + "endpoints": []map[string]any{ + {"agentUrl": "https://e2ecard.example.com/mcp", "protocol": "MCP", "transports": []string{"SSE"}}, + }, + "identityCsrPEM": newTestCSR(t, "ans://v1.0.0.e2ecard.example.com"), + "serverCsrPEM": newTestServerCSR(t, "e2ecard.example.com"), + "agentCardContent": cardBody, + }) + rec := fx.request(t, http.MethodPost, "/v2/ans/agents", + bytes.NewReader(registerBody), fx.asOwner("alice")) + if rec.Code != http.StatusAccepted { + t.Fatalf("register: got %d body=%s", rec.Code, rec.Body) + } + var pending struct { + AgentID string `json:"agentId"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &pending) + if pending.AgentID == "" { + t.Fatalf("agentId not populated in 202 body: %s", rec.Body) + } + agentID := pending.AgentID + + // Drive activation. The lifecycle helper goes through verify-acme + // + verify-dns and produces the AGENT_REGISTERED outbox row. + fx.activateAgent(t, "alice", agentID) + + rows, err := fx.outbox.Claim(context.Background(), 100) + if err != nil { + t.Fatalf("outbox claim: %v", err) + } + var registeredPayload []byte + for _, row := range rows { + if row.EventType == "AGENT_REGISTERED" { + registeredPayload = row.PayloadJSON + break + } + } + if registeredPayload == nil { + t.Fatalf("no AGENT_REGISTERED row found among %d outbox events", len(rows)) + } + + // The outbox payload is { innerEventCanonical, producerSignature } + // — the inner event is the producer-signed payload that gets + // posted to the TL. Walk innerEventCanonical.attestations. + // metadataHashes.capabilitiesHash and confirm it matches. + var envelope struct { + InnerEventCanonical struct { + Attestations struct { + MetadataHashes map[string]string `json:"metadataHashes"` + } `json:"attestations"` + } `json:"innerEventCanonical"` + } + if err := json.Unmarshal(registeredPayload, &envelope); err != nil { + t.Fatalf("decode envelope: %v\npayload=%s", err, registeredPayload) + } + gotHash := envelope.InnerEventCanonical.Attestations.MetadataHashes["capabilitiesHash"] + if gotHash == "" { + t.Fatalf("metadataHashes.capabilitiesHash missing from AGENT_REGISTERED payload\npayload=%s", + registeredPayload) + } + if gotHash != wantHash { + t.Errorf("metadataHashes.capabilitiesHash mismatch:\n want %s\n got %s", + wantHash, gotHash) + } +} diff --git a/internal/ra/handler/registration_errors_test.go b/internal/ra/handler/registration_errors_test.go index 9cee98e..c9acf9e 100644 --- a/internal/ra/handler/registration_errors_test.go +++ b/internal/ra/handler/registration_errors_test.go @@ -20,6 +20,119 @@ import ( // service layer are also touched, but the assertions focus on the // 422/413 status code returned by the handler's early-exit. +// TestRegister_AgentCardContent_PlumbedToService verifies the V2 +// handler delivers the agentCardContent body to the service as the +// raw JSON bytes the operator submitted. The service hashes the bytes +// (covered by service-layer tests); this test only proves the wire +// is connected. +func TestRegister_AgentCardContent_PlumbedToService(t *testing.T) { + t.Parallel() + fx := newHandlerFixture(t) + cardBody := map[string]any{ + "ansName": "ans://v1.0.0.cardplumb.example.com", + "version": "1.0.0", + } + body, _ := json.Marshal(map[string]any{ + "agentDisplayName": "X", + "version": "1.0.0", + "agentHost": "cardplumb.example.com", + "endpoints": []map[string]any{ + {"agentUrl": "https://cardplumb.example.com", "protocol": "MCP", "transports": []string{"SSE"}}, + }, + "identityCsrPEM": newTestCSR(t, "ans://v1.0.0.cardplumb.example.com"), + "serverCsrPEM": newTestServerCSR(t, "cardplumb.example.com"), + "agentCardContent": cardBody, + }) + rec := fx.request(t, http.MethodPost, "/v2/ans/agents", + bytes.NewReader(body), fx.asOwner("alice")) + if rec.Code != http.StatusAccepted { + t.Fatalf("status: got %d want 202; body=%s", rec.Code, rec.Body) + } + // The aggregate must carry a non-empty CapabilitiesHash. The exact + // hex value is the service-layer concern; here we just confirm the + // hash flowed through, which proves the handler delivered the bytes. + regs, err := fx.agents.FindAllByAgentHost(fx.ctx, "cardplumb.example.com") + if err != nil { + t.Fatalf("FindAllByAgentHost: %v", err) + } + if len(regs) != 1 { + t.Fatalf("agents: got %d want 1", len(regs)) + } + if regs[0].CapabilitiesHash == "" { + t.Errorf("CapabilitiesHash empty after register with agentCardContent") + } + if len(regs[0].CapabilitiesHash) != 64 { + t.Errorf("CapabilitiesHash length: got %d want 64", len(regs[0].CapabilitiesHash)) + } +} + +// TestRegister_AgentCardContent_OmittedNoHash verifies the absence +// path: a registration without agentCardContent leaves the aggregate's +// CapabilitiesHash empty. +func TestRegister_AgentCardContent_OmittedNoHash(t *testing.T) { + t.Parallel() + fx := newHandlerFixture(t) + body, _ := json.Marshal(map[string]any{ + "agentDisplayName": "X", + "version": "1.0.0", + "agentHost": "noccard.example.com", + "endpoints": []map[string]any{ + {"agentUrl": "https://noccard.example.com", "protocol": "MCP", "transports": []string{"SSE"}}, + }, + "identityCsrPEM": newTestCSR(t, "ans://v1.0.0.noccard.example.com"), + "serverCsrPEM": newTestServerCSR(t, "noccard.example.com"), + }) + rec := fx.request(t, http.MethodPost, "/v2/ans/agents", + bytes.NewReader(body), fx.asOwner("alice")) + if rec.Code != http.StatusAccepted { + t.Fatalf("status: got %d want 202; body=%s", rec.Code, rec.Body) + } + regs, err := fx.agents.FindAllByAgentHost(fx.ctx, "noccard.example.com") + if err != nil { + t.Fatalf("FindAllByAgentHost: %v", err) + } + if len(regs) != 1 { + t.Fatalf("agents: got %d want 1", len(regs)) + } + if regs[0].CapabilitiesHash != "" { + t.Errorf("CapabilitiesHash: want empty, got %q", regs[0].CapabilitiesHash) + } +} + +// TestRegister_AgentCardContent_MalformedJSON_RejectedAt422 asserts +// the validation path: the entire registration request is malformed +// JSON if the agentCardContent value itself is malformed (the body +// won't even decode), so 422 is the expected response. Submitting a +// JSON value that decodes but then fails JCS canonicalization (e.g., +// an array — JCS only accepts objects in this context) routes through +// the service-layer validator, which returns +// INVALID_AGENT_CARD_CONTENT. We only test the latter here; the former +// is already covered by TestRegister_BadJSONReturns422. +func TestRegister_AgentCardContent_MalformedJSON_RejectedAt422(t *testing.T) { + t.Parallel() + // JCS canonicalization fails on inputs that aren't valid JSON. + // Embedding a literal "garbage" string in agentCardContent would + // be valid JSON (just a string), and JCS accepts strings. To + // trigger the service-layer validation error path, we send an + // agentCardContent that is itself raw bytes the JSON decoder + // won't accept — but those bytes break the outer body decode + // before reaching the service. So the production failure mode + // for malformed agentCardContent is BAD_JSON at the handler. + // This test pins that contract: handler rejects, service is + // never reached. + fx := newHandlerFixture(t) + bad := []byte(`{ + "agentDisplayName": "X", + "agentCardContent": {{this is not valid json}}, + "version": "1.0.0" + }`) + rec := fx.request(t, http.MethodPost, "/v2/ans/agents", + bytes.NewReader(bad), fx.asOwner("alice")) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("status: got %d want 422; body=%s", rec.Code, rec.Body) + } +} + func TestRegister_BadJSONReturns422(t *testing.T) { t.Parallel() fx := newHandlerFixture(t) diff --git a/internal/ra/middleware/ownership_test.go b/internal/ra/middleware/ownership_test.go index cf4b8e3..67c6275 100644 --- a/internal/ra/middleware/ownership_test.go +++ b/internal/ra/middleware/ownership_test.go @@ -270,6 +270,9 @@ func (f *fakeAgentStore) FindByAnsName(_ context.Context, _ domain.AnsName) (*do func (f *fakeAgentStore) ExistsByAnsName(_ context.Context, _ domain.AnsName) (bool, error) { return false, nil } +func (f *fakeAgentStore) ExistsActiveBaseOnlyByAgentHost(_ context.Context, _ string) (bool, error) { + return false, nil +} func (f *fakeAgentStore) FindAllByAgentHost(_ context.Context, _ string) ([]*domain.AgentRegistration, error) { return nil, nil } diff --git a/internal/ra/service/helpers.go b/internal/ra/service/helpers.go index fda9cd6..1c2c9cd 100644 --- a/internal/ra/service/helpers.go +++ b/internal/ra/service/helpers.go @@ -1,16 +1,132 @@ package service import ( + "context" "crypto/sha256" "encoding/hex" "encoding/pem" "errors" "fmt" + "strings" "time" + "github.com/google/uuid" + + anscrypto "github.com/godaddy/ans/internal/crypto" "github.com/godaddy/ans/internal/domain" ) +// checkRegistrationUniqueness gates RegisterAgent on the appropriate +// uniqueness scope. Versioned registrations conflict by ANSName; +// base-only registrations (§3.2.0) conflict by agent FQDN since they +// have no ANSName. Splitting the two paths into a helper keeps the +// nested-if depth out of RegisterAgent. +func (s *RegistrationService) checkRegistrationUniqueness(ctx context.Context, req RegisterRequest, fqdn string) error { + if req.AnsName.IsZero() { + baseExists, err := s.agents.ExistsActiveBaseOnlyByAgentHost(ctx, fqdn) + if err != nil { + return err + } + if baseExists { + return domain.NewConflictError( + "BASE_ONLY_FQDN_TAKEN", + fmt.Sprintf("a base-only registration for %q is already active or pending", fqdn), + ) + } + return nil + } + exists, err := s.agents.ExistsByAnsName(ctx, req.AnsName) + if err != nil { + return err + } + if exists { + return domain.NewConflictError( + "ANS_NAME_TAKEN", + fmt.Sprintf("ANS name %q is already registered", req.AnsName), + ) + } + return nil +} + +// buildOptionalIdentityCSR returns a fresh *AgentCSR when the +// caller supplied a non-empty PEM, or nil when they didn't. Base-only +// registrations (§3.2.0) submit no Identity CSR; the domain layer +// enforces the both-or-neither invariant against the AnsName field. +func buildOptionalIdentityCSR(pem string, now time.Time) *domain.AgentCSR { + if strings.TrimSpace(pem) == "" { + return nil + } + csr := domain.NewIdentityCSR(uuid.NewString(), pem, now) + return &csr +} + +// hashAgentCardContent canonicalizes the raw JSON bytes per +// RFC 8785 (JCS) and returns the SHA-256 hex-lowercase digest. +// The output format matches the wire format the AIM expects for +// attestations.metadataHashes.capabilitiesHash. +// +// JCS canonicalization fails on malformed JSON; the caller surfaces +// that as an INVALID_AGENT_CARD_CONTENT validation error. +func hashAgentCardContent(content []byte) (string, error) { + canonical, err := anscrypto.Canonicalize(content) + if err != nil { + return "", err + } + sum := sha256.Sum256(canonical) + return hex.EncodeToString(sum[:]), nil +} + +// applyDNSRecordStyle resolves the DNS-record-style for the new +// registration and stores it on the aggregate. +// +// V1 lane is pinned to "legacy" regardless of the request: V1 callers +// predate the Consolidated Approach and their tooling expects the +// original `_ans` TXT shape. V1 has no dnsRecordStyle field on the +// wire, so this branch is the only path V1 registrations take. +// V2 callers honor req.DNSRecordStyle: empty normalizes to +// DefaultDNSRecordStyle (consolidated); invalid values surface as +// INVALID_DNS_RECORD_STYLE. +func applyDNSRecordStyle(reg *domain.AgentRegistration, req RegisterRequest) error { + switch { + case req.SchemaVersion == "V1": + reg.DNSRecordStyle = domain.DNSRecordStyleLegacy + case req.DNSRecordStyle == "": + reg.DNSRecordStyle = domain.DefaultDNSRecordStyle + case !req.DNSRecordStyle.IsValid(): + return domain.NewValidationError( + "INVALID_DNS_RECORD_STYLE", + fmt.Sprintf("dnsRecordStyle %q is not one of consolidated, legacy, both", string(req.DNSRecordStyle)), + ) + default: + reg.DNSRecordStyle = req.DNSRecordStyle + } + return nil +} + +// applyAgentCardContentHash hashes the optional agentCardContent +// the operator submitted on the V2 registration request and stores +// the digest on the aggregate per ANS_SPEC.md §A.1. Empty content +// is a no-op (the spec-conformant "no Trust Card body submitted" +// path leaves CapabilitiesHash empty so the activation flow omits +// the metadataHashes.capabilitiesHash key). +// +// Malformed JSON surfaces as INVALID_AGENT_CARD_CONTENT rather than +// silently dropping the digest. +func applyAgentCardContentHash(reg *domain.AgentRegistration, content []byte) error { + if len(content) == 0 { + return nil + } + hashHex, err := hashAgentCardContent(content) + if err != nil { + return domain.NewValidationError( + "INVALID_AGENT_CARD_CONTENT", + fmt.Sprintf("agentCardContent could not be canonicalized: %v", err), + ) + } + reg.CapabilitiesHash = hashHex + return nil +} + // fingerprintOf returns the SHA-256 fingerprint of the DER certificate // inside the given PEM string, formatted as `SHA256:`. // The `SHA256:` prefix matches the algorithm-prefixed form the diff --git a/internal/ra/service/lifecycle.go b/internal/ra/service/lifecycle.go index b6a9f6b..51f01c8 100644 --- a/internal/ra/service/lifecycle.go +++ b/internal/ra/service/lifecycle.go @@ -186,7 +186,7 @@ func (s *RegistrationService) SubmitServerCSR(ctx context.Context, agentID, csrP if err != nil { return "", err } - if err := s.validator.ValidateServerCSR(ctx, csrPEM, reg.AnsName.FQDN()); err != nil { + if err := s.validator.ValidateServerCSR(ctx, csrPEM, reg.FQDN()); err != nil { return "", domain.NewValidationError("INVALID_SERVER_CSR", err.Error()) } csrID := uuid.NewString() @@ -301,37 +301,56 @@ func (s *RegistrationService) VerifyACME(ctx context.Context, agentID string, in } } - // 2. Sign the identity CSR. Issuance + signing are CPU-bound and - // do not touch the DB; we run them outside the tx so the - // SQLite write lock isn't held during work that doesn't need - // it. Same pattern below for the server CSR path. - identityCSR, err := s.certs.FindLatestPendingCSRByType(ctx, agentID, domain.CSRTypeIdentity) - if err != nil { - return nil, err - } - if identityCSR == nil { - return nil, domain.NewInvalidStateError( - "MISSING_IDENTITY_CSR", - "no pending identity CSR for agent — aggregate in inconsistent state", - ) - } - issuedID, err := s.identityCA.IssueIdentityCertificate(ctx, identityCSR.CSRContent, reg.AnsName.String()) - if err != nil { - return nil, domain.NewInternalError("CERT_ISSUE_FAILED", "failed to issue identity cert", err) - } - signedID, err := identityCSR.MarkSigned(now) - if err != nil { - return nil, err - } - reg.IdentityCSR = &signedID - storedID := &domain.StoredCertificate{ - CSRID: identityCSR.CSRID, - CertificateType: domain.CertTypeIdentity, - CertificatePEM: issuedID.CertPEM, - ChainPEM: issuedID.ChainPEM, - Status: domain.CertStatusValid, - IssueTimestamp: issuedID.IssuedAt, - ExpirationTimestamp: issuedID.ExpiresAt, + // 2. Sign the identity CSR — only for versioned registrations. + // Plan F §3.2.0 base-only registrations carry no Identity CSR + // and no Identity Certificate is issued; the lifecycle still + // advances through PENDING_DNS to ACTIVE, just without the + // cert artifacts. Plan G's non-FQDN anchors (DID, LEI) all + // take this path because NON_FQDN_REQUIRES_BASE_ONLY forced + // them base-only at registration time. + // + // For versioned registrations the path is unchanged: fetch + // pending CSR, sign through the IdentityCertificateAuthority + // port, mark the CSR signed, build the StoredCertificate row. + // Issuance + signing are CPU-bound and do not touch the DB; + // we run them outside the tx so the SQLite write lock isn't + // held during work that doesn't need it. Same pattern below + // for the server CSR path. + var ( + signedID domain.AgentCSR + storedID *domain.StoredCertificate + hasIdentity bool + ) + if !reg.IsBaseOnly() { + identityCSR, err := s.certs.FindLatestPendingCSRByType(ctx, agentID, domain.CSRTypeIdentity) + if err != nil { + return nil, err + } + if identityCSR == nil { + return nil, domain.NewInvalidStateError( + "MISSING_IDENTITY_CSR", + "no pending identity CSR for agent — aggregate in inconsistent state", + ) + } + issuedID, err := s.identityCA.IssueIdentityCertificate(ctx, identityCSR.CSRContent, reg.AnsName.String()) + if err != nil { + return nil, domain.NewInternalError("CERT_ISSUE_FAILED", "failed to issue identity cert", err) + } + signedID, err = identityCSR.MarkSigned(now) + if err != nil { + return nil, err + } + reg.IdentityCSR = &signedID + storedID = &domain.StoredCertificate{ + CSRID: identityCSR.CSRID, + CertificateType: domain.CertTypeIdentity, + CertificatePEM: issuedID.CertPEM, + ChainPEM: issuedID.ChainPEM, + Status: domain.CertStatusValid, + IssueTimestamp: issuedID.IssuedAt, + ExpirationTimestamp: issuedID.ExpiresAt, + } + hasIdentity = true } // 3. CSR-path server cert: same shape — sign + validate up @@ -360,14 +379,18 @@ func (s *RegistrationService) VerifyACME(ctx context.Context, agentID string, in // issued server cert (if any), and the agent's new state. // Pre-tx, agent.Save committed first and a downstream failure // left a PENDING_DNS agent with no associated cert rows. + // Base-only registrations skip the identity CSR + cert writes; + // the server CSR path is unchanged. if err := s.uow.Run(ctx, func(txCtx context.Context) error { - // SaveCSR upserts on csr_id so the same row flips - // PENDING → SIGNED. - if err := s.certs.SaveCSR(txCtx, reg.AgentID, &signedID); err != nil { - return err - } - if err := s.certs.SaveIdentityCertificate(txCtx, reg.AgentID, storedID); err != nil { - return err + if hasIdentity { + // SaveCSR upserts on csr_id so the same row flips + // PENDING → SIGNED. + if err := s.certs.SaveCSR(txCtx, reg.AgentID, &signedID); err != nil { + return err + } + if err := s.certs.SaveIdentityCertificate(txCtx, reg.AgentID, storedID); err != nil { + return err + } } if byocCert != nil { if err := s.byoc.Save(txCtx, reg.AgentID, byocCert); err != nil { @@ -708,12 +731,24 @@ func (s *RegistrationService) buildAgentRegisteredEvent( // spec — the min(notAfter) across attested certs. inner.ExpiresAt = agentCertExpiry(identityCerts, byocCert, now) + mhashes := metadataHashesFromEndpoints(reg.Endpoints) + if reg.CapabilitiesHash != "" { + // The agent-level Trust Card hash sealed at activation per + // ANS_SPEC.md §A.1. The map key is reserved by the TL event + // package; agents that registered without agentCardContent + // have CapabilitiesHash empty, in which case the key is + // absent and the AIM falls back to TOFU on first fetch. + if mhashes == nil { + mhashes = map[string]string{} + } + mhashes[event.MetadataHashKeyCapabilitiesHash] = reg.CapabilitiesHash + } inner.Attestations = &event.Attestations{ DomainValidation: "ACME-DNS-01", DNSRecordsProvisioned: provisioned, IdentityCerts: idCertInfos, ServerCerts: serverCertInfos, - MetadataHashes: metadataHashesFromEndpoints(reg.Endpoints), + MetadataHashes: mhashes, } return inner, nil } diff --git a/internal/ra/service/registration.go b/internal/ra/service/registration.go index 8c3c050..d47e7e8 100644 --- a/internal/ra/service/registration.go +++ b/internal/ra/service/registration.go @@ -46,8 +46,16 @@ type OutboxEnqueuer interface { // AGENT_REGISTERED event. Empty treated as "V2" for backwards // compatibility with callers predating the V1 lane. type RegisterRequest struct { - OwnerID string - AnsName domain.AnsName + OwnerID string + // AnsName is the versioned agent name. Zero-value for base-only + // registrations (§3.2.0) where the operator submitted neither a + // version nor an Identity CSR. AgentHost carries the FQDN in that + // case. + AnsName domain.AnsName + // AgentHost is the FQDN. Required for base-only registrations + // (when AnsName is zero). For versioned registrations, optional — + // derived from AnsName.FQDN() if not supplied. + AgentHost string DisplayName string Description string Endpoints []domain.AgentEndpoint @@ -72,6 +80,28 @@ type RegisterRequest struct { ServerCertificatePEM string ServerCertificateChainPEM string SchemaVersion string + + // AgentCardContent is the optional ANS Trust Card body the + // operator submits at registration per ANS_SPEC.md §A.1. The + // caller passes the raw JSON bytes as supplied; the service + // JCS-canonicalizes (RFC 8785), SHA-256 hashes, and stores the + // hex-lowercase digest on the AgentRegistration aggregate before + // discarding the bytes. The activation flow seals the digest into + // the AGENT_REGISTERED event under + // attestations.metadataHashes.capabilitiesHash. + // + // Distinct from the per-endpoint MetadataHash on AgentEndpoint, + // which hashes the protocol-native metadata (e.g., A2A AgentCard). + // Empty when omitted on the registration request. + AgentCardContent []byte + + // DNSRecordStyle selects which DNS record family the RA emits + // in dnsRecordsProvisioned and tells the operator to publish. + // "consolidated" (default), "legacy", or "both". Empty value is + // normalized to domain.DefaultDNSRecordStyle. Invalid value + // surfaces as INVALID_DNS_RECORD_STYLE before the aggregate is + // created. + DNSRecordStyle domain.DNSRecordStyle } // RegisterResponse is returned to the HTTP handler after a successful @@ -217,16 +247,18 @@ func (s *RegistrationService) WithDNSVerifier(v port.DNSVerifier) *RegistrationS func (s *RegistrationService) RegisterAgent(ctx context.Context, req RegisterRequest) (*RegisterResponse, error) { now := s.clock() - // Uniqueness check before heavy work. - exists, err := s.agents.ExistsByAnsName(ctx, req.AnsName) - if err != nil { - return nil, err + // Resolve the canonical FQDN once, up front. Versioned registrations + // can derive it from req.AnsName; base-only registrations carry it + // in req.AgentHost. Downstream cert validators all need it, so a + // single source avoids the empty-FQDN trap that the §3.2.0 path + // would hit if we kept reading `req.AnsName.FQDN()` directly. + fqdn := req.AgentHost + if fqdn == "" { + fqdn = req.AnsName.FQDN() } - if exists { - return nil, domain.NewConflictError( - "ANS_NAME_TAKEN", - fmt.Sprintf("ANS name %q is already registered", req.AnsName), - ) + + if err := s.checkRegistrationUniqueness(ctx, req, fqdn); err != nil { + return nil, err } // Server certificate: exactly one of CSR / BYOC. @@ -262,7 +294,7 @@ func (s *RegistrationService) RegisterAgent(ctx context.Context, req RegisterReq switch { case byocSet: v, err := s.validator.ValidateServerCertificate(ctx, - req.ServerCertificatePEM, req.ServerCertificateChainPEM, req.AnsName.FQDN()) + req.ServerCertificatePEM, req.ServerCertificateChainPEM, fqdn) if err != nil { return nil, domain.NewCertificateError("INVALID_SERVER_CERT", err.Error()) } @@ -283,33 +315,47 @@ func (s *RegistrationService) RegisterAgent(ctx context.Context, req RegisterReq "serverCsrPEM submitted but no server CA is configured — either configure one or use serverCertificatePEM (BYOC)", ) } - if err := s.validator.ValidateServerCSR(ctx, req.ServerCsrPEM, req.AnsName.FQDN()); err != nil { + if err := s.validator.ValidateServerCSR(ctx, req.ServerCsrPEM, fqdn); err != nil { return nil, domain.NewValidationError("INVALID_SERVER_CSR", err.Error()) } srvCSR := domain.NewServerCSR(uuid.NewString(), req.ServerCsrPEM, now) pendingServerCSR = &srvCSR } - // Validate identity CSR shape. Signing is deferred to + // Validate identity CSR shape — only when the operator submitted + // one. Base-only registrations (§3.2.0) skip this entirely; the + // handler+resolveAnsNameForRegister already enforces the both- + // or-neither invariant against `version`. Signing is deferred to // verify-acme; the CSR row stays PENDING until then. - if err := s.validator.ValidateIdentityCSR(ctx, req.IdentityCSRPEM, req.AnsName.String()); err != nil { - return nil, domain.NewValidationError("INVALID_IDENTITY_CSR", err.Error()) + if req.IdentityCSRPEM != "" { + if err := s.validator.ValidateIdentityCSR(ctx, req.IdentityCSRPEM, req.AnsName.String()); err != nil { + return nil, domain.NewValidationError("INVALID_IDENTITY_CSR", err.Error()) + } } - // Build aggregates. + // Build aggregates. Identity CSR is optional under the §3.2.0 + // base-only path; domain-layer NewRegistration enforces both- + // or-neither. agentID := uuid.NewString() - csrID := uuid.NewString() - csr := domain.NewIdentityCSR(csrID, req.IdentityCSRPEM, now) + csrPtr := buildOptionalIdentityCSR(req.IdentityCSRPEM, now) reg, err := domain.NewRegistration( - agentID, req.OwnerID, req.AnsName, req.DisplayName, req.Description, - req.Endpoints, byocCert, &csr, now, + agentID, req.OwnerID, req.AnsName, req.AgentHost, req.DisplayName, req.Description, + req.Endpoints, byocCert, csrPtr, now, ) if err != nil { return nil, err } reg.ServerCSR = pendingServerCSR + if err := applyAgentCardContentHash(reg, req.AgentCardContent); err != nil { + return nil, err + } + + if err := applyDNSRecordStyle(reg, req); err != nil { + return nil, err + } + // Generate the ACME DNS-01 challenge token + expiry. The only // DNS action the operator should take before verify-acme. dns01, _, err := generateChallengeTokens() @@ -341,8 +387,11 @@ func (s *RegistrationService) RegisterAgent(ctx context.Context, req RegisterReq }); err != nil { return err } - if err := s.certs.SaveCSR(txCtx, reg.AgentID, reg.IdentityCSR); err != nil { - return err + // Identity CSR is absent for §3.2.0 base-only registrations. + if reg.IdentityCSR != nil { + if err := s.certs.SaveCSR(txCtx, reg.AgentID, reg.IdentityCSR); err != nil { + return err + } } if reg.ServerCSR != nil { if err := s.certs.SaveCSR(txCtx, reg.AgentID, reg.ServerCSR); err != nil { diff --git a/internal/ra/service/registration_test.go b/internal/ra/service/registration_test.go index 5eadb75..e2ec58b 100644 --- a/internal/ra/service/registration_test.go +++ b/internal/ra/service/registration_test.go @@ -24,6 +24,117 @@ import ( "github.com/godaddy/ans/internal/ra/service" ) +// TestRegistration_AgentCardContent_HashStored exercises the §A.1 +// "hash and forget" flow: when the operator submits agentCardContent +// on the V2 registration request, the service computes +// SHA-256(JCS(content)), stores the hex-lowercase digest on the +// AgentRegistration aggregate, and the activation flow seals the +// digest into the AGENT_REGISTERED event under +// attestations.metadataHashes.capabilitiesHash. +// +// This test validates the Register-time half: the aggregate carries +// the right hash. The activation half (event seal) is covered by the +// lifecycle test below. +func TestRegistration_AgentCardContent_HashStored(t *testing.T) { + t.Parallel() + fx := newRegFixture(t) + + // Two semantically equal JSON bodies that JCS-canonicalize to + // identical bytes. Both must produce the same hash. + contentA := []byte(`{"ansName":"ans://v1.0.0.agent.example.com","version":"1.0.0"}`) + contentB := []byte(`{ "version" : "1.0.0", "ansName" : "ans://v1.0.0.agent.example.com" }`) + + req := fx.req + req.AgentCardContent = contentA + if _, err := fx.svc.RegisterAgent(context.Background(), req); err != nil { + t.Fatalf("RegisterAgent: %v", err) + } + + got, err := fx.agents.FindByAnsName(context.Background(), req.AnsName) + if err != nil { + t.Fatalf("FindByAnsName: %v", err) + } + if got.CapabilitiesHash == "" { + t.Fatal("CapabilitiesHash empty after registration with content") + } + if len(got.CapabilitiesHash) != 64 { + t.Errorf("CapabilitiesHash length: got %d want 64", len(got.CapabilitiesHash)) + } + + // JCS-equivalent bodies hash to the same digest. Re-register a + // second agent under a different name with semantically identical + // content and confirm the digests match. + semver, _ := domain.ParseSemVer("1.0.0") + ans2, _ := domain.NewAnsName(semver, "twin.example.com") + req2 := fx.req + req2.AnsName = ans2 + req2.IdentityCSRPEM = testCSR(t, ans2.String()) + req2.ServerCsrPEM = testServerCSR(t, ans2.FQDN()) + req2.Endpoints = []domain.AgentEndpoint{{ + Protocol: domain.Protocol("MCP"), + AgentURL: "https://twin.example.com/mcp", + Transports: []domain.Transport{domain.Transport("SSE")}, + }} + req2.AgentCardContent = contentB + if _, err := fx.svc.RegisterAgent(context.Background(), req2); err != nil { + t.Fatalf("RegisterAgent twin: %v", err) + } + twin, err := fx.agents.FindByAnsName(context.Background(), ans2) + if err != nil { + t.Fatalf("FindByAnsName twin: %v", err) + } + if twin.CapabilitiesHash != got.CapabilitiesHash { + t.Errorf("JCS-equivalent bodies hashed differently:\n A: %s\n B: %s", + got.CapabilitiesHash, twin.CapabilitiesHash) + } +} + +// TestRegistration_AgentCardContent_OmittedNoHash confirms the +// spec-conformant "no agentCardContent submitted" path: the +// CapabilitiesHash on the aggregate stays empty, and a downstream +// activation will omit the metadataHashes.capabilitiesHash key entirely. +func TestRegistration_AgentCardContent_OmittedNoHash(t *testing.T) { + t.Parallel() + fx := newRegFixture(t) + + if _, err := fx.svc.RegisterAgent(context.Background(), fx.req); err != nil { + t.Fatalf("RegisterAgent: %v", err) + } + got, err := fx.agents.FindByAnsName(context.Background(), fx.req.AnsName) + if err != nil { + t.Fatalf("FindByAnsName: %v", err) + } + if got.CapabilitiesHash != "" { + t.Errorf("CapabilitiesHash: want empty, got %q", got.CapabilitiesHash) + } +} + +// TestRegistration_AgentCardContent_InvalidJSON asserts the validation +// error class operators see when they submit malformed JSON. JCS +// canonicalization fails up front; the registration is rejected with +// a 422-style error, not silently stored without the hash. +func TestRegistration_AgentCardContent_InvalidJSON(t *testing.T) { + t.Parallel() + fx := newRegFixture(t) + + req := fx.req + req.AgentCardContent = []byte(`not actually json {{{`) + _, err := fx.svc.RegisterAgent(context.Background(), req) + if err == nil { + t.Fatal("RegisterAgent: want error, got nil") + } + var verr *domain.Error + if !errors.As(err, &verr) { + t.Fatalf("error type: want *domain.Error, got %T (%v)", err, err) + } + if !errors.Is(err, domain.ErrValidation) { + t.Errorf("expected validation error, got %v (cause: %v)", err, verr.Cause) + } + if verr.Code != "INVALID_AGENT_CARD_CONTENT" { + t.Errorf("code: got %q want INVALID_AGENT_CARD_CONTENT", verr.Code) + } +} + // TestRegistration_NoOutboxEmit pins the V1-aligned terminal-only // event model: POST /v2/ans/agents (like POST /v1/agents/register) // does NOT enqueue anything to the outbox. The single AGENT_REGISTERED @@ -415,3 +526,107 @@ func parseTestURI(t *testing.T, s string) []*url.URL { } return []*url.URL{u} } + +// TestVerifyACME_BaseOnly_NoIdentityCSRRequired pins Plan F #63: +// a base-only registration (no version, no Identity CSR) must +// progress through verify-acme without the MISSING_IDENTITY_CSR +// error that pre-#63 code raised. The lifecycle still advances to +// PENDING_DNS, just without the cert artifacts that the versioned +// path produces. The server CSR path is unchanged regardless. +// +// Plan G's non-FQDN anchors (DID, LEI) all rely on this path +// because NON_FQDN_REQUIRES_BASE_ONLY forces them base-only at +// registration time. Without #63 every DID and LEI registration +// would sit in PENDING_VALIDATION forever. +func TestVerifyACME_BaseOnly_NoIdentityCSRRequired(t *testing.T) { + t.Parallel() + fx := newRegFixture(t) + + // Build a base-only request: no version, no IdentityCSRPEM. + // AgentHost carries the FQDN identity in place of the ANSName. + baseReq := service.RegisterRequest{ + OwnerID: "owner-1", + AnsName: domain.AnsName{}, // zero — base-only signal + AgentHost: "base-only-test.example.com", + DisplayName: "BaseOnlyAgent", + Description: "verify-acme should succeed without IdentityCSR", + Endpoints: []domain.AgentEndpoint{{ + Protocol: domain.Protocol("MCP"), + AgentURL: "https://base-only-test.example.com/mcp", + Transports: []domain.Transport{domain.Transport("SSE")}, + }}, + ServerCsrPEM: testServerCSR(t, "base-only-test.example.com"), + // IdentityCSRPEM intentionally empty. + } + + resp, err := fx.svc.RegisterAgent(context.Background(), baseReq) + if err != nil { + t.Fatalf("base-only register: %v", err) + } + if resp.Registration.AgentID == "" { + t.Fatal("RegisterAgent returned empty AgentID") + } + if !resp.Registration.IsBaseOnly() { + t.Errorf("expected base-only, got AnsName=%q", resp.Registration.AnsName) + } + + // verify-acme: pre-#63 this returned MISSING_IDENTITY_CSR. + if _, err := fx.svc.VerifyACME(context.Background(), + resp.Registration.AgentID, service.VerifyInput{}); err != nil { + t.Fatalf("base-only verify-acme should succeed without identity CSR, got: %v", err) + } + + // Confirm the aggregate advanced to PENDING_DNS. + loaded, err := fx.agents.FindByAgentID(context.Background(), resp.Registration.AgentID) + if err != nil { + t.Fatalf("FindByAgentID: %v", err) + } + if loaded.Status != domain.StatusPendingDNS { + t.Errorf("Status = %q, want PENDING_DNS", loaded.Status) + } + // Confirm no identity cert was issued (the cert table is empty + // for this agent because base-only skips the issuance branch). + identityCerts, err := fx.certs.FindIdentityCertificatesByAgent(context.Background(), resp.Registration.AgentID) + if err != nil { + t.Fatalf("FindIdentityCertificatesByAgent: %v", err) + } + if len(identityCerts) != 0 { + t.Errorf("base-only should have no identity certs, got %d", len(identityCerts)) + } +} + +// TestVerifyACME_Versioned_StillSignsIdentityCSR confirms that the +// existing versioned path is unchanged: an Identity CSR submitted +// at registration is signed at verify-acme, the cert is persisted, +// and the aggregate advances to PENDING_DNS. Pins behavior so a +// future refactor of the base-only branch cannot accidentally +// regress the versioned flow. +func TestVerifyACME_Versioned_StillSignsIdentityCSR(t *testing.T) { + t.Parallel() + fx := newRegFixture(t) + + resp, err := fx.svc.RegisterAgent(context.Background(), fx.req) + if err != nil { + t.Fatalf("register: %v", err) + } + + if _, err := fx.svc.VerifyACME(context.Background(), + resp.Registration.AgentID, service.VerifyInput{}); err != nil { + t.Fatalf("verify-acme: %v", err) + } + + loaded, err := fx.agents.FindByAgentID(context.Background(), resp.Registration.AgentID) + if err != nil { + t.Fatalf("FindByAgentID: %v", err) + } + if loaded.Status != domain.StatusPendingDNS { + t.Errorf("Status = %q, want PENDING_DNS", loaded.Status) + } + identityCerts, err := fx.certs.FindIdentityCertificatesByAgent(context.Background(), resp.Registration.AgentID) + if err != nil { + t.Fatalf("FindIdentityCertificatesByAgent: %v", err) + } + if len(identityCerts) != 1 { + t.Errorf("versioned registration should have 1 identity cert after verify-acme, got %d", len(identityCerts)) + } +} diff --git a/internal/ra/service/renewal.go b/internal/ra/service/renewal.go index ff4cd20..d35f967 100644 --- a/internal/ra/service/renewal.go +++ b/internal/ra/service/renewal.go @@ -98,7 +98,7 @@ func (s *RegistrationService) SubmitServerCertRenewal( // Server CSRs must carry the agent FQDN as a DNS SAN — TLS // server-auth convention, distinct from the identity CSR's // URI SAN shape. - if err := s.validator.ValidateServerCSR(ctx, in.ServerCsrPEM, reg.AnsName.FQDN()); err != nil { + if err := s.validator.ValidateServerCSR(ctx, in.ServerCsrPEM, reg.FQDN()); err != nil { return nil, domain.NewValidationError("INVALID_SERVER_CSR", "Server CSR validation failed: "+err.Error()) } @@ -126,7 +126,7 @@ func (s *RegistrationService) SubmitServerCertRenewal( case byocSet: v, err := s.validator.ValidateServerCertificate(ctx, - in.ServerCertificatePEM, in.ServerCertificateChainPEM, reg.AnsName.FQDN()) + in.ServerCertificatePEM, in.ServerCertificateChainPEM, reg.FQDN()) if err != nil { return nil, domain.NewCertificateError( "INVALID_BYOC_CERT", @@ -290,13 +290,13 @@ func (s *RegistrationService) completeCSRRenewal(ctx context.Context, agentID st if err != nil { return err } - issued, err := s.serverCA.IssueServerCertificate(ctx, csr.CSRContent, reg.AnsName.FQDN()) + issued, err := s.serverCA.IssueServerCertificate(ctx, csr.CSRContent, reg.FQDN()) if err != nil { return domain.NewInternalError("SERVER_CERT_ISSUE_FAILED", "failed to issue server cert for renewal", err) } v, err := s.validator.ValidateServerCertificate(ctx, - issued.CertPEM, issued.ChainPEM, reg.AnsName.FQDN()) + issued.CertPEM, issued.ChainPEM, reg.FQDN()) if err != nil { return domain.NewInternalError("SERVER_CERT_SELFVERIFY_FAILED", "issued renewal cert failed self-validation", err) diff --git a/internal/tl/event/event.go b/internal/tl/event/event.go index 3e6473b..6da66b7 100644 --- a/internal/tl/event/event.go +++ b/internal/tl/event/event.go @@ -174,9 +174,39 @@ type Attestations struct { DNSRecordsProvisioned []DNSRecord `json:"dnsRecordsProvisioned,omitempty"` IdentityCerts []CertificateInfo `json:"identityCerts,omitempty"` ServerCerts []CertificateInfo `json:"serverCerts,omitempty"` - MetadataHashes map[string]string `json:"metadataHashes,omitempty"` + // MetadataHashes carries SHA-256 hex-lowercase digests of + // artifacts the operator submitted at registration time. The + // well-known map keys are reserved by ANS_SPEC.md and reused + // across this package via the constants below + // (MetadataHashKeyCapabilitiesHash, …). + // + // `omitempty` on the map makes the canonical envelope identical + // for events whose operator submitted no metadata and events + // whose map happens to be empty. Both serialize without the + // `metadataHashes` key, keeping leaf-hash stability across the + // presence/absence boundary. + MetadataHashes map[string]string `json:"metadataHashes,omitempty"` } +// Reserved keys for Attestations.MetadataHashes. Values stored in +// the map under these keys are hex-lowercase SHA-256 digests +// (64 lowercase hex chars). +const ( + // MetadataHashKeyCapabilitiesHash is the SHA-256(JCS(agentCardContent)) + // computed at activation per ANS_SPEC.md §A.1. Operators submit the + // ANS Trust Card body (§A.2 — the document hosted at + // /.well-known/ans/trust-card.json) on the V2 registration request + // as `agentCardContent`; the RA hashes it and seals the digest under + // this key. The AIM later verifies the live hosted Trust Card content + // against this value. + // + // Distinct from any per-endpoint metadataHash on AgentEndpoint, which + // hashes the protocol-native metadata (e.g., the A2A AgentCard at + // /.well-known/agent-card.json) rather than the agent-level + // ANS Trust Card. + MetadataHashKeyCapabilitiesHash = "capabilitiesHash" +) + // DNSRecord is one DNS record attesting the agent's *production* // DNS state at event time — the records an independent verifier // would see if they queried the authoritative nameserver after the diff --git a/internal/tl/event/event_test.go b/internal/tl/event/event_test.go index 8459906..44d4de9 100644 --- a/internal/tl/event/event_test.go +++ b/internal/tl/event/event_test.go @@ -462,6 +462,121 @@ func TestType_IsValid(t *testing.T) { } } +// TestAttestations_MetadataHashesCapabilitiesHash exercises the +// well-known `capabilitiesHash` key on the metadataHashes map. The +// canonical wire shape and leaf-hash stability across "field absent" +// vs "field present" are both load-bearing for AIM verification. +func TestAttestations_MetadataHashesCapabilitiesHash(t *testing.T) { + t.Parallel() + + hashHex := "9f3a2b1c4d5e6f7890abcdef0123456789abcdef0123456789abcdef01234567" + + tests := []struct { + name string + setHash bool + // substrings the canonical bytes MUST contain. + mustContain []string + // substrings the canonical bytes MUST NOT contain. + mustOmit []string + }{ + { + name: "absent_omits_metadataHashes_key", + setHash: false, + mustOmit: []string{ + "metadataHashes", + "capabilitiesHash", + }, + }, + { + name: "present_writes_capabilitiesHash_under_metadataHashes", + setHash: true, + mustContain: []string{ + `"metadataHashes":{`, + `"capabilitiesHash":"` + hashHex + `"`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + a := event.Attestations{ + DomainValidation: "ACME-DNS-01", + } + if tc.setHash { + a.MetadataHashes = map[string]string{ + event.MetadataHashKeyCapabilitiesHash: hashHex, + } + } + out, err := json.Marshal(a) + if err != nil { + t.Fatalf("marshal: %v", err) + } + s := string(out) + for _, want := range tc.mustContain { + if !strings.Contains(s, want) { + t.Errorf("missing %q in %s", want, s) + } + } + for _, bad := range tc.mustOmit { + if strings.Contains(s, bad) { + t.Errorf("unexpected %q in %s", bad, s) + } + } + }) + } +} + +// TestAttestations_LeafStabilityAcrossCapabilitiesHashAbsence pins the +// invariant that an envelope whose Attestations.MetadataHashes map is +// nil produces the same canonical bytes (and therefore the same leaf +// hash) as an envelope whose map is explicitly empty. The AIM relies +// on this stability when comparing badge responses across reads. +func TestAttestations_LeafStabilityAcrossCapabilitiesHashAbsence(t *testing.T) { + t.Parallel() + + mkAttestations := func(empty map[string]string) event.Attestations { + return event.Attestations{ + DomainValidation: "ACME-DNS-01", + MetadataHashes: empty, + } + } + withNil, err := json.Marshal(mkAttestations(nil)) + if err != nil { + t.Fatalf("marshal nil: %v", err) + } + withEmpty, err := json.Marshal(mkAttestations(map[string]string{})) + if err != nil { + t.Fatalf("marshal empty: %v", err) + } + if !bytes.Equal(withNil, withEmpty) { + t.Errorf("nil vs empty diverge:\nnil: %s\nempty: %s", + string(withNil), string(withEmpty)) + } + + // And the SHA-256 of the canonical bytes must match: this drives + // leaf-hash stability through the envelope's SigningInput. + if sha256.Sum256(withNil) != sha256.Sum256(withEmpty) { + t.Errorf("hashes diverge across nil/empty (defeats leaf stability)") + } +} + +// TestAttestations_CapabilitiesHashHexFormat documents the wire format +// the AIM expects: 64 lowercase hex chars produced by +// hex.EncodeToString(sha256(jcs(content))). Regressions on this format +// are observable as AIM verification failures even when the underlying +// hash is correct. +func TestAttestations_CapabilitiesHashHexFormat(t *testing.T) { + t.Parallel() + digest := sha256.Sum256([]byte(`{"ansName":"ans://v1.0.0.example.com"}`)) + got := hex.EncodeToString(digest[:]) + if len(got) != 64 { + t.Fatalf("hex digest must be 64 chars, got %d", len(got)) + } + if got != strings.ToLower(got) { + t.Errorf("hex digest must be lowercase, got %q", got) + } +} + // ----- helpers ----- var update = boolPtr(os.Getenv("UPDATE_GOLDEN") != "") diff --git a/spec/api-spec-tl-v2.yaml b/spec/api-spec-tl-v2.yaml index 6dfbd9a..ed84cff 100644 --- a/spec/api-spec-tl-v2.yaml +++ b/spec/api-spec-tl-v2.yaml @@ -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 diff --git a/spec/api-spec-v2.yaml b/spec/api-spec-v2.yaml index 4e3c49a..08e3284 100644 --- a/spec/api-spec-v2.yaml +++ b/spec/api-spec-v2.yaml @@ -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