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/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/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_capabilitieshash_e2e_test.go b/internal/ra/handler/registration_capabilitieshash_e2e_test.go new file mode 100644 index 0000000..02337dc --- /dev/null +++ b/internal/ra/handler/registration_capabilitieshash_e2e_test.go @@ -0,0 +1,130 @@ +package handler_test + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "testing" + + anscrypto "github.com/godaddy/ans/internal/crypto" +) + +// TestRegister_AgentCardContent_E2E_HashSealedAtActivation drives the +// full §A.1 flow and asserts the outbox-enqueued AGENT_REGISTERED +// event carries the expected hash: +// +// 1. POST /v2/ans/agents with agentCardContent → 202 +// 2. POST /v2/ans/agents/{id}/verify-acme → 202 (PENDING_DNS) +// 3. POST /v2/ans/agents/{id}/verify-dns → 202 (ACTIVE) +// 4. Outbox.Claim → AGENT_REGISTERED row whose payload contains +// attestations.metadataHashes.capabilitiesHash equal to +// SHA-256(JCS(agentCardContent)). +// +// This is the closing assertion the AIM relies on: the badge response +// the TL eventually serves carries the same capabilitiesHash the +// operator's submitted Trust Card body produces under JCS+SHA-256. +func TestRegister_AgentCardContent_E2E_HashSealedAtActivation(t *testing.T) { + t.Parallel() + fx := newHandlerFixture(t) + + cardBody := map[string]any{ + "ansName": "ans://v1.0.0.e2ecard.example.com", + "version": "1.0.0", + "agentDisplayName": "E2E Card", + "endpoints": []map[string]any{ + { + "protocol": "MCP", + "agentUrl": "https://e2ecard.example.com/mcp", + "transports": []string{"SSE"}, + "metaDataUrl": "https://e2ecard.example.com/.well-known/agent-card.json", + }, + }, + } + cardJSON, err := json.Marshal(cardBody) + if err != nil { + t.Fatalf("marshal card body: %v", err) + } + + // Compute the expected hash here so the assertion is independent + // of the production code path. JCS canonicalization (RFC 8785) is + // the same package the production hashAgentCardContent uses, but + // invoking it in the test instead of importing the unexported + // helper keeps the dependency boundary explicit. + canonical, err := anscrypto.Canonicalize(cardJSON) + if err != nil { + t.Fatalf("canonicalize: %v", err) + } + digest := sha256.Sum256(canonical) + wantHash := hex.EncodeToString(digest[:]) + + registerBody, _ := json.Marshal(map[string]any{ + "agentDisplayName": "E2E", + "version": "1.0.0", + "agentHost": "e2ecard.example.com", + "endpoints": []map[string]any{ + {"agentUrl": "https://e2ecard.example.com/mcp", "protocol": "MCP", "transports": []string{"SSE"}}, + }, + "identityCsrPEM": newTestCSR(t, "ans://v1.0.0.e2ecard.example.com"), + "serverCsrPEM": newTestServerCSR(t, "e2ecard.example.com"), + "agentCardContent": cardBody, + }) + rec := fx.request(t, http.MethodPost, "/v2/ans/agents", + bytes.NewReader(registerBody), fx.asOwner("alice")) + if rec.Code != http.StatusAccepted { + t.Fatalf("register: got %d body=%s", rec.Code, rec.Body) + } + var pending struct { + AgentID string `json:"agentId"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &pending) + if pending.AgentID == "" { + t.Fatalf("agentId not populated in 202 body: %s", rec.Body) + } + agentID := pending.AgentID + + // Drive activation. The lifecycle helper goes through verify-acme + // + verify-dns and produces the AGENT_REGISTERED outbox row. + fx.activateAgent(t, "alice", agentID) + + rows, err := fx.outbox.Claim(context.Background(), 100) + if err != nil { + t.Fatalf("outbox claim: %v", err) + } + var registeredPayload []byte + for _, row := range rows { + if row.EventType == "AGENT_REGISTERED" { + registeredPayload = row.PayloadJSON + break + } + } + if registeredPayload == nil { + t.Fatalf("no AGENT_REGISTERED row found among %d outbox events", len(rows)) + } + + // The outbox payload is { innerEventCanonical, producerSignature } + // — the inner event is the producer-signed payload that gets + // posted to the TL. Walk innerEventCanonical.attestations. + // metadataHashes.capabilitiesHash and confirm it matches. + var envelope struct { + InnerEventCanonical struct { + Attestations struct { + MetadataHashes map[string]string `json:"metadataHashes"` + } `json:"attestations"` + } `json:"innerEventCanonical"` + } + if err := json.Unmarshal(registeredPayload, &envelope); err != nil { + t.Fatalf("decode envelope: %v\npayload=%s", err, registeredPayload) + } + gotHash := envelope.InnerEventCanonical.Attestations.MetadataHashes["capabilitiesHash"] + if gotHash == "" { + t.Fatalf("metadataHashes.capabilitiesHash missing from AGENT_REGISTERED payload\npayload=%s", + registeredPayload) + } + if gotHash != wantHash { + t.Errorf("metadataHashes.capabilitiesHash mismatch:\n want %s\n got %s", + wantHash, gotHash) + } +} diff --git a/internal/ra/handler/registration_errors_test.go b/internal/ra/handler/registration_errors_test.go index 9cee98e..7f8a767 100644 --- a/internal/ra/handler/registration_errors_test.go +++ b/internal/ra/handler/registration_errors_test.go @@ -20,6 +20,151 @@ 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 pins the +// outer-body contract: when an operator embeds raw bytes the JSON +// decoder cannot parse inside agentCardContent, the failure mode is +// BAD_JSON at the handler, not INVALID_AGENT_CARD_CONTENT at the +// service. The malformed bytes break the outer body decode before +// the service runs. +// +// The service-layer INVALID_AGENT_CARD_CONTENT path is exercised by +// TestRegister_AgentCardContent_NotAnObject_RejectedAt422 below, +// which submits JSON that decodes successfully at the outer layer +// (a JSON array) but the service rejects because agentCardContent +// must be a JSON object per ANS_SPEC §A.1. +func TestRegister_AgentCardContent_MalformedJSON_RejectedAt422(t *testing.T) { + t.Parallel() + 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) + } + var prob struct { + Code string `json:"code"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &prob) + if prob.Code != "BAD_JSON" { + t.Errorf("code: got %q want BAD_JSON (service is not reached)", prob.Code) + } +} + +// TestRegister_AgentCardContent_NotAnObject_RejectedAt422 exercises +// the service-layer INVALID_AGENT_CARD_CONTENT path. Submitting a +// JSON array as agentCardContent decodes cleanly at the outer layer +// (the body is valid JSON), so the request reaches the service. The +// service rejects with INVALID_AGENT_CARD_CONTENT because the +// agentCardContent shape MUST be a JSON object per ANS_SPEC §A.1. +func TestRegister_AgentCardContent_NotAnObject_RejectedAt422(t *testing.T) { + t.Parallel() + fx := newHandlerFixture(t) + body, _ := json.Marshal(map[string]any{ + "agentDisplayName": "X", + "version": "1.0.0", + "agentHost": "x.example.com", + "endpoints": []map[string]any{{"agentUrl": "https://x.example.com", "protocol": "MCP", "transports": []string{"SSE"}}}, + "identityCsrPEM": newTestCSR(t, "ans://v1.0.0.x.example.com"), + "serverCsrPEM": newTestServerCSR(t, "x.example.com"), + "agentCardContent": []string{"not", "an", "object"}, + }) + rec := fx.request(t, http.MethodPost, "/v2/ans/agents", + bytes.NewReader(body), fx.asOwner("alice")) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("status: got %d want 422; body=%s", rec.Code, rec.Body) + } + var prob struct { + Code string `json:"code"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &prob) + if prob.Code != "INVALID_AGENT_CARD_CONTENT" { + t.Errorf("code: got %q want INVALID_AGENT_CARD_CONTENT", prob.Code) + } +} + func TestRegister_BadJSONReturns422(t *testing.T) { t.Parallel() fx := newHandlerFixture(t) diff --git a/internal/ra/service/helpers.go b/internal/ra/service/helpers.go index fda9cd6..349a716 100644 --- a/internal/ra/service/helpers.go +++ b/internal/ra/service/helpers.go @@ -3,14 +3,77 @@ package service import ( "crypto/sha256" "encoding/hex" + "encoding/json" "encoding/pem" "errors" "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 +} + +// 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 + } + // The OpenAPI declares agentCardContent as type=object with + // additionalProperties=true. JCS canonicalization itself accepts + // any valid JSON value (array, string, number, boolean, null), so + // without an explicit shape check the operator could submit a JSON + // array, get a capabilitiesHash sealed for it, and produce a hash + // the AIM cannot reproduce when it later fetches the live Trust + // Card and finds an object there. Reject anything that isn't a + // JSON object before hashing. + var probe any + if err := json.Unmarshal(content, &probe); err != nil { + return domain.NewValidationError( + "INVALID_AGENT_CARD_CONTENT", + fmt.Sprintf("agentCardContent could not be parsed as JSON: %v", err), + ) + } + if _, ok := probe.(map[string]any); !ok { + return domain.NewValidationError( + "INVALID_AGENT_CARD_CONTENT", + fmt.Sprintf("agentCardContent must be a JSON object, got %T", probe), + ) + } + hashHex, err := hashAgentCardContent(content) + if err != nil { + return domain.NewValidationError( + "INVALID_AGENT_CARD_CONTENT", + fmt.Sprintf("agentCardContent could not be canonicalized: %v", err), + ) + } + reg.CapabilitiesHash = hashHex + return nil +} + // fingerprintOf returns the SHA-256 fingerprint of the DER certificate // inside the given PEM string, formatted as `SHA256:`. // The `SHA256:` prefix matches the algorithm-prefixed form the diff --git a/internal/ra/service/lifecycle.go b/internal/ra/service/lifecycle.go index b6a9f6b..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..6171a68 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,10 @@ func (s *RegistrationService) RegisterAgent(ctx context.Context, req RegisterReq } reg.ServerCSR = pendingServerCSR + if err := applyAgentCardContentHash(reg, req.AgentCardContent); 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/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 diff --git a/internal/tl/event/event.go b/internal/tl/event/event.go index 3e6473b..6da66b7 100644 --- a/internal/tl/event/event.go +++ b/internal/tl/event/event.go @@ -174,9 +174,39 @@ type Attestations struct { DNSRecordsProvisioned []DNSRecord `json:"dnsRecordsProvisioned,omitempty"` IdentityCerts []CertificateInfo `json:"identityCerts,omitempty"` ServerCerts []CertificateInfo `json:"serverCerts,omitempty"` - MetadataHashes map[string]string `json:"metadataHashes,omitempty"` + // MetadataHashes carries SHA-256 hex-lowercase digests of + // artifacts the operator submitted at registration time. The + // well-known map keys are reserved by ANS_SPEC.md and reused + // across this package via the constants below + // (MetadataHashKeyCapabilitiesHash, …). + // + // `omitempty` on the map makes the canonical envelope identical + // for events whose operator submitted no metadata and events + // whose map happens to be empty. Both serialize without the + // `metadataHashes` key, keeping leaf-hash stability across the + // presence/absence boundary. + MetadataHashes map[string]string `json:"metadataHashes,omitempty"` } +// Reserved keys for Attestations.MetadataHashes. Values stored in +// the map under these keys are hex-lowercase SHA-256 digests +// (64 lowercase hex chars). +const ( + // MetadataHashKeyCapabilitiesHash is the SHA-256(JCS(agentCardContent)) + // computed at activation per ANS_SPEC.md §A.1. Operators submit the + // ANS Trust Card body (§A.2 — the document hosted at + // /.well-known/ans/trust-card.json) on the V2 registration request + // as `agentCardContent`; the RA hashes it and seals the digest under + // this key. The AIM later verifies the live hosted Trust Card content + // against this value. + // + // Distinct from any per-endpoint metadataHash on AgentEndpoint, which + // hashes the protocol-native metadata (e.g., the A2A AgentCard at + // /.well-known/agent-card.json) rather than the agent-level + // ANS Trust Card. + MetadataHashKeyCapabilitiesHash = "capabilitiesHash" +) + // DNSRecord is one DNS record attesting the agent's *production* // DNS state at event time — the records an independent verifier // would see if they queried the authoritative nameserver after the diff --git a/internal/tl/event/event_test.go b/internal/tl/event/event_test.go index 8459906..44d4de9 100644 --- a/internal/tl/event/event_test.go +++ b/internal/tl/event/event_test.go @@ -462,6 +462,121 @@ func TestType_IsValid(t *testing.T) { } } +// TestAttestations_MetadataHashesCapabilitiesHash exercises the +// well-known `capabilitiesHash` key on the metadataHashes map. The +// canonical wire shape and leaf-hash stability across "field absent" +// vs "field present" are both load-bearing for AIM verification. +func TestAttestations_MetadataHashesCapabilitiesHash(t *testing.T) { + t.Parallel() + + hashHex := "9f3a2b1c4d5e6f7890abcdef0123456789abcdef0123456789abcdef01234567" + + tests := []struct { + name string + setHash bool + // substrings the canonical bytes MUST contain. + mustContain []string + // substrings the canonical bytes MUST NOT contain. + mustOmit []string + }{ + { + name: "absent_omits_metadataHashes_key", + setHash: false, + mustOmit: []string{ + "metadataHashes", + "capabilitiesHash", + }, + }, + { + name: "present_writes_capabilitiesHash_under_metadataHashes", + setHash: true, + mustContain: []string{ + `"metadataHashes":{`, + `"capabilitiesHash":"` + hashHex + `"`, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + a := event.Attestations{ + DomainValidation: "ACME-DNS-01", + } + if tc.setHash { + a.MetadataHashes = map[string]string{ + event.MetadataHashKeyCapabilitiesHash: hashHex, + } + } + out, err := json.Marshal(a) + if err != nil { + t.Fatalf("marshal: %v", err) + } + s := string(out) + for _, want := range tc.mustContain { + if !strings.Contains(s, want) { + t.Errorf("missing %q in %s", want, s) + } + } + for _, bad := range tc.mustOmit { + if strings.Contains(s, bad) { + t.Errorf("unexpected %q in %s", bad, s) + } + } + }) + } +} + +// TestAttestations_LeafStabilityAcrossCapabilitiesHashAbsence pins the +// invariant that an envelope whose Attestations.MetadataHashes map is +// nil produces the same canonical bytes (and therefore the same leaf +// hash) as an envelope whose map is explicitly empty. The AIM relies +// on this stability when comparing badge responses across reads. +func TestAttestations_LeafStabilityAcrossCapabilitiesHashAbsence(t *testing.T) { + t.Parallel() + + mkAttestations := func(empty map[string]string) event.Attestations { + return event.Attestations{ + DomainValidation: "ACME-DNS-01", + MetadataHashes: empty, + } + } + withNil, err := json.Marshal(mkAttestations(nil)) + if err != nil { + t.Fatalf("marshal nil: %v", err) + } + withEmpty, err := json.Marshal(mkAttestations(map[string]string{})) + if err != nil { + t.Fatalf("marshal empty: %v", err) + } + if !bytes.Equal(withNil, withEmpty) { + t.Errorf("nil vs empty diverge:\nnil: %s\nempty: %s", + string(withNil), string(withEmpty)) + } + + // And the SHA-256 of the canonical bytes must match: this drives + // leaf-hash stability through the envelope's SigningInput. + if sha256.Sum256(withNil) != sha256.Sum256(withEmpty) { + t.Errorf("hashes diverge across nil/empty (defeats leaf stability)") + } +} + +// TestAttestations_CapabilitiesHashHexFormat documents the wire format +// the AIM expects: 64 lowercase hex chars produced by +// hex.EncodeToString(sha256(jcs(content))). Regressions on this format +// are observable as AIM verification failures even when the underlying +// hash is correct. +func TestAttestations_CapabilitiesHashHexFormat(t *testing.T) { + t.Parallel() + digest := sha256.Sum256([]byte(`{"ansName":"ans://v1.0.0.example.com"}`)) + got := hex.EncodeToString(digest[:]) + if len(got) != 64 { + t.Fatalf("hex digest must be 64 chars, got %d", len(got)) + } + if got != strings.ToLower(got) { + t.Errorf("hex digest must be lowercase, got %q", got) + } +} + // ----- helpers ----- var update = boolPtr(os.Getenv("UPDATE_GOLDEN") != "") diff --git a/spec/api-spec-tl-v2.yaml b/spec/api-spec-tl-v2.yaml index 6dfbd9a..ed84cff 100644 --- a/spec/api-spec-tl-v2.yaml +++ b/spec/api-spec-tl-v2.yaml @@ -716,7 +716,32 @@ components: version: { type: string } attestations: type: object - description: Event-type specific attestation payload. + description: | + Event-type specific attestation payload. + + For AGENT_REGISTERED events, the optional + `metadataHashes` map carries SHA-256 hex-lowercase + digests of artifacts the operator submitted at + registration. The well-known map key + `capabilitiesHash` is the SHA-256(JCS(agentCardContent)) + sealed at activation per ANS_SPEC.md §A.1; the AIM + uses it to verify the live ANS Trust Card content + against what was registered. When `agentCardContent` + was omitted on registration, the `capabilitiesHash` + key is absent. + properties: + metadataHashes: + type: object + description: | + Map of well-known hash names to SHA-256 hex-lowercase + digests. Reserved keys: `capabilitiesHash` + (SHA-256(JCS(agentCardContent)) per ANS_SPEC.md §A.1). + Additional keys reserved for future expansion. + additionalProperties: + type: string + pattern: '^[0-9a-f]{64}$' + example: + capabilitiesHash: "9f3a2b1c4d5e6f7890abcdef0123456789abcdef0123456789abcdef01234567" AppendResponse: type: object diff --git a/spec/api-spec-v2.yaml b/spec/api-spec-v2.yaml index 4e3c49a..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