From 36386ddbd0d9918c319b64caa628379e46d3c459 Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Fri, 15 May 2026 20:49:25 -0400 Subject: [PATCH 01/12] spec(v2): add agentCardContent on registration + capabilitiesHash on attestations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optional Registration Metadata path per ANS_SPEC.md §A.1: operators submit the ANS Trust Card body, the RA computes SHA-256(JCS(content)) at activation, and seals the hex-lowercase digest into the V2 AGENT_REGISTERED TL event under attestations.metadataHashes.capabilitiesHash. The AIM later verifies the live hosted Trust Card against the sealed hash. Reuses the existing metadataHashes map rather than introducing a new struct field, since the map already accommodates well-known hash keys. Spec-only change. Implementation lands in subsequent commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/adapter/docsui/openapi/ra.yaml | 30 +++++++++++++++++++++++++ internal/adapter/docsui/openapi/tl.yaml | 27 +++++++++++++++++++++- spec/api-spec-tl-v2.yaml | 27 +++++++++++++++++++++- spec/api-spec-v2.yaml | 30 +++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 2 deletions(-) diff --git a/internal/adapter/docsui/openapi/ra.yaml b/internal/adapter/docsui/openapi/ra.yaml index 4e3c49a..7212267 100644 --- a/internal/adapter/docsui/openapi/ra.yaml +++ b/internal/adapter/docsui/openapi/ra.yaml @@ -1065,6 +1065,36 @@ 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" 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/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..7212267 100644 --- a/spec/api-spec-v2.yaml +++ b/spec/api-spec-v2.yaml @@ -1065,6 +1065,36 @@ 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" required: - agentDisplayName - version From 60cdc8a26416586999d6b83d3119d58a808904c8 Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Fri, 15 May 2026 20:51:11 -0400 Subject: [PATCH 02/12] feat(tl/event): document capabilitiesHash as well-known metadataHashes key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reserve the Attestations.MetadataHashes["capabilitiesHash"] key for the SHA-256(JCS(agentCardContent)) hash sealed at activation per ANS_SPEC.md §A.1. Export MetadataHashKeyCapabilitiesHash so the RA service and AIM verifier reach for the same constant rather than string-literalling the key. Tests pin three invariants the AIM relies on: 1. The map omits the key when no agentCardContent was submitted. 2. nil and empty MetadataHashes produce identical canonical bytes (leaf-hash stability across the absence boundary). 3. The hex digest is lowercase 64-char. No envelope shape change. The map already existed; this commit adds a constant, documents the convention, and reads from the existing shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/tl/event/event.go | 32 ++++++++- internal/tl/event/event_test.go | 115 ++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 1 deletion(-) 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") != "") From f26d172721c2931e046efbda6eee563cd49499ae Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Fri, 15 May 2026 20:56:33 -0400 Subject: [PATCH 03/12] feat(ra): hash optional agentCardContent at registration, seal at activation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the §A.1 "hash and forget" Registration Metadata flow: 1. RegisterRequest carries optional AgentCardContent ([]byte) on the V2 path. The service JCS-canonicalizes (RFC 8785), SHA-256 hashes, and stores the hex-lowercase digest as AgentRegistration.CapabilitiesHash. The raw content is then discarded — only the digest persists. 2. Migration 006 adds the capabilities_hash column on agent_registrations (nullable for backwards compatibility and for the spec-conformant "no content submitted" path). 3. Activation reads reg.CapabilitiesHash and, when populated, writes it into the AGENT_REGISTERED event under attestations.metadataHashes.capabilitiesHash. Empty stays absent. The metadataHashes map is the right home for this digest: it already exists, already has omitempty semantics, and the well-known key constant lives next to its consumers in internal/tl/event. Validation: malformed JSON (JCS canonicalization fails) returns INVALID_AGENT_CARD_CONTENT rather than silently dropping the digest. Tests pin: hash stored, hash absent when content omitted, JCS-equivalent bodies hash identically across registrations, malformed JSON rejected. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/adapter/store/sqlite/agent.go | 10 +- .../006_agent_capabilities_hash.sql | 20 ++++ internal/domain/agent.go | 13 ++ internal/ra/service/helpers.go | 17 +++ internal/ra/service/lifecycle.go | 14 ++- internal/ra/service/registration.go | 28 +++++ internal/ra/service/registration_test.go | 111 ++++++++++++++++++ 7 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 internal/adapter/store/sqlite/migrations/006_agent_capabilities_hash.sql diff --git a/internal/adapter/store/sqlite/agent.go b/internal/adapter/store/sqlite/agent.go index a06be3f..16fa81b 100644 --- a/internal/adapter/store/sqlite/agent.go +++ b/internal/adapter/store/sqlite/agent.go @@ -39,6 +39,7 @@ 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"` CreatedAtMs int64 `db:"created_at_ms"` UpdatedAtMs int64 `db:"updated_at_ms"` } @@ -73,6 +74,9 @@ 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 + } return reg, nil } @@ -93,8 +97,9 @@ 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, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` res, err := s.db.extx(ctx).ExecContext(ctx, q, agent.AgentID, agent.OwnerID, @@ -109,6 +114,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) nullableInt64(agent.SupersedesRegistrationID), nullableString(agent.ACMEChallenge.DNS01Token), nullableMs(agent.ACMEChallenge.ExpiresAt), + nullableString(agent.CapabilitiesHash), now, now, ) if err != nil { @@ -131,6 +137,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) supersedes_registration_id = ?, acme_dns01_token = ?, acme_challenge_expires_at_ms = ?, + capabilities_hash = ?, updated_at_ms = ? WHERE id = ?` _, err := s.db.extx(ctx).ExecContext(ctx, q, @@ -141,6 +148,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) nullableInt64(agent.SupersedesRegistrationID), nullableString(agent.ACMEChallenge.DNS01Token), nullableMs(agent.ACMEChallenge.ExpiresAt), + nullableString(agent.CapabilitiesHash), now, agent.ID, ) 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/domain/agent.go b/internal/domain/agent.go index 71efd91..675df4f 100644 --- a/internal/domain/agent.go +++ b/internal/domain/agent.go @@ -103,6 +103,19 @@ 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"` + // PendingEvents holds domain events raised during this aggregate operation. // They are cleared after being published. PendingEvents []Event `json:"-"` diff --git a/internal/ra/service/helpers.go b/internal/ra/service/helpers.go index fda9cd6..b591d4e 100644 --- a/internal/ra/service/helpers.go +++ b/internal/ra/service/helpers.go @@ -8,9 +8,26 @@ import ( "fmt" "time" + anscrypto "github.com/godaddy/ans/internal/crypto" "github.com/godaddy/ans/internal/domain" ) +// 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 +} + // 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..2f34919 100644 --- a/internal/ra/service/lifecycle.go +++ b/internal/ra/service/lifecycle.go @@ -708,12 +708,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..001cea4 100644 --- a/internal/ra/service/registration.go +++ b/internal/ra/service/registration.go @@ -72,6 +72,20 @@ 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 } // RegisterResponse is returned to the HTTP handler after a successful @@ -310,6 +324,20 @@ func (s *RegistrationService) RegisterAgent(ctx context.Context, req RegisterReq } reg.ServerCSR = pendingServerCSR + // Hash the optional ANS Trust Card body if the operator submitted + // one (ANS_SPEC.md §A.1). The hash is the durable record; the + // content itself is discarded immediately after canonicalization. + if len(req.AgentCardContent) > 0 { + hashHex, err := hashAgentCardContent(req.AgentCardContent) + if err != nil { + return nil, domain.NewValidationError( + "INVALID_AGENT_CARD_CONTENT", + fmt.Sprintf("agentCardContent could not be canonicalized: %v", err), + ) + } + reg.CapabilitiesHash = hashHex + } + // Generate the ACME DNS-01 challenge token + expiry. The only // DNS action the operator should take before verify-acme. dns01, _, err := generateChallengeTokens() diff --git a/internal/ra/service/registration_test.go b/internal/ra/service/registration_test.go index 5eadb75..989d033 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 From e2f89f6bc343e3cd81e191746780959f4ffe65c4 Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Fri, 15 May 2026 20:59:17 -0400 Subject: [PATCH 04/12] feat(ra/handler): plumb agentCardContent through V2 registration handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the optional agentCardContent field to the V2 registrationRequest DTO and forwards the raw bytes through to service.RegisterRequest. Modeled as json.RawMessage so the operator-submitted bytes reach the JCS canonicalizer without an intermediate map[string]any round-trip that could shift the digest. Tests cover: 1. Field plumbed end-to-end (POST 202 → aggregate carries hash). 2. Field omitted (CapabilitiesHash empty, no metadataHashes entry at activation). 3. Malformed body returns 422 BAD_JSON without reaching the service. The fixture exposes the agents store + context.Background() so handler tests can assert on the persisted aggregate without standing up a parallel verify-acme/verify-dns flow. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/ra/handler/lifecycle_test.go | 10 +- internal/ra/handler/registration.go | 9 ++ .../ra/handler/registration_errors_test.go | 113 ++++++++++++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) 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..cf7a2f1 100644 --- a/internal/ra/handler/registration.go +++ b/internal/ra/handler/registration.go @@ -39,6 +39,14 @@ type registrationRequest struct { 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"` } type endpointDTO struct { @@ -153,6 +161,7 @@ func (h *RegistrationHandler) Register(w http.ResponseWriter, r *http.Request) { ServerCsrPEM: req.ServerCsrPEM, ServerCertificatePEM: req.ServerCertificatePEM, ServerCertificateChainPEM: req.ServerCertificateChainPEM, + AgentCardContent: []byte(req.AgentCardContent), }) if err != nil { WriteError(w, err) 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) From 25dcefcc12d6cbeeabe543a8beec08059a7ec129 Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Fri, 15 May 2026 21:03:19 -0400 Subject: [PATCH 05/12] test(ra): e2e seal of capabilitiesHash from register through AGENT_REGISTERED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives the full §A.1 flow against the in-memory fixture: register-with-agentCardContent → verify-acme → verify-dns → claim the AGENT_REGISTERED outbox row → assert innerEventCanonical.attestations.metadataHashes.capabilitiesHash equals SHA-256(JCS(agentCardContent)) computed independently by the test. Also extracts the hash-and-store logic into applyAgentCardContentHash so RegisterAgent stays under the funlen 130-line ceiling (the new helper + the existing hashAgentCardContent live in helpers.go). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../registration_capabilitieshash_e2e_test.go | 130 ++++++++++++++++++ internal/ra/service/helpers.go | 24 ++++ internal/ra/service/registration.go | 14 +- 3 files changed, 156 insertions(+), 12 deletions(-) create mode 100644 internal/ra/handler/registration_capabilitieshash_e2e_test.go 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/service/helpers.go b/internal/ra/service/helpers.go index b591d4e..277187a 100644 --- a/internal/ra/service/helpers.go +++ b/internal/ra/service/helpers.go @@ -28,6 +28,30 @@ func hashAgentCardContent(content []byte) (string, error) { return hex.EncodeToString(sum[:]), 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/registration.go b/internal/ra/service/registration.go index 001cea4..6171a68 100644 --- a/internal/ra/service/registration.go +++ b/internal/ra/service/registration.go @@ -324,18 +324,8 @@ func (s *RegistrationService) RegisterAgent(ctx context.Context, req RegisterReq } reg.ServerCSR = pendingServerCSR - // Hash the optional ANS Trust Card body if the operator submitted - // one (ANS_SPEC.md §A.1). The hash is the durable record; the - // content itself is discarded immediately after canonicalization. - if len(req.AgentCardContent) > 0 { - hashHex, err := hashAgentCardContent(req.AgentCardContent) - if err != nil { - return nil, domain.NewValidationError( - "INVALID_AGENT_CARD_CONTENT", - fmt.Sprintf("agentCardContent could not be canonicalized: %v", err), - ) - } - reg.CapabilitiesHash = hashHex + if err := applyAgentCardContentHash(reg, req.AgentCardContent); err != nil { + return nil, err } // Generate the ACME DNS-01 challenge token + expiry. The only From b3e7adfd8cdde1a2eca149230c475afae02b256b Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Sat, 16 May 2026 10:10:09 -0400 Subject: [PATCH 06/12] feat(dns): emit + verify Consolidated Approach SVCB records Extends domain.ComputeRequiredDNSRecords to emit one SVCB record per protocol at the agent bare FQDN, alongside the existing _ans TXT family. The SVCB row carries: alpn=PROTOCOL from endpoint.Protocol port=443 ServiceMode SvcPriority 1 at the FQDN wk=SUFFIX A2A: agent-card.json; MCP: mcp.json card-sha256=BASE64URL base64url of reg.CapabilitiesHash when set card-sha256 and capabilities_hash are the section 4.4.2 cross-check encodings of the same SHA-256 (DNS uses base64url, TL uses hex). When the operator did not submit agentCardContent, the SvcParam is absent and verifiers fall back to TOFU on first Trust Card fetch. Adds verifySVCB to LookupVerifier mirroring verifyHTTPS. Tests cover present-matching, absent (zone has different name), and wrong-target cases (AliasMode where ServiceMode was expected). Provisional SvcParams (wk, card-sha256) are unit-tested at the domain layer because miekg/dns rejects them in zone-file form until IANA registration; the verifier- level test exercises only registered SvcParamKeys (alpn, port). Required=false: section 4.4.2 marks Consolidated Approach SVCB as MAY, opt-in during the _ans TXT transition. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/adapter/dns/dns_test.go | 80 +++++++++++++ internal/adapter/dns/lookup.go | 48 ++++++++ internal/domain/dnsrecords.go | 105 ++++++++++++++++- internal/domain/dnsrecords_test.go | 173 ++++++++++++++++++++++++++--- 4 files changed, 392 insertions(+), 14 deletions(-) diff --git a/internal/adapter/dns/dns_test.go b/internal/adapter/dns/dns_test.go index 0cb0347..7193388 100644 --- a/internal/adapter/dns/dns_test.go +++ b/internal/adapter/dns/dns_test.go @@ -252,6 +252,86 @@ func TestLookupVerifier_HTTPSMatch(t *testing.T) { } } +// TestLookupVerifier_SVCBMatch covers the Consolidated Approach SVCB +// record at the bare agent FQDN. The expected value is the same +// presentation form the RA's ComputeRequiredDNSRecords emits (see +// internal/domain/dnsrecords.go), and the verifier matches after +// whitespace normalization mirroring verifyHTTPS. +// +// Restricted to IANA-registered SvcParamKeys (alpn + port) because the +// miekg/dns zone-file parser used by the test fixture rejects symbolic +// names for the still-provisional Consolidated Approach SvcParams (`wk`, +// `card-sha256`, `cap`, etc.). Until those keys are IANA-registered per +// RFC 9460 §6, the test exercises the verifier dispatch and matching +// path with registered keys; the unregistered keys are unit-tested at +// the domain layer (internal/domain/dnsrecords_test.go). +func TestLookupVerifier_SVCBMatch(t *testing.T) { + t.Parallel() + s := newTestServer(t) + s.add("agent.example.com.", "SVCB", + `agent.example.com. 3600 IN SVCB 1 . alpn=a2a port=443`) + + recs := []domain.ExpectedDNSRecord{{ + Name: "agent.example.com", + Type: domain.DNSRecordSVCB, + Value: `1 . alpn=a2a port=443`, + Required: false, + }} + got := s.verifyAgainst(t, recs) + if !got[0].found { + t.Errorf("SVCB should match; got=%+v", got[0]) + } +} + +// TestLookupVerifier_SVCBMissing covers the absent-record path. The +// agent's zone never published the SVCB record (or it was removed). +// Verifier reports not-found without an error (NXDOMAIN-style empty +// answer). +func TestLookupVerifier_SVCBMissing(t *testing.T) { + t.Parallel() + s := newTestServer(t) + // Different name in the zone — query for the agent's FQDN returns + // no SVCB answers. + s.add("other.example.com.", "SVCB", + `other.example.com. 3600 IN SVCB 1 . alpn=a2a`) + + recs := []domain.ExpectedDNSRecord{{ + Name: "agent.example.com", + Type: domain.DNSRecordSVCB, + Value: `1 . alpn=a2a`, + Required: false, + }} + got := s.verifyAgainst(t, recs) + if got[0].found { + t.Error("SVCB must not be Found when the zone has no matching record") + } +} + +// TestLookupVerifier_SVCBWrongTargetMissesMatch confirms that a record +// with the right alpn but a different SvcPriority/TargetName does not +// satisfy the expectation. Matching is on the full normalized +// presentation form, so a TargetName mismatch fails the comparison. +func TestLookupVerifier_SVCBWrongTargetMissesMatch(t *testing.T) { + t.Parallel() + s := newTestServer(t) + // AliasMode (priority 0) at agent.example.com pointing at a + // hosting target — different shape than what the RA expects in + // ServiceMode (priority 1). + s.add("agent.example.com.", "SVCB", + `agent.example.com. 3600 IN SVCB 0 host.provider.example.`) + + recs := []domain.ExpectedDNSRecord{{ + Name: "agent.example.com", + Type: domain.DNSRecordSVCB, + Value: `1 . alpn=a2a`, + Required: false, + }} + got := s.verifyAgainst(t, recs) + if got[0].found { + t.Error("ServiceMode expectation should not match an AliasMode record") + } +} + func TestLookupVerifier_NXDOMAINSurfacedAsError(t *testing.T) { t.Parallel() s := newTestServer(t) diff --git a/internal/adapter/dns/lookup.go b/internal/adapter/dns/lookup.go index ed5cc33..2c695d7 100644 --- a/internal/adapter/dns/lookup.go +++ b/internal/adapter/dns/lookup.go @@ -86,6 +86,8 @@ func (v *LookupVerifier) VerifyRecords( r = v.verifyTLSA(lookupCtx, server, rec) case domain.DNSRecordHTTPS: r = v.verifyHTTPS(lookupCtx, server, rec) + case domain.DNSRecordSVCB: + r = v.verifySVCB(lookupCtx, server, rec) default: r.Error = fmt.Sprintf("unsupported record type: %s", rec.Type) } @@ -253,6 +255,52 @@ func formatHTTPSValue(s *dns.SVCB) string { return sb.String() } +// verifySVCB checks for a Consolidated Approach SVCB record (RFC 9460) +// at the agent's bare FQDN. Multiple SVCB records can share one RRset +// name distinguished by alpn, so verification iterates the answer +// section, normalizes each record's wire form, and matches against +// the expected SvcParams. The matching strategy mirrors verifyHTTPS: +// the expected value carries every SvcParam the RA computed (alpn, +// port, wk, card-sha256), and the live record MUST carry the same +// SvcParams in the same alpn-keyed form. +// +// SvcParam unknown-key ignore semantics (RFC 9460 §8) apply at the +// client, not at this verifier — we only check that the SvcParams +// the RA committed are present, not that the live record is free of +// extra SvcParams from other ecosystems. Other agentic specs adding +// their own SvcParams alongside ours is the entire point of the +// Consolidated Approach. +func (v *LookupVerifier) verifySVCB(ctx context.Context, server string, rec domain.ExpectedDNSRecord) port.RecordVerification { + r := port.RecordVerification{Record: rec} + resp, err := v.exchange(ctx, server, rec.Name, dns.TypeSVCB) + if err != nil { + r.Error = err.Error() + return r + } + if resp.Rcode != dns.RcodeSuccess { + r.Error = fmt.Sprintf("rcode %s", dns.RcodeToString[resp.Rcode]) + return r + } + r.DNSSECVerified = resp.AuthenticatedData + wantNorm := normalizeHTTPS(rec.Value) + for _, rr := range resp.Answer { + svcb, ok := rr.(*dns.SVCB) + if !ok { + continue + } + got := formatHTTPSValue(svcb) + if r.Actual == "" { + r.Actual = got + } + if normalizeHTTPS(got) == wantNorm { + r.Found = true + r.Actual = got + return r + } + } + return r +} + // normalizeTLSA collapses whitespace and lowercases the hex so // "3 1 1 abcd..." matches "3 1 1 ABCD...". func normalizeTLSA(s string) string { diff --git a/internal/domain/dnsrecords.go b/internal/domain/dnsrecords.go index e1d1794..011a58d 100644 --- a/internal/domain/dnsrecords.go +++ b/internal/domain/dnsrecords.go @@ -1,6 +1,10 @@ package domain -import "fmt" +import ( + "encoding/base64" + "encoding/hex" + "fmt" +) // DNSRecordType represents a DNS record type. type DNSRecordType string @@ -9,6 +13,14 @@ const ( DNSRecordTXT DNSRecordType = "TXT" DNSRecordTLSA DNSRecordType = "TLSA" DNSRecordHTTPS DNSRecordType = "HTTPS" + // DNSRecordSVCB is the cross-draft "Consolidated Approach" service + // binding record (RFC 9460) emitted at the agent's bare FQDN. One + // SVCB record per protocol carries that protocol's connection hints + // and capability locators in a single DNS lookup. SvcParams from + // DNS-AID, ANS, and other agentic specs coexist in the same record + // per RFC 9460 §8 unknown-key ignore semantics. See ANS_SPEC.md + // §4.4.2 in github.com/gdcorp-engineering/ans-registry-poc. + DNSRecordSVCB DNSRecordType = "SVCB" ) // DNSRecordPurpose describes why a DNS record is needed. @@ -58,6 +70,56 @@ func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { }) } + // Consolidated Approach SVCB record at the bare FQDN — one per + // protocol endpoint. RFC 9460 ServiceMode (SvcPriority 1) with + // TargetName "." (same name) so address resolution stays at the + // agent's FQDN. SvcParams from DNS-AID, ANS, and other agentic + // specs coexist via RFC 9460 §8 unknown-key ignore. card-sha256 + // carries base64url(reg.CapabilitiesHash) when the operator + // submitted agentCardContent; otherwise the SvcParam is absent + // and a verifier falls back to TOFU on first Trust Card fetch. + // + // Provisional-key note: `wk` and `card-sha256` are not yet + // IANA-registered SvcParamKeys per RFC 9460 §6. The Consolidated + // Approach draft emits them by symbolic name; production + // deployments using strict-RFC parsers MAY need to publish them + // in keyNNNNN form until registration completes. The expected + // value the RA writes here uses the symbolic form to match the + // draft's worked examples; the verifier compares post- + // normalization, and operators whose authoritative DNS only + // emits keyNNNNN form will see a mismatch the RA reports as a + // non-blocking integrity finding (Required=false below). + // + // Required=false: §4.4.2 marks the Consolidated Approach as MAY, + // opt-in alongside the `_ans` TXT family during the transition. + cardSHA := capabilitiesHashBase64URL(reg.CapabilitiesHash) + for _, ep := range reg.Endpoints { + alpn := protocolToANSValue(ep.Protocol) + wk := wkPathFor(ep.Protocol) + // RFC 9460 §2.1 presentation form: unquoted SvcParamValue when + // the value has no characters special to the presentation + // format. alpn tokens (a2a, mcp), port digits, well-known path + // suffixes (agent-card.json), and base64url digests all qualify. + // The resolver-side formatter (formatHTTPSValue) also emits + // unquoted, so the verifier's normalize+compare matches without + // quote-stripping. + value := fmt.Sprintf(`1 . alpn=%s port=443`, alpn) + if wk != "" { + value += fmt.Sprintf(` wk=%s`, wk) + } + if cardSHA != "" { + value += fmt.Sprintf(` card-sha256=%s`, cardSHA) + } + records = append(records, ExpectedDNSRecord{ + Name: fqdn, + Type: DNSRecordSVCB, + Value: value, + Purpose: PurposeDiscovery, + Required: false, + TTL: 3600, + }) + } + // _ans-badge TXT record — trust badge. Required alongside _ans: // resolvers and badge-verifying clients expect to find both, and // publishing _ans without _ans-badge would advertise an agent @@ -118,3 +180,44 @@ func protocolToANSValue(p Protocol) string { return string(p) } } + +// wkPathFor returns the suffix-only well-known path published in the +// Consolidated Approach SVCB record's `wk=` SvcParam. Suffix-only matches +// the consolidated-draft examples (§4 line 134); clients prepend +// `/.well-known/` to construct the full path. Empty result means the +// caller SHOULD omit `wk=` entirely (e.g., direct-mode agents that +// expose no canonical metadata file). +// +// A2A: `agent-card.json` (IANA-registered well-known per A2A spec). +// MCP: `mcp.json` (de-facto convention; see SEP-1649 progress). +// HTTP-API: empty (no per-protocol metadata file convention). +func wkPathFor(p Protocol) string { + switch p { + case ProtocolA2A: + return "agent-card.json" + case ProtocolMCP: + return "mcp.json" + default: + return "" + } +} + +// capabilitiesHashBase64URL re-encodes a hex-lowercase SHA-256 digest +// (the form `AgentRegistration.CapabilitiesHash` carries) into the +// base64url form (RFC 4648 §5, no padding) the SVCB `card-sha256` +// SvcParam expects. Empty input returns empty output, which the caller +// SHOULD treat as "omit the SvcParam entirely" — agents registered +// without `agentCardContent` have no committed value to publish. +func capabilitiesHashBase64URL(hexDigest string) string { + if hexDigest == "" { + return "" + } + raw, err := hex.DecodeString(hexDigest) + if err != nil || len(raw) == 0 { + // Malformed input is logically equivalent to absence; the RA + // stores well-formed hex by construction (helpers.go: + // hashAgentCardContent), but defensive on the boundary. + return "" + } + return base64.RawURLEncoding.EncodeToString(raw) +} diff --git a/internal/domain/dnsrecords_test.go b/internal/domain/dnsrecords_test.go index 9929dec..100e64d 100644 --- a/internal/domain/dnsrecords_test.go +++ b/internal/domain/dnsrecords_test.go @@ -21,21 +21,37 @@ func TestComputeRequiredDNSRecords_WithoutCert(t *testing.T) { records := ComputeRequiredDNSRecords(reg) require.NotEmpty(t, records) - // 2 endpoints → 2 _ans TXT records + 1 badge record. - var anxCount, badgeCount, tlsaCount int + // 2 endpoints → 2 _ans TXT + 2 Consolidated Approach SVCB + + // 1 badge TXT (no TLSA: no cert). + var ansTxtCount, svcbCount, badgeCount, tlsaCount int for _, r := range records { switch r.Purpose { case PurposeDiscovery: - anxCount++ - assert.Equal(t, DNSRecordTXT, r.Type) - assert.True(t, strings.HasPrefix(r.Name, "_ans.")) - assert.True(t, r.Required) - assert.Contains(t, r.Value, "v=ans1") - // Version is bare semver, not DNS-label form — TXT - // payloads carry the machine-parseable semver directly. - assert.Contains(t, r.Value, "version=1.2.3") - assert.NotContains(t, r.Value, "v1.2.3", "no v-prefix in TXT payload") - assert.NotContains(t, r.Value, "1-2-3", "no dash form anywhere") + switch r.Type { + case DNSRecordTXT: + ansTxtCount++ + assert.True(t, strings.HasPrefix(r.Name, "_ans.")) + assert.True(t, r.Required) + assert.Contains(t, r.Value, "v=ans1") + // Version is bare semver, not DNS-label form — TXT + // payloads carry the machine-parseable semver directly. + assert.Contains(t, r.Value, "version=1.2.3") + assert.NotContains(t, r.Value, "v1.2.3", "no v-prefix in TXT payload") + assert.NotContains(t, r.Value, "1-2-3", "no dash form anywhere") + case DNSRecordSVCB: + svcbCount++ + assert.Equal(t, "agent.example.com", r.Name, + "Consolidated Approach SVCB at the bare FQDN, not at _ans.{fqdn}") + assert.False(t, r.Required, "Consolidated Approach SVCB is MAY per §4.4.2") + assert.Contains(t, r.Value, `1 . `, "ServiceMode (priority 1) with TargetName .") + assert.Contains(t, r.Value, "alpn=", "alpn distinguishes protocols within the RRset") + assert.Contains(t, r.Value, "port=443") + // No agentCardContent submitted in this fixture, so + // card-sha256 should be absent. + assert.NotContains(t, r.Value, "card-sha256") + default: + t.Errorf("unexpected discovery record type %q", r.Type) + } case PurposeBadge: badgeCount++ assert.Equal(t, DNSRecordTXT, r.Type) @@ -48,11 +64,142 @@ func TestComputeRequiredDNSRecords_WithoutCert(t *testing.T) { } } - assert.Equal(t, 2, anxCount) + assert.Equal(t, 2, ansTxtCount) + assert.Equal(t, 2, svcbCount, "one SVCB row per protocol at the bare FQDN") assert.Equal(t, 1, badgeCount) assert.Equal(t, 0, tlsaCount, "no cert → no TLSA record") } +// TestComputeRequiredDNSRecords_SVCBWkPath pins the per-protocol `wk=` +// SvcParam value the Consolidated Approach SVCB carries. A2A maps to +// `agent-card.json` (IANA-registered); MCP maps to `mcp.json` (de-facto +// convention). Suffix-only — the consolidated draft's primary examples +// use the suffix and clients prepend `/.well-known/`. +func TestComputeRequiredDNSRecords_SVCBWkPath(t *testing.T) { + ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") + reg := &AgentRegistration{ + AnsName: ansName, + Endpoints: []AgentEndpoint{ + {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com"}, + {Protocol: ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, + }, + } + records := ComputeRequiredDNSRecords(reg) + + for _, r := range records { + if r.Type != DNSRecordSVCB { + continue + } + switch { + case strings.Contains(r.Value, `alpn=a2a`): + assert.Contains(t, r.Value, `wk=agent-card.json`) + case strings.Contains(r.Value, `alpn=mcp`): + assert.Contains(t, r.Value, `wk=mcp.json`) + default: + t.Errorf("SVCB row missing recognized alpn: %q", r.Value) + } + } +} + +// TestComputeRequiredDNSRecords_SVCBCardSHA256_PresentWhenSet verifies +// that an agent registered with agentCardContent emits SVCB rows whose +// card-sha256 SvcParam is the base64url form of reg.CapabilitiesHash. +// This is the DNS half of §4.4.2's three-way cross-check (the live +// Trust Card body, the TL-sealed capabilities_hash, and the SVCB +// card-sha256 all commit to the same SHA-256). +func TestComputeRequiredDNSRecords_SVCBCardSHA256_PresentWhenSet(t *testing.T) { + ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") + // Fixture digest used across the cross-check — the same hex appears + // in the TL event's attestations.metadataHashes.capabilitiesHash. + hexDigest := "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27" + wantBase64 := "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc" + reg := &AgentRegistration{ + AnsName: ansName, + CapabilitiesHash: hexDigest, + Endpoints: []AgentEndpoint{ + {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com"}, + }, + } + records := ComputeRequiredDNSRecords(reg) + + var sawSVCB bool + for _, r := range records { + if r.Type != DNSRecordSVCB { + continue + } + sawSVCB = true + assert.Contains(t, r.Value, `card-sha256=`+wantBase64, + "SVCB card-sha256 must be base64url(decoded hex of reg.CapabilitiesHash)") + } + assert.True(t, sawSVCB, "expected at least one SVCB row") +} + +// TestComputeRequiredDNSRecords_SVCBCardSHA256_AbsentWhenUnset verifies +// the spec-conformant "no agentCardContent submitted" path: the SVCB +// row omits the card-sha256 SvcParam entirely. A verifier seeing no +// SvcParam falls back to TOFU on first Trust Card fetch (§4.4.2). +func TestComputeRequiredDNSRecords_SVCBCardSHA256_AbsentWhenUnset(t *testing.T) { + ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") + reg := &AgentRegistration{ + AnsName: ansName, + Endpoints: []AgentEndpoint{ + {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com"}, + }, + } + records := ComputeRequiredDNSRecords(reg) + for _, r := range records { + if r.Type == DNSRecordSVCB { + assert.NotContains(t, r.Value, "card-sha256", + "no agentCardContent → SVCB has no card-sha256 SvcParam") + } + } +} + +// TestCapabilitiesHashBase64URL pins the hex→base64url conversion. +func TestCapabilitiesHashBase64URL(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "live_webmesh_trust_card_digest", + in: "098d650cc6d280dee4c0f47489a75cf17b9bfbbae53051806d4e084108b2ff27", + want: "CY1lDMbSgN7kwPR0iadc8Xub-7rlMFGAbU4IQQiy_yc", + }, + { + name: "all_zeros", + in: "0000000000000000000000000000000000000000000000000000000000000000", + want: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }, + { + name: "empty_input_empty_output", + in: "", + want: "", + }, + { + name: "malformed_hex_returns_empty", + in: "not hex", + want: "", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := capabilitiesHashBase64URL(tc.in) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestWkPathFor pins the per-protocol well-known suffix mapping. +func TestWkPathFor(t *testing.T) { + assert.Equal(t, "agent-card.json", wkPathFor(ProtocolA2A)) + assert.Equal(t, "mcp.json", wkPathFor(ProtocolMCP)) + assert.Equal(t, "", wkPathFor(ProtocolHTTPAPI), + "HTTP-API has no per-protocol metadata file convention") + assert.Equal(t, "", wkPathFor(Protocol("UNKNOWN"))) +} + func TestComputeRequiredDNSRecords_WithCert(t *testing.T) { ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") reg := &AgentRegistration{ From c676530e3d13789c4dd734070f85a46dbb235a2f Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Sat, 16 May 2026 10:35:58 -0400 Subject: [PATCH 07/12] feat(ra): dnsRecordStyle on V2 register controls DNS-record family Adds dnsRecordStyle to the V2 RegistrationRequest with three values: "consolidated" (default, recommended), "legacy" (original _ans TXT shape), "both" (transition union). Empty -> consolidated. Invalid -> 422 INVALID_DNS_RECORD_STYLE. The default points new integrations at the lean Consolidated Approach shape per section 4.4.2 SHOULD: one SVCB record at the bare FQDN per protocol, plus shared _ans-prefixed records and TLSA. Operators on existing zone-edit tooling for _ans TXT pick "legacy" explicitly. Migration operators set "both" for a defined window then flip back to "consolidated". V1 lane pins to "legacy" regardless of the request because V1 callers predate the Consolidated Approach and their tooling expects the original shape. V1 has no dnsRecordStyle field on the wire. Migration 007 adds the dns_record_style column on agent_registrations. Nullable for backwards compatibility with pre-Plan-D rows. Tests: - "both" emits 2x _ans TXT + 2x SVCB + shared records (existing test updated to set DNSRecordStyleBoth so it exercises the union path). - New tests cover "consolidated" (no _ans TXT), "legacy" (no SVCB), and "both" (union); the SvcParam wk/card-sha256 tests already covered the consolidated path implicitly. - Lint: extracted applyDNSRecordStyle helper to keep RegisterAgent under the funlen ceiling. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/adapter/docsui/openapi/ra.yaml | 22 +++ internal/adapter/store/sqlite/agent.go | 10 +- .../migrations/007_agent_dns_record_style.sql | 21 +++ internal/domain/agent.go | 7 + internal/domain/dnsrecords.go | 153 +++++++++++++----- internal/domain/dnsrecords_test.go | 4 + internal/ra/handler/registration.go | 9 ++ internal/ra/service/helpers.go | 27 ++++ internal/ra/service/registration.go | 12 ++ spec/api-spec-v2.yaml | 22 +++ 10 files changed, 249 insertions(+), 38 deletions(-) create mode 100644 internal/adapter/store/sqlite/migrations/007_agent_dns_record_style.sql diff --git a/internal/adapter/docsui/openapi/ra.yaml b/internal/adapter/docsui/openapi/ra.yaml index 7212267..08e3284 100644 --- a/internal/adapter/docsui/openapi/ra.yaml +++ b/internal/adapter/docsui/openapi/ra.yaml @@ -1095,6 +1095,28 @@ components: - protocol: "A2A" agentUrl: "https://support.example.com" metadataUrl: "https://support.example.com/.well-known/agent-card.json" + dnsRecordStyle: + type: string + enum: [consolidated, legacy, both] + description: | + Selects which DNS record family the RA emits for this + registration. Surfaces on the 202 register response's + dnsRecords[], on GET /v2/ans/agents/{agentId}, and on the + AGENT_REGISTERED TL event's + attestations.dnsRecordsProvisioned[]. + + consolidated (default, recommended): Consolidated + Approach SVCB rows at the bare FQDN per ANS_SPEC.md + §4.4.2, plus shared `_ans-`-prefixed records and TLSA. + legacy: original `_ans` TXT shape, supported + indefinitely for operators on existing zone-edit + tooling that targets `_ans.{fqdn}`. + both: union; the §4.4.2 transition shape. + + Empty/missing → consolidated. Default points new + integrations at the lean shape per §4.4.2 SHOULD. + default: "consolidated" + example: "consolidated" required: - agentDisplayName - version diff --git a/internal/adapter/store/sqlite/agent.go b/internal/adapter/store/sqlite/agent.go index 16fa81b..99aad57 100644 --- a/internal/adapter/store/sqlite/agent.go +++ b/internal/adapter/store/sqlite/agent.go @@ -40,6 +40,7 @@ type agentRow struct { ACMEDNS01Token sql.NullString `db:"acme_dns01_token"` ACMEChallengeExpiresAtMs sql.NullInt64 `db:"acme_challenge_expires_at_ms"` CapabilitiesHash sql.NullString `db:"capabilities_hash"` + DNSRecordStyle sql.NullString `db:"dns_record_style"` CreatedAtMs int64 `db:"created_at_ms"` UpdatedAtMs int64 `db:"updated_at_ms"` } @@ -77,6 +78,9 @@ func (r agentRow) toDomain() (*domain.AgentRegistration, error) { if r.CapabilitiesHash.Valid { reg.CapabilitiesHash = r.CapabilitiesHash.String } + if r.DNSRecordStyle.Valid { + reg.DNSRecordStyle = domain.DNSRecordStyle(r.DNSRecordStyle.String) + } return reg, nil } @@ -98,8 +102,9 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) supersedes_registration_id, acme_dns01_token, acme_challenge_expires_at_ms, capabilities_hash, + dns_record_style, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` res, err := s.db.extx(ctx).ExecContext(ctx, q, agent.AgentID, agent.OwnerID, @@ -115,6 +120,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) nullableString(agent.ACMEChallenge.DNS01Token), nullableMs(agent.ACMEChallenge.ExpiresAt), nullableString(agent.CapabilitiesHash), + nullableString(string(agent.DNSRecordStyle)), now, now, ) if err != nil { @@ -138,6 +144,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) acme_dns01_token = ?, acme_challenge_expires_at_ms = ?, capabilities_hash = ?, + dns_record_style = ?, updated_at_ms = ? WHERE id = ?` _, err := s.db.extx(ctx).ExecContext(ctx, q, @@ -149,6 +156,7 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) nullableString(agent.ACMEChallenge.DNS01Token), nullableMs(agent.ACMEChallenge.ExpiresAt), nullableString(agent.CapabilitiesHash), + nullableString(string(agent.DNSRecordStyle)), now, agent.ID, ) diff --git a/internal/adapter/store/sqlite/migrations/007_agent_dns_record_style.sql b/internal/adapter/store/sqlite/migrations/007_agent_dns_record_style.sql new file mode 100644 index 0000000..422b86c --- /dev/null +++ b/internal/adapter/store/sqlite/migrations/007_agent_dns_record_style.sql @@ -0,0 +1,21 @@ +-- 007_agent_dns_record_style.sql +-- Persist the operator's chosen DNS-record-style on the registration +-- row so the verify-acme/verify-dns flow and the badge response carry +-- the same shape the operator chose at registration time. +-- +-- One of: +-- "consolidated" — Consolidated Approach SVCB rows + shared records +-- (default; recommended; aligned with §4.4.2). +-- "legacy" — original `_ans` TXT shape + shared records. +-- Backwards-compatible with operators registered +-- before the Consolidated Approach landed. +-- "both" — union; the §4.4.2 transition shape for operators +-- running both record families during migration. +-- +-- Nullable for backwards compatibility with agents registered before +-- this migration. The domain helper ComputeRequiredDNSRecords treats +-- empty value as the default ("consolidated") via DefaultDNSRecordStyle, +-- so old agents do not lose attestation behavior. + +ALTER TABLE agent_registrations + ADD COLUMN dns_record_style TEXT; diff --git a/internal/domain/agent.go b/internal/domain/agent.go index 675df4f..f5f4ea6 100644 --- a/internal/domain/agent.go +++ b/internal/domain/agent.go @@ -116,6 +116,13 @@ type AgentRegistration struct { // matches the AIM's verification expectation directly. CapabilitiesHash string `json:"capabilitiesHash,omitempty"` + // DNSRecordStyle selects which DNS record family the RA emits + // for this registration: "consolidated" (Consolidated Approach + // SVCB rows, default), "legacy" (the original `_ans` TXT shape), + // or "both" (the transition union). Empty at the domain layer + // is treated as DefaultDNSRecordStyle by ComputeRequiredDNSRecords. + DNSRecordStyle DNSRecordStyle `json:"dnsRecordStyle,omitempty"` + // PendingEvents holds domain events raised during this aggregate operation. // They are cleared after being published. PendingEvents []Event `json:"-"` diff --git a/internal/domain/dnsrecords.go b/internal/domain/dnsrecords.go index 011a58d..ba88f74 100644 --- a/internal/domain/dnsrecords.go +++ b/internal/domain/dnsrecords.go @@ -6,6 +6,59 @@ import ( "fmt" ) +// DNSRecordStyle selects which DNS record family the RA emits in its +// dnsRecordsProvisioned attestation and in the records it tells the +// operator to publish at registration time. +// +// Default is "consolidated": one SVCB record per protocol at the +// agent's bare FQDN per the cross-draft Consolidated Approach (§4.4.2). +// Operators on infrastructure that already publishes the legacy +// `_ans` TXT family pick "legacy". Migration operators pick "both" +// for a defined window, then flip back to "consolidated". +// +// Legacy MUST stay supported indefinitely. Operators picking "legacy" +// will continue to receive the original `_ans` TXT shape this RA has +// emitted since v0.1.x. The cross-channel hash consistency check +// (§4.4.2) only applies when the SVCB record is present, so "legacy" +// agents do not benefit from the card-sha256 ↔ capabilities_hash +// guarantee — that is a property of the chosen style, not a defect. +type DNSRecordStyle string + +const ( + // DNSRecordStyleConsolidated emits Consolidated Approach SVCB + // records (one per protocol, bare-FQDN owner) plus the + // `_ans-prefixed` records that no SvcParam covers (badge, + // identity DANE) plus the server-cert TLSA. The default. + DNSRecordStyleConsolidated DNSRecordStyle = "consolidated" + + // DNSRecordStyleLegacy emits the original `_ans` TXT family + // (one per protocol) plus the same `_ans-`-prefixed records + // plus the server-cert TLSA. No SVCB rows. + DNSRecordStyleLegacy DNSRecordStyle = "legacy" + + // DNSRecordStyleBoth emits the union of Consolidated Approach + // SVCB and legacy `_ans` TXT — the transition shape per §4.4.2 + // where the two record families coexist on the same agent's zone. + DNSRecordStyleBoth DNSRecordStyle = "both" +) + +// DefaultDNSRecordStyle is the style applied when the registration +// request omits dnsRecordStyle entirely. Pinned to "consolidated" so +// new integrations follow §4.4.2's "publish one SVCB record... rather +// than parallel per-ecosystem record trees" SHOULD by default. +const DefaultDNSRecordStyle = DNSRecordStyleConsolidated + +// IsValid reports whether s is one of the three defined styles. +// Empty string is treated as invalid; callers normalize empty to +// DefaultDNSRecordStyle before validation. +func (s DNSRecordStyle) IsValid() bool { + switch s { + case DNSRecordStyleConsolidated, DNSRecordStyleLegacy, DNSRecordStyleBoth: + return true + } + return false +} + // DNSRecordType represents a DNS record type. type DNSRecordType string @@ -46,6 +99,21 @@ type ExpectedDNSRecord struct { // ComputeRequiredDNSRecords generates the DNS records an operator must create // for a given agent registration. The RA does not create these records — the // operator manages their own DNS. The RA only verifies they exist. +// +// The set of records emitted depends on reg.DNSRecordStyle: +// +// - "consolidated" (default, recommended): Consolidated Approach SVCB +// rows (one per protocol) plus the shared `_ans-`-prefixed records +// plus the server-cert TLSA. No legacy `_ans` TXT rows. +// - "legacy": the original `_ans` TXT shape (one row per protocol) +// plus the same shared records. No SVCB rows. Backwards-compatible +// with operators who registered before the Consolidated Approach +// landed and have existing zone-edit tooling for `_ans` TXT. +// - "both": union of consolidated + legacy. The §4.4.2 transition +// shape; operators run both record families on the same zone for +// a defined window, then flip back to "consolidated". +// +// Empty reg.DNSRecordStyle is normalized to DefaultDNSRecordStyle. func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { fqdn := reg.FQDN() // Version is emitted as a bare semver string ("1.2.0"). The @@ -54,20 +122,29 @@ func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { // directly, matching the shape a client would parse with any // semver library. version := reg.AnsName.Version().String() + style := reg.DNSRecordStyle + if !style.IsValid() { + style = DefaultDNSRecordStyle + } var records []ExpectedDNSRecord - // _ans TXT record for each protocol endpoint — agent discovery. - for _, ep := range reg.Endpoints { - value := fmt.Sprintf("v=ans1; version=%s; p=%s; mode=direct; url=%s", - version, protocolToANSValue(ep.Protocol), ep.AgentURL) - records = append(records, ExpectedDNSRecord{ - Name: fmt.Sprintf("_ans.%s", fqdn), - Type: DNSRecordTXT, - Value: value, - Purpose: PurposeDiscovery, - Required: true, - TTL: 3600, - }) + emitLegacy := style == DNSRecordStyleLegacy || style == DNSRecordStyleBoth + emitConsolidated := style == DNSRecordStyleConsolidated || style == DNSRecordStyleBoth + + // _ans TXT record for each protocol endpoint — legacy discovery. + if emitLegacy { + for _, ep := range reg.Endpoints { + value := fmt.Sprintf("v=ans1; version=%s; p=%s; mode=direct; url=%s", + version, protocolToANSValue(ep.Protocol), ep.AgentURL) + records = append(records, ExpectedDNSRecord{ + Name: fmt.Sprintf("_ans.%s", fqdn), + Type: DNSRecordTXT, + Value: value, + Purpose: PurposeDiscovery, + Required: true, + TTL: 3600, + }) + } } // Consolidated Approach SVCB record at the bare FQDN — one per @@ -92,32 +169,34 @@ func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { // // Required=false: §4.4.2 marks the Consolidated Approach as MAY, // opt-in alongside the `_ans` TXT family during the transition. - cardSHA := capabilitiesHashBase64URL(reg.CapabilitiesHash) - for _, ep := range reg.Endpoints { - alpn := protocolToANSValue(ep.Protocol) - wk := wkPathFor(ep.Protocol) - // RFC 9460 §2.1 presentation form: unquoted SvcParamValue when - // the value has no characters special to the presentation - // format. alpn tokens (a2a, mcp), port digits, well-known path - // suffixes (agent-card.json), and base64url digests all qualify. - // The resolver-side formatter (formatHTTPSValue) also emits - // unquoted, so the verifier's normalize+compare matches without - // quote-stripping. - value := fmt.Sprintf(`1 . alpn=%s port=443`, alpn) - if wk != "" { - value += fmt.Sprintf(` wk=%s`, wk) - } - if cardSHA != "" { - value += fmt.Sprintf(` card-sha256=%s`, cardSHA) + if emitConsolidated { + cardSHA := capabilitiesHashBase64URL(reg.CapabilitiesHash) + for _, ep := range reg.Endpoints { + alpn := protocolToANSValue(ep.Protocol) + wk := wkPathFor(ep.Protocol) + // RFC 9460 §2.1 presentation form: unquoted SvcParamValue when + // the value has no characters special to the presentation + // format. alpn tokens (a2a, mcp), port digits, well-known path + // suffixes (agent-card.json), and base64url digests all qualify. + // The resolver-side formatter (formatHTTPSValue) also emits + // unquoted, so the verifier's normalize+compare matches without + // quote-stripping. + value := fmt.Sprintf(`1 . alpn=%s port=443`, alpn) + if wk != "" { + value += fmt.Sprintf(` wk=%s`, wk) + } + if cardSHA != "" { + value += fmt.Sprintf(` card-sha256=%s`, cardSHA) + } + records = append(records, ExpectedDNSRecord{ + Name: fqdn, + Type: DNSRecordSVCB, + Value: value, + Purpose: PurposeDiscovery, + Required: false, + TTL: 3600, + }) } - records = append(records, ExpectedDNSRecord{ - Name: fqdn, - Type: DNSRecordSVCB, - Value: value, - Purpose: PurposeDiscovery, - Required: false, - TTL: 3600, - }) } // _ans-badge TXT record — trust badge. Required alongside _ans: diff --git a/internal/domain/dnsrecords_test.go b/internal/domain/dnsrecords_test.go index 100e64d..f5ac7a6 100644 --- a/internal/domain/dnsrecords_test.go +++ b/internal/domain/dnsrecords_test.go @@ -12,6 +12,10 @@ func TestComputeRequiredDNSRecords_WithoutCert(t *testing.T) { ansName, _ := NewAnsName(mustSemVer(1, 2, 3), "agent.example.com") reg := &AgentRegistration{ AnsName: ansName, + // Force "both" style so this fixture exercises the union path: + // _ans TXT + Consolidated Approach SVCB. Tests below cover the + // single-style emission paths. + DNSRecordStyle: DNSRecordStyleBoth, Endpoints: []AgentEndpoint{ {Protocol: ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com/a2a"}, diff --git a/internal/ra/handler/registration.go b/internal/ra/handler/registration.go index cf7a2f1..5b60472 100644 --- a/internal/ra/handler/registration.go +++ b/internal/ra/handler/registration.go @@ -47,6 +47,14 @@ type registrationRequest struct { // round-trip through map[string]any would risk reordering or // number normalization that would shift the resulting digest. AgentCardContent json.RawMessage `json:"agentCardContent,omitempty"` + + // DNSRecordStyle selects which DNS record family the RA emits + // for this registration. One of "consolidated" (default, + // recommended), "legacy" (original `_ans` TXT shape), "both" + // (transition union). Empty/missing → consolidated. Invalid + // value rejected with 422 INVALID_DNS_RECORD_STYLE. See + // ANS_SPEC.md §4.4.2 for record-shape semantics. + DNSRecordStyle string `json:"dnsRecordStyle,omitempty"` } type endpointDTO struct { @@ -162,6 +170,7 @@ func (h *RegistrationHandler) Register(w http.ResponseWriter, r *http.Request) { ServerCertificatePEM: req.ServerCertificatePEM, ServerCertificateChainPEM: req.ServerCertificateChainPEM, AgentCardContent: []byte(req.AgentCardContent), + DNSRecordStyle: domain.DNSRecordStyle(req.DNSRecordStyle), }) if err != nil { WriteError(w, err) diff --git a/internal/ra/service/helpers.go b/internal/ra/service/helpers.go index 277187a..74fc5c5 100644 --- a/internal/ra/service/helpers.go +++ b/internal/ra/service/helpers.go @@ -28,6 +28,33 @@ func hashAgentCardContent(content []byte) (string, error) { return hex.EncodeToString(sum[:]), nil } +// applyDNSRecordStyle resolves the DNS-record-style for the new +// registration and stores it on the aggregate. +// +// V1 lane is pinned to "legacy" regardless of the request: V1 callers +// predate the Consolidated Approach and their tooling expects the +// original `_ans` TXT shape. V1 has no dnsRecordStyle field on the +// wire, so this branch is the only path V1 registrations take. +// V2 callers honor req.DNSRecordStyle: empty normalizes to +// DefaultDNSRecordStyle (consolidated); invalid values surface as +// INVALID_DNS_RECORD_STYLE. +func applyDNSRecordStyle(reg *domain.AgentRegistration, req RegisterRequest) error { + switch { + case req.SchemaVersion == "V1": + reg.DNSRecordStyle = domain.DNSRecordStyleLegacy + case req.DNSRecordStyle == "": + reg.DNSRecordStyle = domain.DefaultDNSRecordStyle + case !req.DNSRecordStyle.IsValid(): + return domain.NewValidationError( + "INVALID_DNS_RECORD_STYLE", + fmt.Sprintf("dnsRecordStyle %q is not one of consolidated, legacy, both", string(req.DNSRecordStyle)), + ) + default: + reg.DNSRecordStyle = req.DNSRecordStyle + } + return nil +} + // applyAgentCardContentHash hashes the optional agentCardContent // the operator submitted on the V2 registration request and stores // the digest on the aggregate per ANS_SPEC.md §A.1. Empty content diff --git a/internal/ra/service/registration.go b/internal/ra/service/registration.go index 6171a68..6c1f080 100644 --- a/internal/ra/service/registration.go +++ b/internal/ra/service/registration.go @@ -86,6 +86,14 @@ type RegisterRequest struct { // which hashes the protocol-native metadata (e.g., A2A AgentCard). // Empty when omitted on the registration request. AgentCardContent []byte + + // DNSRecordStyle selects which DNS record family the RA emits + // in dnsRecordsProvisioned and tells the operator to publish. + // "consolidated" (default), "legacy", or "both". Empty value is + // normalized to domain.DefaultDNSRecordStyle. Invalid value + // surfaces as INVALID_DNS_RECORD_STYLE before the aggregate is + // created. + DNSRecordStyle domain.DNSRecordStyle } // RegisterResponse is returned to the HTTP handler after a successful @@ -328,6 +336,10 @@ func (s *RegistrationService) RegisterAgent(ctx context.Context, req RegisterReq return nil, err } + if err := applyDNSRecordStyle(reg, req); err != nil { + return nil, err + } + // Generate the ACME DNS-01 challenge token + expiry. The only // DNS action the operator should take before verify-acme. dns01, _, err := generateChallengeTokens() diff --git a/spec/api-spec-v2.yaml b/spec/api-spec-v2.yaml index 7212267..08e3284 100644 --- a/spec/api-spec-v2.yaml +++ b/spec/api-spec-v2.yaml @@ -1095,6 +1095,28 @@ components: - protocol: "A2A" agentUrl: "https://support.example.com" metadataUrl: "https://support.example.com/.well-known/agent-card.json" + dnsRecordStyle: + type: string + enum: [consolidated, legacy, both] + description: | + Selects which DNS record family the RA emits for this + registration. Surfaces on the 202 register response's + dnsRecords[], on GET /v2/ans/agents/{agentId}, and on the + AGENT_REGISTERED TL event's + attestations.dnsRecordsProvisioned[]. + + consolidated (default, recommended): Consolidated + Approach SVCB rows at the bare FQDN per ANS_SPEC.md + §4.4.2, plus shared `_ans-`-prefixed records and TLSA. + legacy: original `_ans` TXT shape, supported + indefinitely for operators on existing zone-edit + tooling that targets `_ans.{fqdn}`. + both: union; the §4.4.2 transition shape. + + Empty/missing → consolidated. Default points new + integrations at the lean shape per §4.4.2 SHOULD. + default: "consolidated" + example: "consolidated" required: - agentDisplayName - version From c72a97ed97eeed035c92e6b005f3777789680db9 Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Sat, 16 May 2026 10:51:37 -0400 Subject: [PATCH 08/12] feat(dns): emit HTTPS RR alongside legacy _ans TXT family Closes a long-standing spec/impl gap: ANS_SPEC.md section A.8.1 lists the HTTPS RR (RFC 9460 type 65) at the agent FQDN as RA-generated content the AHP provisions, but ComputeRequiredDNSRecords had never emitted it. The DNSRecordHTTPS enum value and verifyHTTPS verifier were already in place; this commit wires the emission. Generated only for the legacy + both styles, not for consolidated: the SVCB rows the consolidated form publishes already carry the same alpn/port/ECH SvcParams the HTTPS RR would, so emitting both would duplicate content and risk the two records drifting (section A.8.2 explicitly notes this). Operators on the consolidated path who still want HTTPS-RR-aware clients (typically browsers) to see the metadata can publish their own HTTPS RR as a side addition. Required=false: HTTPS RR is blocked by CNAME at the agent FQDN per RFC 1034 section 3.6.2. AHPs whose apex is fronted via CNAME cannot publish it at the same name; the RA does not block verify-dns on its absence. Tests pin: legacy style includes HTTPS RR + no SVCB; consolidated style includes SVCB + no HTTPS RR; both style includes both families. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/domain/dnsrecords.go | 27 +++++++++++++ internal/domain/dnsrecords_test.go | 62 +++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/internal/domain/dnsrecords.go b/internal/domain/dnsrecords.go index ba88f74..e758e81 100644 --- a/internal/domain/dnsrecords.go +++ b/internal/domain/dnsrecords.go @@ -145,6 +145,33 @@ func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { TTL: 3600, }) } + + // HTTPS RR (RFC 9460 type 65) at the agent FQDN — service + // binding for HTTP/2 (and Encrypted Client Hello when the + // AHP provides an ECH config out-of-band). Per §A.8.1 the + // RA generates the content; the AHP decides whether to + // publish based on whether their apex is aliased via CNAME + // (CNAME at the agent FQDN blocks HTTPS RR at the same name + // per RFC 1034 §3.6.2). + // + // Skipped for the consolidated form: the SVCB rows already + // carry alpn / port / ECH SvcParams, so an HTTPS RR + // alongside duplicates content (§A.8.2). Legacy keeps it + // because the `_ans` TXT family does not carry connection + // hints — clients without ANS-protocol awareness rely on + // HTTPS RR for ALPN signalling. + // + // Required=false: operators on CNAME-fronted apex zones + // cannot publish this record at the same name; the spec + // does not block them on its absence. + records = append(records, ExpectedDNSRecord{ + Name: fqdn, + Type: DNSRecordHTTPS, + Value: `1 . alpn=h2`, + Purpose: PurposeDiscovery, + Required: false, + TTL: 3600, + }) } // Consolidated Approach SVCB record at the bare FQDN — one per diff --git a/internal/domain/dnsrecords_test.go b/internal/domain/dnsrecords_test.go index f5ac7a6..7831b9a 100644 --- a/internal/domain/dnsrecords_test.go +++ b/internal/domain/dnsrecords_test.go @@ -25,9 +25,9 @@ func TestComputeRequiredDNSRecords_WithoutCert(t *testing.T) { records := ComputeRequiredDNSRecords(reg) require.NotEmpty(t, records) - // 2 endpoints → 2 _ans TXT + 2 Consolidated Approach SVCB + + // 2 endpoints → 2 _ans TXT + 1 HTTPS + 2 Consolidated Approach SVCB + // 1 badge TXT (no TLSA: no cert). - var ansTxtCount, svcbCount, badgeCount, tlsaCount int + var ansTxtCount, httpsCount, svcbCount, badgeCount, tlsaCount int for _, r := range records { switch r.Purpose { case PurposeDiscovery: @@ -53,6 +53,13 @@ func TestComputeRequiredDNSRecords_WithoutCert(t *testing.T) { // No agentCardContent submitted in this fixture, so // card-sha256 should be absent. assert.NotContains(t, r.Value, "card-sha256") + case DNSRecordHTTPS: + httpsCount++ + assert.Equal(t, "agent.example.com", r.Name, + "HTTPS RR at the bare FQDN per §A.8.1") + assert.False(t, r.Required, + "HTTPS RR is opt-in: blocked by CNAME at @ when AHP fronts the apex") + assert.Contains(t, r.Value, "alpn=h2") default: t.Errorf("unexpected discovery record type %q", r.Type) } @@ -69,11 +76,62 @@ func TestComputeRequiredDNSRecords_WithoutCert(t *testing.T) { } assert.Equal(t, 2, ansTxtCount) + assert.Equal(t, 1, httpsCount, "one HTTPS RR at the bare FQDN per §A.8.1") assert.Equal(t, 2, svcbCount, "one SVCB row per protocol at the bare FQDN") assert.Equal(t, 1, badgeCount) assert.Equal(t, 0, tlsaCount, "no cert → no TLSA record") } +// TestComputeRequiredDNSRecords_LegacyOnlyEmitsHTTPSRR pins the legacy +// shape: HTTPS RR is generated alongside the `_ans` TXT family, NOT +// alongside the consolidated SVCB rows (which would duplicate the +// alpn/port SvcParams). §A.8.1 lists the HTTPS RR as RA-generated +// content the AHP provisions when the apex isn't aliased via CNAME. +func TestComputeRequiredDNSRecords_LegacyOnlyEmitsHTTPSRR(t *testing.T) { + ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") + reg := &AgentRegistration{ + AnsName: ansName, + DNSRecordStyle: DNSRecordStyleLegacy, + Endpoints: []AgentEndpoint{ + {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com"}, + }, + } + records := ComputeRequiredDNSRecords(reg) + + var sawHTTPS, sawSVCB bool + for _, r := range records { + switch r.Type { + case DNSRecordHTTPS: + sawHTTPS = true + case DNSRecordSVCB: + sawSVCB = true + } + } + assert.True(t, sawHTTPS, "legacy style must include an HTTPS RR") + assert.False(t, sawSVCB, "legacy style must NOT include SVCB rows") +} + +// TestComputeRequiredDNSRecords_ConsolidatedOmitsHTTPSRR pins the +// consolidated form's lean shape: HTTPS RR is omitted because the +// SVCB rows already carry equivalent SvcParams (alpn, port, ECH). +// Publishing both would duplicate content and risk drift between +// the two records. §A.8.2 calls this out explicitly. +func TestComputeRequiredDNSRecords_ConsolidatedOmitsHTTPSRR(t *testing.T) { + ansName, _ := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") + reg := &AgentRegistration{ + AnsName: ansName, + DNSRecordStyle: DNSRecordStyleConsolidated, + Endpoints: []AgentEndpoint{ + {Protocol: ProtocolA2A, AgentURL: "https://agent.example.com"}, + }, + } + records := ComputeRequiredDNSRecords(reg) + for _, r := range records { + assert.NotEqual(t, DNSRecordHTTPS, r.Type, + "consolidated style omits HTTPS RR (SVCB SvcParams subsume it)") + } +} + // TestComputeRequiredDNSRecords_SVCBWkPath pins the per-protocol `wk=` // SvcParam value the Consolidated Approach SVCB carries. A2A maps to // `agent-card.json` (IANA-registered); MCP maps to `mcp.json` (de-facto From 08e0839e0c2127699db10274f7071b003e2f9b30 Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Sat, 16 May 2026 11:47:46 -0400 Subject: [PATCH 09/12] feat: optional version + identity CSR (base-only registrations) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements ANS_SPEC.md section 3.2.0 base-only registration path: the registrant submits NEITHER a version nor an Identity CSR, yielding a registration with no ANSName and no Identity Certificate, identified by FQDN alone. Mirrors PR #974 against the Kotlin RA. Domain: - AgentRegistration gains AgentHost field (always set; canonical FQDN). - AnsName field stays but may be zero-value for base-only registrations. - IsBaseOnly() helper for emission paths. - NewRegistration accepts agentHost parameter and enforces the both- or-neither invariant: version + identityCsr are coupled, mixed forms rejected with VERSIONED_REQUIRES_IDENTITY_CSR or BASE_ONLY_REJECTS_IDENTITY_CSR. Handler / service: - V2 register endpoint marks version + identityCsrPEM as optional. - resolveAnsNameForRegister() helper centralizes the both-or-neither validation at the API boundary. - buildOptionalIdentityCSR() materializes a CSR aggregate only when the operator submitted one. DNS record emission: - _ans TXT records omit the version= field for base-only. - _ans-badge TXT records omit version= for base-only. - SVCB rows still emit (no version SvcParam in either form). - Identity Cert TLSA (_ans-identity._tls) is AHP-managed and absent from RA output regardless; base-only further means no Identity Certificate is ever issued. Tests pin: missing CSR with version → VERSIONED_REQUIRES_IDENTITY_CSR. CSR with no version → BASE_ONLY_REJECTS_IDENTITY_CSR. The pre-Plan-F "missing ans name" test was renamed to capture the new semantics. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/domain/agent.go | 108 ++++++++++++++++++++++++---- internal/domain/agent_test.go | 15 ++-- internal/domain/dnsrecords.go | 37 ++++++++-- internal/ra/handler/registration.go | 68 +++++++++++++----- internal/ra/service/helpers.go | 15 ++++ internal/ra/service/registration.go | 23 ++++-- 6 files changed, 215 insertions(+), 51 deletions(-) diff --git a/internal/domain/agent.go b/internal/domain/agent.go index f5f4ea6..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"` @@ -129,10 +141,27 @@ type AgentRegistration struct { } // 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, @@ -146,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", @@ -164,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, @@ -346,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/dnsrecords.go b/internal/domain/dnsrecords.go index e758e81..e087f85 100644 --- a/internal/domain/dnsrecords.go +++ b/internal/domain/dnsrecords.go @@ -120,8 +120,15 @@ func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { // `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 @@ -132,10 +139,17 @@ func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { 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 { - value := fmt.Sprintf("v=ans1; version=%s; p=%s; mode=direct; url=%s", - version, protocolToANSValue(ep.Protocol), ep.AgentURL) + 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, @@ -229,10 +243,19 @@ func ComputeRequiredDNSRecords(reg *AgentRegistration) []ExpectedDNSRecord { // _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, diff --git a/internal/ra/handler/registration.go b/internal/ra/handler/registration.go index 5b60472..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,20 @@ 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 @@ -141,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 @@ -162,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, @@ -293,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/service/helpers.go b/internal/ra/service/helpers.go index 74fc5c5..7142ea8 100644 --- a/internal/ra/service/helpers.go +++ b/internal/ra/service/helpers.go @@ -6,12 +6,27 @@ import ( "encoding/pem" "errors" "fmt" + "strings" "time" + "github.com/google/uuid" + anscrypto "github.com/godaddy/ans/internal/crypto" "github.com/godaddy/ans/internal/domain" ) +// 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 diff --git a/internal/ra/service/registration.go b/internal/ra/service/registration.go index 6c1f080..b9294b9 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 @@ -318,14 +326,15 @@ func (s *RegistrationService) RegisterAgent(ctx context.Context, req RegisterReq 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 From bd91bf6d99aeca157d6f502817a2b9b43ce21237 Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Sat, 16 May 2026 12:00:27 -0400 Subject: [PATCH 10/12] feat: end-to-end base-only registration (POST 202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live testing the §3.2.0 base-only path against the demo RA exposed several spots where the implementation still assumed a versioned ANSName. This commit closes the POST /v2/ans/agents path so multiple base-only registrations can coexist on the same RA instance. Domain: - AnsName.String() returns "" for the zero value rather than the malformed "ans://v0.0.0." that surfaced in API responses. Service: - Resolve fqdn once at the top of RegisterAgent and reuse for all cert validators. Pre-Plan-F code read req.AnsName.FQDN() inline, which returned "" for base-only and tripped INVALID_SERVER_CSR. - ValidateIdentityCSR is gated on IdentityCSRPEM != "" — base-only requests submit no CSR, and the handler+resolveAnsNameForRegister has already enforced the both-or-neither invariant. - SaveCSR is gated on IdentityCSR != nil, eliminating a nil-pointer panic at uow time. - Uniqueness check forks: versioned uses ExistsByAnsName as before; base-only uses ExistsActiveBaseOnlyByAgentHost so two base-only registrations on the same FQDN cannot coexist while letting distinct FQDNs register independently. Storage: - Migration 008 relaxes ans_name to nullable. Pre-Plan-F it was TEXT NOT NULL UNIQUE; two base-only registrations stored "" and collided on UNIQUE. The new schema persists NULL for the zero AnsName, which UNIQUE allows in unbounded multiplicity per SQLite semantics. - agentRow.AnsName is sql.NullString; toDomain decodes NULL/empty as the zero AnsName and populates AgentHost from the row directly so loaded base-only aggregates round-trip with their FQDN intact. - Save persists agent.AnsName.String() through nullableString and reads agent.FQDN() for the agent_host column instead of agent.AnsName.FQDN(), which was empty for base-only. Port: - AgentStore gains ExistsActiveBaseOnlyByAgentHost; the existing middleware test fake adds a no-op implementation. Live verification: registered four project skills against the local demo RA in sequence (ans-registration, ans-deep-analysis, ans-watchtower-analyze, ans-meeting-brief). Each returned 202 with distinct agentIds and persisted with NULL ans_name + populated agent_host. The verify-acme flow still assumes an Identity CSR is pending; gating that path for base-only is deferred to a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/adapter/store/sqlite/agent.go | 61 ++++++++++++++--- .../008_agent_ans_name_nullable.sql | 54 +++++++++++++++ internal/domain/ansname.go | 6 ++ internal/port/store.go | 8 +++ internal/ra/middleware/ownership_test.go | 3 + internal/ra/service/registration.go | 67 ++++++++++++++----- 6 files changed, 172 insertions(+), 27 deletions(-) create mode 100644 internal/adapter/store/sqlite/migrations/008_agent_ans_name_nullable.sql diff --git a/internal/adapter/store/sqlite/agent.go b/internal/adapter/store/sqlite/agent.go index 99aad57..58e350e 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"` @@ -46,17 +46,24 @@ type agentRow struct { } 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, @@ -105,11 +112,18 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) dns_record_style, created_at_ms, updated_at_ms ) 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, @@ -194,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 { @@ -203,6 +225,23 @@ 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 (ans_name empty) already claims +// the given FQDN. 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 = '' + 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/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/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/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/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/registration.go b/internal/ra/service/registration.go index b9294b9..fb3d3de 100644 --- a/internal/ra/service/registration.go +++ b/internal/ra/service/registration.go @@ -247,16 +247,43 @@ 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), - ) + + // Uniqueness check before heavy work. Versioned: by ANSName. + // Base-only (zero AnsName): the AnsName lookup short-circuits to + // false (the store treats empty as "not a key"), and we follow up + // with an FQDN scope so two base-only registrations cannot + // simultaneously claim the same agent host. + if req.AnsName.IsZero() { + baseExists, err := s.agents.ExistsActiveBaseOnlyByAgentHost(ctx, fqdn) + if err != nil { + return nil, err + } + if baseExists { + return nil, domain.NewConflictError( + "BASE_ONLY_FQDN_TAKEN", + fmt.Sprintf("a base-only registration for %q is already active or pending", fqdn), + ) + } + } else { + exists, err := s.agents.ExistsByAnsName(ctx, req.AnsName) + if err != nil { + return nil, err + } + if exists { + return nil, domain.NewConflictError( + "ANS_NAME_TAKEN", + fmt.Sprintf("ANS name %q is already registered", req.AnsName), + ) + } } // Server certificate: exactly one of CSR / BYOC. @@ -292,7 +319,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()) } @@ -313,17 +340,22 @@ 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. Identity CSR is optional under the §3.2.0 @@ -380,8 +412,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 { From 956e41d225e715881dc37d0eed70bbf1eca37b10 Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Sat, 16 May 2026 12:24:42 -0400 Subject: [PATCH 11/12] fix: serialize base-only fields cleanly in V2 list + detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The V2 list and detail handlers were still reading reg.AnsName.FQDN() and reg.AnsName.Version() inline, which surfaced empty strings (or the nonsense "0.0.0" version) for §3.2.0 base-only registrations whose AnsName is the zero value. The list was the surface that finally exposed it: agentHost came back empty even though the row stored a populated agent_host column. Handler: - mapListResponse and mapAgentDetails read AgentHost from reg.FQDN() (which falls back to AgentHost when AnsName is zero) and gate Version emission on !reg.IsBaseOnly() so base-only items emit Version="" rather than "0.0.0". Service: - Same change applied across the service layer call sites that pass the FQDN downstream to cert validators / signers — renewal.go and lifecycle.go switched from reg.AnsName.FQDN() to reg.FQDN(). These paths are versioned-only today, but the read pattern is now consistent so a base-only registration that reaches them will not silently lose its identity. - checkRegistrationUniqueness extracted from RegisterAgent to keep funlen under threshold and remove the nested-if depth lint hit the inline branching introduced. Storage: - ExistsActiveBaseOnlyByAgentHost matches both NULL and empty-string ans_name values so the predicate stays correct across an in-place upgrade where some pre-008 rows could still be empty strings rather than NULL. Tests: - Unit tests pin the new uniqueness check: PENDING_VALIDATION base-only counts as claimed, REVOKED releases the FQDN, versioned rows do not collide, and ExistsByAnsName(zero) short-circuits to false. Coverage holds at 90%. Live confirmation against the demo RA: 4 base-only project skills registered → V2 list returns wrapper shape with returnedCount=4, agentHost populated, version empty, status PENDING_VALIDATION across both pages of a cursor-driven walk. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/adapter/store/sqlite/agent.go | 9 +- internal/adapter/store/sqlite/agent_test.go | 97 +++++++++++++++++++++ internal/ra/handler/dto.go | 26 +++++- internal/ra/service/helpers.go | 33 +++++++ internal/ra/service/lifecycle.go | 2 +- internal/ra/service/registration.go | 29 +----- internal/ra/service/renewal.go | 8 +- 7 files changed, 165 insertions(+), 39 deletions(-) diff --git a/internal/adapter/store/sqlite/agent.go b/internal/adapter/store/sqlite/agent.go index 58e350e..2d7c623 100644 --- a/internal/adapter/store/sqlite/agent.go +++ b/internal/adapter/store/sqlite/agent.go @@ -226,15 +226,18 @@ func (s *AgentStore) ExistsByAnsName(ctx context.Context, ansName domain.AnsName } // ExistsActiveBaseOnlyByAgentHost returns true if a non-revoked, -// non-failed base-only registration (ans_name empty) already claims -// the given FQDN. The §3.2.0 path lets the same FQDN host multiple +// 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 = '' + 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 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/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/service/helpers.go b/internal/ra/service/helpers.go index 7142ea8..1c2c9cd 100644 --- a/internal/ra/service/helpers.go +++ b/internal/ra/service/helpers.go @@ -1,6 +1,7 @@ package service import ( + "context" "crypto/sha256" "encoding/hex" "encoding/pem" @@ -15,6 +16,38 @@ import ( "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 diff --git a/internal/ra/service/lifecycle.go b/internal/ra/service/lifecycle.go index 2f34919..244d294 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() diff --git a/internal/ra/service/registration.go b/internal/ra/service/registration.go index fb3d3de..d47e7e8 100644 --- a/internal/ra/service/registration.go +++ b/internal/ra/service/registration.go @@ -257,33 +257,8 @@ func (s *RegistrationService) RegisterAgent(ctx context.Context, req RegisterReq fqdn = req.AnsName.FQDN() } - // Uniqueness check before heavy work. Versioned: by ANSName. - // Base-only (zero AnsName): the AnsName lookup short-circuits to - // false (the store treats empty as "not a key"), and we follow up - // with an FQDN scope so two base-only registrations cannot - // simultaneously claim the same agent host. - if req.AnsName.IsZero() { - baseExists, err := s.agents.ExistsActiveBaseOnlyByAgentHost(ctx, fqdn) - if err != nil { - return nil, err - } - if baseExists { - return nil, domain.NewConflictError( - "BASE_ONLY_FQDN_TAKEN", - fmt.Sprintf("a base-only registration for %q is already active or pending", fqdn), - ) - } - } else { - exists, err := s.agents.ExistsByAnsName(ctx, req.AnsName) - if err != nil { - return nil, err - } - 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. 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) From 60bc0b8c37bae448ab179e8ad069a49b1722f121 Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Sat, 16 May 2026 16:08:03 -0400 Subject: [PATCH 12/12] fix(lifecycle): verify-acme skips identity-cert path for base-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan F follow-up (#63): pre-fix, verify-acme on a base-only registration returned MISSING_IDENTITY_CSR — base-only registers no Identity CSR by definition, so the lifecycle could never reach ACTIVE. Plan G's non-FQDN anchors (DID, LEI) all rely on the base-only path because NON_FQDN_REQUIRES_BASE_ONLY forced them there at registration time, so without this fix every DID/LEI registration sat in PENDING_VALIDATION forever. Fix: gate the identity-cert issuance + persistence branches on reg.IsBaseOnly(): - Versioned registrations: unchanged. Fetch pending CSR, sign through the IdentityCertificateAuthority port, persist the signed CSR + the StoredCertificate row, advance to PENDING_DNS. - Base-only registrations: skip the fetch + sign + persist. The aggregate still advances to PENDING_DNS through the standard state-machine call. No identity cert is created; the store sees no row. - Server CSR path: unchanged regardless of anchor type. BYOC and CSR-signed server certs work for any anchor. verify-dns needed no change: ComputeRequiredDNSRecords already omits the identity-cert TLSA when no identity cert is present, and buildAgentRegisteredEvent's identity-cert loop produces an empty slice for base-only (no certs to enumerate). The transition to ACTIVE proceeds cleanly. Tests pin the new behavior: - TestVerifyACME_BaseOnly_NoIdentityCSRRequired registers a base-only agent (zero AnsName, empty IdentityCSRPEM, AgentHost carries the FQDN identity), drives verify-acme, confirms the aggregate advanced to PENDING_DNS and the cert table is empty. - TestVerifyACME_Versioned_StillSignsIdentityCSR exercises the unchanged versioned path: register with version + Identity CSR, drive verify-acme, confirm 1 identity cert row was created. Live verification: registered did:web:lifecycle-test.example.com through the demo RA, drove POST verify-acme → PENDING_DNS, POST verify-dns → ACTIVE. Pre-fix the verify-acme call returned HTTP 409 MISSING_IDENTITY_CSR; post-fix it returns 202 with the documented PENDING_DNS body and the agent reaches ACTIVE. Coverage holds at 90.3%. Closes Plan F follow-up (#63), unblocks the Plan G PR pipeline. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/ra/service/lifecycle.go | 99 ++++++++++++--------- internal/ra/service/registration_test.go | 104 +++++++++++++++++++++++ 2 files changed, 165 insertions(+), 38 deletions(-) diff --git a/internal/ra/service/lifecycle.go b/internal/ra/service/lifecycle.go index 244d294..51f01c8 100644 --- a/internal/ra/service/lifecycle.go +++ b/internal/ra/service/lifecycle.go @@ -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 { diff --git a/internal/ra/service/registration_test.go b/internal/ra/service/registration_test.go index 989d033..e2ec58b 100644 --- a/internal/ra/service/registration_test.go +++ b/internal/ra/service/registration_test.go @@ -526,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)) + } +}