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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions internal/adapter/docsui/openapi/ra.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 26 additions & 1 deletion internal/adapter/docsui/openapi/tl.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -716,7 +716,32 @@ components:
version: { type: string }
attestations:
type: object
description: Event-type specific attestation payload.
description: |
Event-type specific attestation payload.

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

AppendResponse:
type: object
Expand Down
10 changes: 9 additions & 1 deletion internal/adapter/store/sqlite/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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
}

Expand All @@ -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,
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 13 additions & 0 deletions internal/domain/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:"-"`
Expand Down
10 changes: 9 additions & 1 deletion internal/ra/handler/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions internal/ra/handler/registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
130 changes: 130 additions & 0 deletions internal/ra/handler/registration_capabilitieshash_e2e_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading