diff --git a/cmd/ans-ra/main.go b/cmd/ans-ra/main.go index 510cb02..eab23c7 100644 --- a/cmd/ans-ra/main.go +++ b/cmd/ans-ra/main.go @@ -215,6 +215,14 @@ func run(cfgPath string) error { r.With(writeOwnership).Post("/v2/ans/agents/{agentId}/certificates/identity", lifeH.SubmitIdentityCSR) r.With(writeOwnership).Post("/v2/ans/agents/{agentId}/certificates/server", lifeH.SubmitServerCSR) + // EquivalenceLink (PR #20 schema; this PR's handler). Cross-anchor + // binding event: the caller must own both the primary {agentId} + // and the linked agentId in the body. The writeOwnership + // middleware confirms ownership of the primary; the handler + // re-checks ownership of the linked agent inside the service. + linkH := handler.NewEquivalenceLinkHandler(regSvc) + r.With(writeOwnership).Post("/v2/ans/agents/{agentId}/equivalence-links", linkH.CreateLink) + // Server certificate renewal routes. r.With(readOwnership).Get("/v2/ans/agents/{agentId}/certificates/server/renewal", lifeH.GetServerCertRenewal) r.With(writeOwnership).Post("/v2/ans/agents/{agentId}/certificates/server/renewal", lifeH.SubmitServerCertRenewal) diff --git a/internal/ra/handler/equivalence_link.go b/internal/ra/handler/equivalence_link.go new file mode 100644 index 0000000..263e2cb --- /dev/null +++ b/internal/ra/handler/equivalence_link.go @@ -0,0 +1,104 @@ +// Package handler: equivalence-link HTTP handler. +// +// Mounts at POST /v2/ans/agents/{agentId}/equivalence-links and emits +// one EQUIVALENCE_LINK event into the Transparency Log. The path +// {agentId} is the primary registration; the body carries the linked +// registration's agent id plus an optional rationale string. +// +// Auth: the writeOwnership middleware confirms the caller owns the +// primary registration before this handler runs. The service then +// re-checks ownership on the linked agent so a caller cannot link +// their own registration to one they do not control. +package handler + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/godaddy/ans/internal/adapter/auth" + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/ra/service" +) + +// EquivalenceLinkHandler exposes the link-emission endpoint. It owns +// no state; all logic lives in service.RegistrationService.LinkEquivalence. +type EquivalenceLinkHandler struct { + svc *service.RegistrationService +} + +// NewEquivalenceLinkHandler wires the handler against a registration +// service. Returned value is safe to share across request goroutines. +func NewEquivalenceLinkHandler(svc *service.RegistrationService) *EquivalenceLinkHandler { + return &EquivalenceLinkHandler{svc: svc} +} + +// linkRequest is the JSON shape the handler accepts on the body. +// linkedAnsId is the linked registration's agent UUID; rationale is +// the operator's free-text justification persisted in the link event. +type linkRequest struct { + LinkedAnsID string `json:"linkedAnsId"` + Rationale string `json:"rationale,omitempty"` +} + +// linkResponse mirrors what the service returns plus the path-derived +// primary agent id. Callers use the returned anchor type and value +// to display the link to operators without re-fetching the linked +// registration. +type linkResponse struct { + PrimaryAgentID string `json:"primaryAgentId"` + PrimaryAnsName string `json:"primaryAnsName,omitempty"` + LinkedAnsID string `json:"linkedAnsId"` + LinkedAnsName string `json:"linkedAnsName,omitempty"` + LinkedAnchorType string `json:"linkedAnchorType"` + LinkedAnchorValue string `json:"linkedAnchorValue"` + Rationale string `json:"rationale,omitempty"` + Timestamp string `json:"timestamp"` +} + +// CreateLink handles POST /v2/ans/agents/{agentId}/equivalence-links. +func (h *EquivalenceLinkHandler) CreateLink(w http.ResponseWriter, r *http.Request) { + primaryID := chi.URLParam(r, "agentId") + if primaryID == "" { + WriteError(w, domain.NewValidationError("MISSING_AGENT_ID", "agentId is required")) + return + } + + var body linkRequest + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(&body); err != nil { + WriteError(w, domain.NewValidationError("BAD_JSON", "request body is not valid JSON: "+err.Error())) + return + } + + id, ok := auth.IdentityFromContext(r.Context()) + if !ok || id.Subject == "" { + WriteError(w, domain.NewValidationError("MISSING_OWNER", "owner identity is required")) + return + } + ownerID := id.Subject + + res, err := h.svc.LinkEquivalence(r.Context(), service.LinkEquivalenceInput{ + OwnerID: ownerID, + PrimaryAgentID: primaryID, + LinkedAgentID: body.LinkedAnsID, + Rationale: body.Rationale, + }) + if err != nil { + WriteError(w, err) + return + } + + WriteJSON(w, http.StatusCreated, linkResponse{ + PrimaryAgentID: res.PrimaryAgentID, + PrimaryAnsName: res.PrimaryAnsName, + LinkedAnsID: res.LinkedAgentID, + LinkedAnsName: res.LinkedAnsName, + LinkedAnchorType: res.LinkedAnchorType, + LinkedAnchorValue: res.LinkedAnchorValue, + Rationale: res.Rationale, + Timestamp: res.Timestamp, + }) +} diff --git a/internal/ra/service/equivalence_link.go b/internal/ra/service/equivalence_link.go new file mode 100644 index 0000000..497cec4 --- /dev/null +++ b/internal/ra/service/equivalence_link.go @@ -0,0 +1,208 @@ +// Package service: equivalence-link emission. +// +// Implements the RA-side handler for the EQUIVALENCE_LINK event type +// schema in internal/tl/event. An operator running multiple agents +// under different anchor profiles (FQDN + LEI, FQDN + did:web, etc.) +// asserts that two of those registrations refer to the same operator +// by emitting one link event into the Transparency Log. +// +// Authorization model. The simplest defensible auth shape: the same +// authenticated operator must own both registrations on this RA. The +// caller's identity arrives through the existing ownership middleware +// (which gates the path-{agentId} primary registration), and this +// service performs a second ownership check on the linked agent. A +// future amendment may admit federated link events that span two RAs +// with two distinct producer signatures, in which case the Envelope +// schema grows a cosigner block; for now, both registrations live on +// this RA and the RA's existing producer key signs the single envelope. +package service + +import ( + "context" + "fmt" + "time" + + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/tl/event" +) + +// LinkEquivalenceInput collects what the handler needs to pass into +// the service layer. Both agent IDs are RA-issued UUIDs; rationale is +// the operator's plain-language reason a downstream verifier will see +// in the TL-sealed event. +type LinkEquivalenceInput struct { + OwnerID string // authenticated operator (from ownership middleware) + PrimaryAgentID string // {agentId} from the request path + LinkedAgentID string // body field linkedAnsId + Rationale string // optional free-text justification +} + +// LinkEquivalenceResult is what the handler returns to the caller. +type LinkEquivalenceResult struct { + PrimaryAgentID string + PrimaryAnsName string + LinkedAgentID string + LinkedAnsName string + LinkedAnchorType string + LinkedAnchorValue string + Rationale string + Timestamp string +} + +// LinkEquivalence emits one EQUIVALENCE_LINK event linking two +// registrations the caller owns on this RA. Returns 403-shaped +// validation errors on auth failures, 404-shaped on missing linked +// agent, 422 on a self-link attempt or non-ACTIVE linked agent. +func (s *RegistrationService) LinkEquivalence( + ctx context.Context, in LinkEquivalenceInput, +) (*LinkEquivalenceResult, error) { + if in.OwnerID == "" { + return nil, domain.NewValidationError("MISSING_OWNER", "owner id is required") + } + if in.PrimaryAgentID == "" { + return nil, domain.NewValidationError("MISSING_PRIMARY_AGENT_ID", "primary agentId is required") + } + if in.LinkedAgentID == "" { + return nil, domain.NewValidationError("MISSING_LINKED_AGENT_ID", "linkedAnsId is required") + } + if in.PrimaryAgentID == in.LinkedAgentID { + return nil, domain.NewValidationError( + "EQUIVALENCE_SELF_LINK", + "primary and linked agents must differ; cannot link a registration to itself", + ) + } + + // The ownership middleware has already loaded primary by path id, + // confirmed primary.OwnerID matches the caller, and would have + // returned 403 before we got here. We re-fetch primary anyway + // because the service path is also reachable from internal callers + // that bypass the middleware (admin tooling, future federation). + primary, err := s.agents.FindByAgentID(ctx, in.PrimaryAgentID) + if err != nil { + return nil, err + } + if primary == nil { + return nil, domain.NewValidationError( + "AGENT_NOT_FOUND", + fmt.Sprintf("primary agent %s not found", in.PrimaryAgentID), + ) + } + if primary.OwnerID != in.OwnerID { + return nil, domain.NewValidationError( + "NOT_AUTHORIZED", + "caller does not own the primary registration", + ) + } + + linked, err := s.agents.FindByAgentID(ctx, in.LinkedAgentID) + if err != nil { + return nil, err + } + if linked == nil { + return nil, domain.NewValidationError( + "LINKED_AGENT_NOT_FOUND", + fmt.Sprintf("linked agent %s not found on this RA", in.LinkedAgentID), + ) + } + if linked.OwnerID != in.OwnerID { + // Returning a not-found-shaped error preserves the existence- + // hiding posture the read endpoints use; the caller cannot + // probe for agents owned by other operators. + return nil, domain.NewValidationError( + "LINKED_AGENT_NOT_FOUND", + fmt.Sprintf("linked agent %s not found on this RA", in.LinkedAgentID), + ) + } + if linked.Status != domain.StatusActive { + return nil, domain.NewValidationError( + "LINKED_AGENT_NOT_ACTIVE", + fmt.Sprintf("linked agent %s is not ACTIVE (status=%s)", in.LinkedAgentID, linked.Status), + ) + } + + now := s.now() + inner := s.equivalenceInnerEvent(primary, linked, in.Rationale, now) + if err := inner.Validate(); err != nil { + // Belt-and-suspenders: the inner-event validator is the schema + // gate. Surfacing the failure here keeps the misshapen event + // off the outbox. + return nil, domain.NewValidationError("INVALID_EVENT", err.Error()) + } + + if err := s.enqueueTLEvent(ctx, string(event.TypeEquivalenceLink), primary, inner, now); err != nil { + return nil, fmt.Errorf("enqueue equivalence link: %w", err) + } + + return &LinkEquivalenceResult{ + PrimaryAgentID: primary.AgentID, + PrimaryAnsName: primary.AnsName.String(), + LinkedAgentID: linked.AgentID, + LinkedAnsName: linked.AnsName.String(), + LinkedAnchorType: anchorTypeFromRegistration(linked), + LinkedAnchorValue: anchorValueFromRegistration(linked), + Rationale: in.Rationale, + Timestamp: now.UTC().Format(time.RFC3339), + }, nil +} + +// equivalenceInnerEvent builds the TL inner event for a link. Unlike +// baseInnerEvent, this builder leaves Agent and Attestations nil and +// populates Equivalence per the EQUIVALENCE_LINK schema. The emitted +// shape passes internal/tl/event.Validate's link-event branch. +func (s *RegistrationService) equivalenceInnerEvent( + primary, linked *domain.AgentRegistration, rationale string, now time.Time, +) *event.Event { + raID := "" + if s.signer != nil { + raID = s.signer.RaID + } + return &event.Event{ + AnsID: primary.AgentID, + AnsName: primary.AnsName.String(), + EventType: event.TypeEquivalenceLink, + // No Agent block: the linked event documents an existing + // registration relationship, not an agent's own facts. + // No Attestations: domain-control proofs apply per + // registration, not per link. + Equivalence: &event.EquivalenceLink{ + LinkedAnsID: linked.AgentID, + LinkedAnsName: linked.AnsName.String(), + LinkedAnchorType: anchorTypeFromRegistration(linked), + LinkedAnchorResolvedID: anchorValueFromRegistration(linked), + Rationale: rationale, + }, + RaID: raID, + IssuedAt: now.UTC().Format(time.RFC3339), + Timestamp: now.UTC().Format(time.RFC3339), + } +} + +// anchorTypeFromRegistration recovers the anchor profile id that +// produced this registration. Plan G persists AnchorClaim on the +// aggregate when present; pre-Plan-G rows surface as nil and +// register implicitly under FQDN, so the absence is treated as fqdn. +func anchorTypeFromRegistration(reg *domain.AgentRegistration) string { + if reg.AnchorClaim != nil && reg.AnchorClaim.AnchorType != "" { + return string(reg.AnchorClaim.AnchorType) + } + return "fqdn" +} + +// anchorValueFromRegistration recovers the canonical anchor input. +// For FQDN the value is the agent host; for did:* / lei the value is +// the resolved id stored on the AnchorClaim aggregate. +func anchorValueFromRegistration(reg *domain.AgentRegistration) string { + if reg.AnchorClaim != nil && reg.AnchorClaim.ResolvedID != "" { + return reg.AnchorClaim.ResolvedID + } + return reg.AgentHost +} + +// now returns the service's current time. Wraps the tested clock +// accessor so equivalenceInnerEvent stays a pure function. +func (s *RegistrationService) now() time.Time { + if s.clock != nil { + return s.clock() + } + return time.Now() +} diff --git a/internal/ra/service/equivalence_link_test.go b/internal/ra/service/equivalence_link_test.go new file mode 100644 index 0000000..e147896 --- /dev/null +++ b/internal/ra/service/equivalence_link_test.go @@ -0,0 +1,157 @@ +package service + +import ( + "testing" + "time" + + "github.com/godaddy/ans/internal/domain" + "github.com/godaddy/ans/internal/tl/event" +) + +// mustAnsName parses a string into a domain.AnsName or fails the test. +func mustAnsName(t *testing.T, s string) domain.AnsName { + t.Helper() + n, err := domain.ParseAnsName(s) + if err != nil { + t.Fatalf("parse ans name %q: %v", s, err) + } + return n +} + +// Unit tests for the link-emission helpers. End-to-end coverage runs +// against the live demo stack in scripts/demo (see PR description for +// the live test plan). The store-coupled paths (LinkEquivalence +// itself) are exercised through that integration; these unit tests +// pin the anchor recovery + inner-event shape in isolation so a +// future field rename surfaces here. + +func TestAnchorTypeFromRegistration_FQDNDefault(t *testing.T) { + reg := &domain.AgentRegistration{AgentHost: "agent.test"} + if got := anchorTypeFromRegistration(reg); got != "fqdn" { + t.Errorf("got %q, want fqdn (default for missing AnchorClaim)", got) + } +} + +func TestAnchorTypeFromRegistration_LEI(t *testing.T) { + reg := &domain.AgentRegistration{ + AgentHost: "agent.test", + AnchorClaim: &domain.IdentityClaim{ + AnchorType: domain.AnchorTypeLEI, + ResolvedID: "529900T8BM49AURSDO55", + }, + } + if got := anchorTypeFromRegistration(reg); got != "lei" { + t.Errorf("got %q, want lei", got) + } +} + +func TestAnchorValueFromRegistration_LEI(t *testing.T) { + reg := &domain.AgentRegistration{ + AgentHost: "ignored.test", + AnchorClaim: &domain.IdentityClaim{ + AnchorType: domain.AnchorTypeLEI, + ResolvedID: "529900T8BM49AURSDO55", + }, + } + if got := anchorValueFromRegistration(reg); got != "529900T8BM49AURSDO55" { + t.Errorf("got %q, want LEI string from ResolvedID", got) + } +} + +func TestAnchorValueFromRegistration_FQDNFallsBackToHost(t *testing.T) { + reg := &domain.AgentRegistration{AgentHost: "agent.test"} + if got := anchorValueFromRegistration(reg); got != "agent.test" { + t.Errorf("got %q, want agent.test (AgentHost fallback)", got) + } +} + +func TestEquivalenceInnerEvent_ShapeValidates(t *testing.T) { + // The link inner event MUST pass internal/tl/event.Validate's + // link-event branch: present Equivalence, no Agent, no + // Attestations, non-empty linkedAnsId differing from ansId. + primary := &domain.AgentRegistration{ + AgentID: "primary-uuid", + AgentHost: "primary.acme.com", + AnsName: mustAnsName(t, "ans://v1.0.0.primary.acme.com"), + } + linked := &domain.AgentRegistration{ + AgentID: "linked-uuid", + AgentHost: "ignored.test", + AnsName: mustAnsName(t, "ans://v1.0.0.linked.acme.com"), + AnchorClaim: &domain.IdentityClaim{ + AnchorType: domain.AnchorTypeLEI, + ResolvedID: "529900T8BM49AURSDO55", + }, + } + svc := &RegistrationService{ + signer: &EventSigner{RaID: "ans-ra-test"}, + clock: func() time.Time { return time.Date(2026, 5, 17, 12, 0, 0, 0, time.UTC) }, + } + now := svc.clock() + + inner := svc.equivalenceInnerEvent(primary, linked, "operator-asserted", now) + if err := inner.Validate(); err != nil { + t.Fatalf("inner event Validate: %v", err) + } + + if inner.EventType != event.TypeEquivalenceLink { + t.Errorf("EventType: got %q, want EQUIVALENCE_LINK", inner.EventType) + } + if inner.Agent != nil { + t.Error("Agent block must be absent on link events") + } + if inner.Attestations != nil { + t.Error("Attestations must be absent on link events") + } + if inner.Equivalence == nil { + t.Fatal("Equivalence block must be present on link events") + } + if inner.Equivalence.LinkedAnsID != "linked-uuid" { + t.Errorf("LinkedAnsID: got %q", inner.Equivalence.LinkedAnsID) + } + if inner.Equivalence.LinkedAnchorType != "lei" { + t.Errorf("LinkedAnchorType: got %q", inner.Equivalence.LinkedAnchorType) + } + if inner.Equivalence.LinkedAnchorResolvedID != "529900T8BM49AURSDO55" { + t.Errorf("LinkedAnchorResolvedID: got %q", inner.Equivalence.LinkedAnchorResolvedID) + } + if inner.Equivalence.Rationale != "operator-asserted" { + t.Errorf("Rationale: got %q", inner.Equivalence.Rationale) + } + if inner.RaID != "ans-ra-test" { + t.Errorf("RaID: got %q", inner.RaID) + } + if inner.IssuedAt == "" || inner.Timestamp == "" { + t.Error("IssuedAt and Timestamp must be set") + } +} + +func TestEquivalenceInnerEvent_FQDNToFQDN(t *testing.T) { + // Same-anchor-type link still validates; the schema does not + // require the two anchors to differ. + primary := &domain.AgentRegistration{ + AgentID: "p", + AgentHost: "p.test", + AnsName: mustAnsName(t, "ans://v1.0.0.p.test"), + } + linked := &domain.AgentRegistration{ + AgentID: "l", + AgentHost: "l.test", + AnsName: mustAnsName(t, "ans://v1.0.0.l.test"), + } + svc := &RegistrationService{ + signer: &EventSigner{RaID: "ans-ra-test"}, + clock: func() time.Time { return time.Date(2026, 5, 17, 12, 0, 0, 0, time.UTC) }, + } + inner := svc.equivalenceInnerEvent(primary, linked, "", svc.clock()) + if err := inner.Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } + if inner.Equivalence.LinkedAnchorType != "fqdn" { + t.Errorf("LinkedAnchorType: got %q, want fqdn", inner.Equivalence.LinkedAnchorType) + } + if inner.Equivalence.LinkedAnchorResolvedID != "l.test" { + t.Errorf("LinkedAnchorResolvedID: got %q, want l.test (host fallback)", + inner.Equivalence.LinkedAnchorResolvedID) + } +} diff --git a/internal/tl/event/equivalence_link_test.go b/internal/tl/event/equivalence_link_test.go new file mode 100644 index 0000000..4e10158 --- /dev/null +++ b/internal/tl/event/equivalence_link_test.go @@ -0,0 +1,188 @@ +package event + +import ( + "encoding/json" + "strings" + "testing" +) + +// validLinkEvent returns a populated EQUIVALENCE_LINK envelope that +// passes Validate (modulo signing fields). Tests build mutations on +// top of this fixture rather than re-stating the entire shape. +func validLinkEvent() *Envelope { + return &Envelope{ + SchemaVersion: SchemaVersion, + Payload: &Payload{ + LogID: "log-uuid-1", + Producer: &Producer{ + KeyID: "ans-ra-signer", + Signature: "sig-jws", + Event: &Event{ + AnsID: "primary-id", + AnsName: "ans://v1.0.0.invoicing.acme.com", + EventType: TypeEquivalenceLink, + RaID: "ans-ra-local", + Timestamp: "2026-05-17T10:00:00Z", + Equivalence: &EquivalenceLink{ + LinkedAnsID: "linked-id", + LinkedAnsName: "ans://v1.0.0.invoicing.acme.com", + LinkedAnchorType: "lei", + LinkedAnchorResolvedID: "529900T8BM49AURSDO55", + Rationale: "operator-asserted-multi-anchor", + }, + }, + }, + }, + } +} + +func TestValidate_EquivalenceLink_HappyPath(t *testing.T) { + if err := validLinkEvent().Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } +} + +func TestValidate_EquivalenceLink_MissingEquivalenceFails(t *testing.T) { + env := validLinkEvent() + env.Payload.Producer.Event.Equivalence = nil + err := env.Validate() + if err == nil { + t.Fatal("expected error when EQUIVALENCE_LINK has no equivalence") + } + if !strings.Contains(err.Error(), "EQUIVALENCE_LINK requires equivalence") { + t.Errorf("error: %v", err) + } +} + +func TestValidate_EquivalenceLink_MissingLinkedAnsIDFails(t *testing.T) { + env := validLinkEvent() + env.Payload.Producer.Event.Equivalence.LinkedAnsID = "" + err := env.Validate() + if err == nil { + t.Fatal("expected error when LinkedAnsID is empty") + } + if !strings.Contains(err.Error(), "equivalence.linkedAnsId required") { + t.Errorf("error: %v", err) + } +} + +func TestValidate_EquivalenceLink_SelfReferenceFails(t *testing.T) { + env := validLinkEvent() + env.Payload.Producer.Event.Equivalence.LinkedAnsID = env.Payload.Producer.Event.AnsID + err := env.Validate() + if err == nil { + t.Fatal("expected error when LinkedAnsID == AnsID") + } + if !strings.Contains(err.Error(), "must differ from ansId") { + t.Errorf("error: %v", err) + } +} + +func TestValidate_EquivalenceLink_AgentFieldRejected(t *testing.T) { + env := validLinkEvent() + env.Payload.Producer.Event.Agent = &Agent{Host: "x.test", Name: "x", Version: "1.0.0"} + err := env.Validate() + if err == nil { + t.Fatal("expected error when EQUIVALENCE_LINK carries agent") + } + if !strings.Contains(err.Error(), "must not carry agent") { + t.Errorf("error: %v", err) + } +} + +func TestValidate_EquivalenceLink_AttestationsRejected(t *testing.T) { + env := validLinkEvent() + env.Payload.Producer.Event.Attestations = &Attestations{DomainValidation: "ACME-DNS-01"} + err := env.Validate() + if err == nil { + t.Fatal("expected error when EQUIVALENCE_LINK carries attestations") + } + if !strings.Contains(err.Error(), "must not carry attestations") { + t.Errorf("error: %v", err) + } +} + +func TestValidate_NonLink_RejectsEquivalence(t *testing.T) { + env := validLinkEvent() + env.Payload.Producer.Event.EventType = TypeAgentRegistered + env.Payload.Producer.Event.Agent = &Agent{Host: "x.test", Name: "x", Version: "1.0.0"} + env.Payload.Producer.Event.Attestations = &Attestations{DomainValidation: "ACME-DNS-01"} + // Equivalence is still populated; should be rejected. + err := env.Validate() + if err == nil { + t.Fatal("expected error when AGENT_REGISTERED carries equivalence") + } + if !strings.Contains(err.Error(), "equivalence not allowed for eventType") { + t.Errorf("error: %v", err) + } +} + +func TestType_EquivalenceLink_IsValid(t *testing.T) { + if !TypeEquivalenceLink.IsValid() { + t.Error("TypeEquivalenceLink should be IsValid true") + } +} + +// TestEquivalenceLink_JSONRoundTrip exercises the envelope's JCS path: +// EQUIVALENCE_LINK serializes to JSON and deserializes back without +// shape drift. Important because the leaf hash is computed over the +// canonical bytes; any field-name change breaks downstream verifiers. +func TestEquivalenceLink_JSONRoundTrip(t *testing.T) { + env := validLinkEvent() + b, err := json.Marshal(env) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + // Spot-check the canonical field names appear in the output. + for _, want := range []string{ + `"eventType":"EQUIVALENCE_LINK"`, + `"equivalence":`, + `"linkedAnsId":"linked-id"`, + `"linkedAnchorType":"lei"`, + `"linkedAnchorResolvedId":"529900T8BM49AURSDO55"`, + `"rationale":"operator-asserted-multi-anchor"`, + } { + if !strings.Contains(string(b), want) { + t.Errorf("JSON missing %s", want) + } + } + + var back Envelope + if err := json.Unmarshal(b, &back); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if back.Payload.Producer.Event.Equivalence.LinkedAnsID != "linked-id" { + t.Errorf("round-trip lost LinkedAnsID: %+v", back.Payload.Producer.Event.Equivalence) + } +} + +// TestEquivalenceLink_OmitemptyOnNonLink confirms the equivalence field +// stays absent in JSON when not set, so existing AGENT_REGISTERED bytes +// stay byte-identical to pre-change. +func TestEquivalenceLink_OmitemptyOnNonLink(t *testing.T) { + env := &Envelope{ + SchemaVersion: SchemaVersion, + Payload: &Payload{ + LogID: "log-uuid-2", + Producer: &Producer{ + KeyID: "ans-ra-signer", + Signature: "sig", + Event: &Event{ + AnsID: "primary", + AnsName: "ans://v1.0.0.acme.com", + EventType: TypeAgentRegistered, + Timestamp: "2026-05-17T10:00:00Z", + Agent: &Agent{Host: "acme.com", Name: "acme", Version: "1.0.0"}, + }, + }, + }, + } + b, err := json.Marshal(env) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if strings.Contains(string(b), `"equivalence"`) { + t.Errorf("equivalence key should be absent on non-link events; got: %s", b) + } +} diff --git a/internal/tl/event/event.go b/internal/tl/event/event.go index 6da66b7..6b3706a 100644 --- a/internal/tl/event/event.go +++ b/internal/tl/event/event.go @@ -86,6 +86,12 @@ const ( // TypeAgentDeprecated — the agent was superseded by a newer // version and is scheduled for rotation. TypeAgentDeprecated Type = "AGENT_DEPRECATED" + + // TypeEquivalenceLink — two registrations share an operational + // identity. Carries Equivalence in place of Agent / Attestations. + // See ANS-0 §7 cross-anchor coexistence and the EquivalenceLink + // struct below. + TypeEquivalenceLink Type = "EQUIVALENCE_LINK" ) // IsValid reports whether t is a recognized event type. @@ -94,7 +100,8 @@ func (t Type) IsValid() bool { case TypeAgentRegistered, TypeAgentRenewed, TypeAgentRevoked, - TypeAgentDeprecated: + TypeAgentDeprecated, + TypeEquivalenceLink: return true default: return false @@ -130,18 +137,66 @@ type Producer struct { // Event is the producer-authored payload. The producer JCS-canonicalizes // this and signs it; the resulting detached JWS lands in Producer.Signature. type Event struct { - AnsID string `json:"ansId"` - AnsName string `json:"ansName"` - EventType Type `json:"eventType"` - Agent *Agent `json:"agent,omitempty"` - Attestations *Attestations `json:"attestations,omitempty"` - ExpiresAt string `json:"expiresAt,omitempty"` - IssuedAt string `json:"issuedAt,omitempty"` - RaID string `json:"raId,omitempty"` - RenewalStatus string `json:"renewalStatus,omitempty"` - RevocationReasonCode string `json:"revocationReasonCode,omitempty"` - RevokedAt string `json:"revokedAt,omitempty"` - Timestamp string `json:"timestamp"` // RFC3339, required + AnsID string `json:"ansId"` + AnsName string `json:"ansName"` + EventType Type `json:"eventType"` + Agent *Agent `json:"agent,omitempty"` + Attestations *Attestations `json:"attestations,omitempty"` + Equivalence *EquivalenceLink `json:"equivalence,omitempty"` + ExpiresAt string `json:"expiresAt,omitempty"` + IssuedAt string `json:"issuedAt,omitempty"` + RaID string `json:"raId,omitempty"` + RenewalStatus string `json:"renewalStatus,omitempty"` + RevocationReasonCode string `json:"revocationReasonCode,omitempty"` + RevokedAt string `json:"revokedAt,omitempty"` + Timestamp string `json:"timestamp"` // RFC3339, required +} + +// EquivalenceLink records that two registrations share an operational +// identity. Recorded under EventType=EQUIVALENCE_LINK; the AnsID at +// the top of the event is the *primary* registration making the +// assertion, and the LinkedAnsID is the *other* registration the +// primary is asserting equivalence with. +// +// The producer signature on the enclosing event is the cryptographic +// link: a verifier reading the event sees the producer (the RA) attest +// that "AnsID and LinkedAnsID resolve to the same operational entity." +// The producer's authority to make that claim derives from the RA's +// position as the registrar of both sides; deployments enforce that +// authority by validating that the asserting controller has authority +// over both registrations before signing. +// +// The reference impl provides the type and validation; emission paths +// and per-deployment authority checks are operator concerns. +type EquivalenceLink struct { + // LinkedAnsID is the agentId of the linked registration (the one + // asserted to be the same operational entity as the event's + // primary AnsID). Required. + LinkedAnsID string `json:"linkedAnsId"` + + // LinkedAnsName is the linked registration's ANSName when the + // linked anchor produces one; empty for non-FQDN anchors that + // register base-only. Optional. + LinkedAnsName string `json:"linkedAnsName,omitempty"` + + // LinkedAnchorType reports the linked registration's anchor type + // ("fqdn", "did", "lei"). Optional but recommended; lets verifiers + // filter equivalence-graph edges by anchor type without joining + // against the registrations themselves. + LinkedAnchorType string `json:"linkedAnchorType,omitempty"` + + // LinkedAnchorResolvedID is the linked registration's canonical + // anchor identifier (e.g., the LEI string, the DID URI, the FQDN + // for an FQDN anchor). Optional but recommended for the same + // reason as LinkedAnchorType. + LinkedAnchorResolvedID string `json:"linkedAnchorResolvedId,omitempty"` + + // Rationale records why the link is being asserted. The default + // "operator-asserted-multi-anchor" applies when the operator + // registered the same operational entity under multiple anchors + // and is now publishing the cryptographic link. Other values are + // deployment-specific and not normatively constrained here. + Rationale string `json:"rationale,omitempty"` } // Agent identifies which agent the event refers to. @@ -309,6 +364,25 @@ func (ev *Event) Validate() error { if _, err := time.Parse(time.RFC3339, ev.Timestamp); err != nil { return fmt.Errorf("event: timestamp must be RFC3339: %w", err) } + if ev.EventType == TypeEquivalenceLink { + if ev.Equivalence == nil { + return errors.New("event: EQUIVALENCE_LINK requires equivalence") + } + if ev.Equivalence.LinkedAnsID == "" { + return errors.New("event: equivalence.linkedAnsId required") + } + if ev.Equivalence.LinkedAnsID == ev.AnsID { + return errors.New("event: equivalence.linkedAnsId must differ from ansId") + } + if ev.Agent != nil { + return errors.New("event: EQUIVALENCE_LINK must not carry agent") + } + if ev.Attestations != nil { + return errors.New("event: EQUIVALENCE_LINK must not carry attestations") + } + } else if ev.Equivalence != nil { + return fmt.Errorf("event: equivalence not allowed for eventType %q", ev.EventType) + } return nil }