diff --git a/internal/adapter/anchor/lei/attestation_source.go b/internal/adapter/anchor/lei/attestation_source.go new file mode 100644 index 0000000..cd4761a --- /dev/null +++ b/internal/adapter/anchor/lei/attestation_source.go @@ -0,0 +1,88 @@ +// attestation_source.go separates the entity-verification responsibility +// (handled by GLEIFClient) from the attestation-key responsibility +// (the entity's published verification key the agent uses to sign +// registration events). +// +// The GLEIF Public API does not carry an attestation key in the +// Level 1 record. Two paths populate the key: +// +// - vLEI Option A: a self-attestation issued through GLEIF's vLEI +// infrastructure, fetched via that infrastructure's API. +// - vLEI Option B: a custom field at the entity's Local Operating +// Unit (LOU), retrieved through the LOU's API. +// +// Both paths are deployment choices, not protocol-level concerns. +// The Resolver composes a GLEIFClient (entity verification) with an +// AttestationJWKSource (key retrieval); the two surfaces stay +// independent so a deployment can use the basic GLEIF Level 1 API +// for verification and a separate pluggable source for the key. +package lei + +import ( + "context" + "sync" +) + +// AttestationJWKSource returns the entity's published verification +// key as a JWK byte sequence (RFC 7517). +// +// Implementations MUST be safe for concurrent use. The Resolver may +// call Lookup concurrently from multiple verification cycles. +type AttestationJWKSource interface { + // Lookup returns the JWK bytes for the given (canonical, uppercase) + // LEI. Returns nil with no error when the source has no key for + // the LEI; the caller decides whether absence is a failure. + Lookup(ctx context.Context, lei string) ([]byte, error) +} + +// StaticAttestationSource is an in-memory map from LEI to JWK bytes. +// Useful for testbeds, integration tests, and deployments that +// preconfigure entity keys out-of-band (e.g., from a sealed config +// file or a secret store). +// +// A vLEI-aware source replaces this in production deployments that +// resolve keys through GLEIF's vLEI infrastructure. +type StaticAttestationSource struct { + mu sync.RWMutex + keys map[string][]byte +} + +// NewStaticAttestationSource returns an empty static source. +// Populate it with Set before passing it to Resolver.WithAttestationSource. +func NewStaticAttestationSource() *StaticAttestationSource { + return &StaticAttestationSource{ + keys: make(map[string][]byte), + } +} + +// Set registers a JWK for the given LEI. The LEI is canonicalized +// to uppercase before storage so callers may pass either form. +func (s *StaticAttestationSource) Set(lei string, jwk []byte) { + canonical, err := Canonicalize(lei) + if err != nil { + // Stored as the input form; Lookup will not match. Caller + // bug surfaces at lookup time rather than here. Keeping Set + // non-erroring lets callers wire up a source from config + // without a separate validation step. + canonical = lei + } + s.mu.Lock() + defer s.mu.Unlock() + cp := make([]byte, len(jwk)) + copy(cp, jwk) + s.keys[canonical] = cp +} + +// Lookup implements AttestationJWKSource. Returns a defensive copy +// so callers cannot mutate the stored bytes. +func (s *StaticAttestationSource) Lookup(_ context.Context, lei string) ([]byte, error) { + s.mu.RLock() + defer s.mu.RUnlock() + stored, ok := s.keys[lei] + if !ok { + return nil, nil + } + cp := make([]byte, len(stored)) + copy(cp, stored) + return cp, nil +} diff --git a/internal/adapter/anchor/lei/attestation_source_test.go b/internal/adapter/anchor/lei/attestation_source_test.go new file mode 100644 index 0000000..76647b2 --- /dev/null +++ b/internal/adapter/anchor/lei/attestation_source_test.go @@ -0,0 +1,164 @@ +package lei + +import ( + "context" + "testing" +) + +func TestStaticAttestationSource_SetAndLookup(t *testing.T) { + s := NewStaticAttestationSource() + jwk := []byte(`{"kty":"OKP","crv":"Ed25519","x":"abc"}`) + s.Set(validLEI, jwk) + + got, err := s.Lookup(context.Background(), validLEI) + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if string(got) != string(jwk) { + t.Errorf("Lookup: got %q, want %q", got, jwk) + } +} + +func TestStaticAttestationSource_LookupMissing(t *testing.T) { + s := NewStaticAttestationSource() + got, err := s.Lookup(context.Background(), validLEI) + if err != nil { + t.Fatalf("Lookup on empty: %v", err) + } + if got != nil { + t.Errorf("Lookup on empty source should return nil, got %q", got) + } +} + +func TestStaticAttestationSource_SetCanonicalizes(t *testing.T) { + s := NewStaticAttestationSource() + jwk := []byte(`{"kty":"OKP","crv":"Ed25519","x":"abc"}`) + // Set with lowercase form; Lookup with canonical form should hit. + s.Set("529900t8bm49aursdo55", jwk) + + got, err := s.Lookup(context.Background(), validLEI) + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if string(got) != string(jwk) { + t.Errorf("canonicalization failed: got %q", got) + } +} + +func TestStaticAttestationSource_DefensiveCopyOnLookup(t *testing.T) { + s := NewStaticAttestationSource() + jwk := []byte(`{"kty":"OKP","crv":"Ed25519","x":"abc"}`) + s.Set(validLEI, jwk) + + got, _ := s.Lookup(context.Background(), validLEI) + got[0] = 'X' // mutate the returned slice + + // Lookup again — should return unmutated bytes. + again, _ := s.Lookup(context.Background(), validLEI) + if string(again) != string(jwk) { + t.Errorf("stored bytes mutated by caller: got %q", again) + } +} + +func TestStaticAttestationSource_DefensiveCopyOnSet(t *testing.T) { + s := NewStaticAttestationSource() + jwk := []byte(`{"kty":"OKP","crv":"Ed25519","x":"abc"}`) + s.Set(validLEI, jwk) + + // Mutate the original slice the caller passed to Set. + jwk[0] = 'X' + + got, _ := s.Lookup(context.Background(), validLEI) + if got[0] == 'X' { + t.Error("Set should defensively copy; caller's mutation leaked into store") + } +} + +func TestStaticAttestationSource_OverwriteSet(t *testing.T) { + s := NewStaticAttestationSource() + first := []byte(`{"kty":"OKP","crv":"Ed25519","x":"first"}`) + second := []byte(`{"kty":"OKP","crv":"Ed25519","x":"second"}`) + s.Set(validLEI, first) + s.Set(validLEI, second) + + got, _ := s.Lookup(context.Background(), validLEI) + if string(got) != string(second) { + t.Errorf("overwrite failed: got %q, want %q", got, second) + } +} + +// TestResolver_Resolve_AttestationFromSource exercises the new +// composition: GLEIF entity check + AttestationJWKSource for the +// JWK. The Level 1 record has no AttestationJWK; the source supplies +// it; the resolver returns a complete IdentityClaim. +func TestResolver_Resolve_AttestationFromSource(t *testing.T) { + jwk := []byte(`{"kty":"OKP","crv":"Ed25519","x":"abc"}`) + + gleif := &fakeGLEIFClient{ + record: &GLEIFRecord{ + LEI: validLEI, + EntityName: "Test Entity", + EntityStatus: "ACTIVE", + Jurisdiction: "US-DE", + // AttestationJWK intentionally empty; comes from source. + }, + } + source := NewStaticAttestationSource() + source.Set(validLEI, jwk) + + r := New().WithClient(gleif).WithAttestationSource(source) + claim, err := r.Resolve(context.Background(), validLEI) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if string(claim.PublicKeyJWK) != string(jwk) { + t.Errorf("PublicKeyJWK: got %q, want %q", claim.PublicKeyJWK, jwk) + } + if claim.ResolvedID != validLEI { + t.Errorf("ResolvedID: got %q, want %q", claim.ResolvedID, validLEI) + } +} + +// TestResolver_Resolve_AttestationFallsBackToRecord verifies that a +// vLEI-aware GLEIFClient that already populates AttestationJWK takes +// precedence; the source is the fallback path. +func TestResolver_Resolve_AttestationFallsBackToRecord(t *testing.T) { + fromRecord := []byte(`{"kty":"OKP","crv":"Ed25519","x":"from-record"}`) + fromSource := []byte(`{"kty":"OKP","crv":"Ed25519","x":"from-source"}`) + + gleif := &fakeGLEIFClient{ + record: &GLEIFRecord{ + LEI: validLEI, + EntityStatus: "ACTIVE", + AttestationJWK: fromRecord, + }, + } + source := NewStaticAttestationSource() + source.Set(validLEI, fromSource) + + r := New().WithClient(gleif).WithAttestationSource(source) + claim, err := r.Resolve(context.Background(), validLEI) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if string(claim.PublicKeyJWK) != string(fromRecord) { + t.Errorf("expected record's AttestationJWK to win, got %q", claim.PublicKeyJWK) + } +} + +// TestResolver_Resolve_NoSourceNoKey verifies the existing +// LEI_NO_ATTESTATION_KEY error path: no source, no key on the +// record, error. +func TestResolver_Resolve_NoSourceNoKey(t *testing.T) { + gleif := &fakeGLEIFClient{ + record: &GLEIFRecord{ + LEI: validLEI, + EntityStatus: "ACTIVE", + }, + } + r := New().WithClient(gleif) + _, err := r.Resolve(context.Background(), validLEI) + if err == nil { + t.Fatal("expected LEI_NO_ATTESTATION_KEY error") + } +} diff --git a/internal/adapter/anchor/lei/gleif_client.go b/internal/adapter/anchor/lei/gleif_client.go new file mode 100644 index 0000000..cced78f --- /dev/null +++ b/internal/adapter/anchor/lei/gleif_client.go @@ -0,0 +1,151 @@ +// gleif_client.go ships a GLEIFClient that talks to the GLEIF +// Public API at api.gleif.org. The endpoint is free, public, and +// unauthenticated; production deployments may wrap it with caching, +// rate limiting, or LOU-mirror selection per anchor-0c-lei.md §3.3. +// +// The client populates the entity-status fields of GLEIFRecord +// (status, name, jurisdiction, updatedAt) from the Level 1 record +// the API returns. AttestationJWK is left empty: the GLEIF Level 1 +// record does not carry an attestation key. Deployments that need +// the AttestationJWK populate it through a separate +// AttestationJWKSource composed with the Resolver (see +// attestation_source.go). +// +// vLEI Option A (self-attestation through GLEIF vLEI infrastructure) +// and Option B (LOU custom field) both ride above this client; both +// add the AttestationJWK without changing the entity-verification +// pipeline this file implements. +package lei + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// gleifAPIBaseURL is the production GLEIF Public API base. Tests +// override it via WithGLEIFBaseURL(httptest.Server.URL). +const gleifAPIBaseURL = "https://api.gleif.org/api/v1" + +// GLEIFHTTPClient is the concrete GLEIFClient implementation. +type GLEIFHTTPClient struct { + baseURL string + http *http.Client +} + +// NewGLEIFHTTPClient returns a client pointing at the production +// GLEIF API with sensible HTTP timeouts. Production callers SHOULD +// wrap this with a caching layer (the GLEIF API rate-limits free +// callers) and a retry policy keyed on transient 5xx responses. +func NewGLEIFHTTPClient() *GLEIFHTTPClient { + return &GLEIFHTTPClient{ + baseURL: gleifAPIBaseURL, + http: &http.Client{ + Timeout: 15 * time.Second, + }, + } +} + +// WithBaseURL returns a copy with a different base URL. Tests use +// this to point at httptest.Server.URL. +func (c *GLEIFHTTPClient) WithBaseURL(url string) *GLEIFHTTPClient { + cp := *c + cp.baseURL = strings.TrimRight(url, "/") + return &cp +} + +// WithHTTPClient returns a copy with a different *http.Client. +// Production callers inject a client carrying their preferred +// transport (proxy, mTLS to a corporate egress, HTTP/2 connection +// pool, observability hooks). +func (c *GLEIFHTTPClient) WithHTTPClient(h *http.Client) *GLEIFHTTPClient { + cp := *c + cp.http = h + return &cp +} + +// gleifLevel1Response is the subset of the GLEIF Level 1 response +// the resolver consumes. The GLEIF API returns a JSON:API envelope +// (https://jsonapi.org); fields the resolver does not need are +// ignored. See https://documenter.getpostman.com/view/7679680/SVYrrxuU +// for the full schema. +type gleifLevel1Response struct { + Data struct { + Type string `json:"type"` + ID string `json:"id"` + Attributes struct { + LEI string `json:"lei"` + Entity struct { + LegalName struct { + Name string `json:"name"` + Language string `json:"language"` + } `json:"legalName"` + Status string `json:"status"` + LegalAddress struct { + Country string `json:"country"` + } `json:"legalAddress"` + Jurisdiction string `json:"jurisdiction"` + } `json:"entity"` + Registration struct { + Status string `json:"status"` + LastUpdateDate string `json:"lastUpdateDate"` + } `json:"registration"` + } `json:"attributes"` + } `json:"data"` +} + +// LookupRecord fetches the Level 1 record for an LEI from +// api.gleif.org. Returns nil with no error when the API responds +// 404 (record not found); any other error path returns a non-nil +// error so the resolver can surface a typed failure to its caller. +func (c *GLEIFHTTPClient) LookupRecord(ctx context.Context, lei string) (*GLEIFRecord, error) { + url := fmt.Sprintf("%s/lei-records/%s", c.baseURL, lei) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Accept", "application/vnd.api+json") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("gleif http: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + preview := string(body) + if len(preview) > 200 { + preview = preview[:200] + "..." + } + return nil, fmt.Errorf("gleif http %d: %s", resp.StatusCode, preview) + } + + var decoded gleifLevel1Response + if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { + return nil, fmt.Errorf("decode gleif response: %w", err) + } + if decoded.Data.Attributes.LEI == "" { + return nil, errors.New("gleif response missing data.attributes.lei") + } + + updatedAt, _ := time.Parse(time.RFC3339, decoded.Data.Attributes.Registration.LastUpdateDate) + + return &GLEIFRecord{ + LEI: decoded.Data.Attributes.LEI, + EntityName: decoded.Data.Attributes.Entity.LegalName.Name, + EntityStatus: decoded.Data.Attributes.Entity.Status, + Jurisdiction: decoded.Data.Attributes.Entity.Jurisdiction, + // AttestationJWK intentionally left empty; populated by a + // separate AttestationJWKSource composed with the Resolver. + UpdatedAt: updatedAt, + }, nil +} diff --git a/internal/adapter/anchor/lei/gleif_client_test.go b/internal/adapter/anchor/lei/gleif_client_test.go new file mode 100644 index 0000000..d62fd9b --- /dev/null +++ b/internal/adapter/anchor/lei/gleif_client_test.go @@ -0,0 +1,173 @@ +package lei + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// gleifLevel1Body is the JSON:API envelope shape api.gleif.org returns +// for a Level 1 record. Tests build a stub server that returns this. +const gleifLevel1Body = `{ + "data": { + "type": "lei-records", + "id": "529900T8BM49AURSDO55", + "attributes": { + "lei": "529900T8BM49AURSDO55", + "entity": { + "legalName": {"name": "Example Bank Inc.", "language": "en"}, + "status": "ACTIVE", + "legalAddress": {"country": "US"}, + "jurisdiction": "US-DE" + }, + "registration": { + "status": "ISSUED", + "lastUpdateDate": "2026-01-15T10:30:00Z" + } + } + } +}` + +func TestGLEIFHTTPClient_LookupRecord_Active(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/lei-records/529900T8BM49AURSDO55" { + t.Errorf("unexpected path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + if accept := r.Header.Get("Accept"); !strings.Contains(accept, "json") { + t.Errorf("missing or wrong Accept header: %q", accept) + } + w.Header().Set("Content-Type", "application/vnd.api+json") + _, _ = w.Write([]byte(gleifLevel1Body)) + })) + defer srv.Close() + + c := NewGLEIFHTTPClient().WithBaseURL(srv.URL) + rec, err := c.LookupRecord(context.Background(), "529900T8BM49AURSDO55") + if err != nil { + t.Fatalf("LookupRecord: %v", err) + } + if rec == nil { + t.Fatal("expected record, got nil") + } + if rec.LEI != "529900T8BM49AURSDO55" { + t.Errorf("LEI: got %q", rec.LEI) + } + if rec.EntityName != "Example Bank Inc." { + t.Errorf("EntityName: got %q", rec.EntityName) + } + if rec.EntityStatus != "ACTIVE" { + t.Errorf("EntityStatus: got %q", rec.EntityStatus) + } + if rec.Jurisdiction != "US-DE" { + t.Errorf("Jurisdiction: got %q", rec.Jurisdiction) + } + want, _ := time.Parse(time.RFC3339, "2026-01-15T10:30:00Z") + if !rec.UpdatedAt.Equal(want) { + t.Errorf("UpdatedAt: got %v, want %v", rec.UpdatedAt, want) + } + if len(rec.AttestationJWK) != 0 { + t.Error("AttestationJWK should be empty for Level 1 records") + } +} + +func TestGLEIFHTTPClient_LookupRecord_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, `{"errors":[{"status":"404"}]}`, http.StatusNotFound) + })) + defer srv.Close() + + c := NewGLEIFHTTPClient().WithBaseURL(srv.URL) + rec, err := c.LookupRecord(context.Background(), "529900T8BM49AURSDO55") + if err != nil { + t.Fatalf("expected nil error on 404, got %v", err) + } + if rec != nil { + t.Errorf("expected nil record on 404, got %+v", rec) + } +} + +func TestGLEIFHTTPClient_LookupRecord_5xx(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "rate limited", http.StatusTooManyRequests) + })) + defer srv.Close() + + c := NewGLEIFHTTPClient().WithBaseURL(srv.URL) + rec, err := c.LookupRecord(context.Background(), "529900T8BM49AURSDO55") + if err == nil { + t.Fatal("expected error on 429") + } + if rec != nil { + t.Errorf("expected nil record on error, got %+v", rec) + } + if !strings.Contains(err.Error(), "429") { + t.Errorf("error should mention status code: %v", err) + } +} + +func TestGLEIFHTTPClient_LookupRecord_BadJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/vnd.api+json") + _, _ = w.Write([]byte(`{not valid json`)) + })) + defer srv.Close() + + c := NewGLEIFHTTPClient().WithBaseURL(srv.URL) + _, err := c.LookupRecord(context.Background(), "529900T8BM49AURSDO55") + if err == nil { + t.Fatal("expected decode error") + } +} + +func TestGLEIFHTTPClient_LookupRecord_MissingLEI(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/vnd.api+json") + _, _ = w.Write([]byte(`{"data":{"type":"lei-records","id":"x","attributes":{}}}`)) + })) + defer srv.Close() + + c := NewGLEIFHTTPClient().WithBaseURL(srv.URL) + _, err := c.LookupRecord(context.Background(), "529900T8BM49AURSDO55") + if err == nil { + t.Fatal("expected error when response is missing data.attributes.lei") + } +} + +func TestGLEIFHTTPClient_LookupRecord_ContextCancellation(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(200 * time.Millisecond) + w.Header().Set("Content-Type", "application/vnd.api+json") + _, _ = w.Write([]byte(gleifLevel1Body)) + })) + defer srv.Close() + + c := NewGLEIFHTTPClient().WithBaseURL(srv.URL) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + _, err := c.LookupRecord(ctx, "529900T8BM49AURSDO55") + if err == nil { + t.Fatal("expected error on context timeout") + } +} + +func TestGLEIFHTTPClient_BaseURLTrailingSlashStripped(t *testing.T) { + c := NewGLEIFHTTPClient().WithBaseURL("https://example.test/") + if !strings.HasSuffix(c.baseURL, "test") { + t.Errorf("trailing slash not stripped: baseURL=%q", c.baseURL) + } +} + +func TestGLEIFHTTPClient_DefaultBaseURL(t *testing.T) { + c := NewGLEIFHTTPClient() + if c.baseURL != gleifAPIBaseURL { + t.Errorf("default base URL: got %q, want %q", c.baseURL, gleifAPIBaseURL) + } + if c.http.Timeout == 0 { + t.Error("HTTP client should have a non-zero timeout") + } +} diff --git a/internal/adapter/anchor/lei/resolver.go b/internal/adapter/anchor/lei/resolver.go index 4711a61..d71ca97 100644 --- a/internal/adapter/anchor/lei/resolver.go +++ b/internal/adapter/anchor/lei/resolver.go @@ -65,10 +65,12 @@ type GLEIFRecord struct { // Resolver implements port.AnchorResolver for LEI anchors. The // stub form (no client injected) handles format-only resolution and // surfaces a clear LEI_GLEIF_NOT_CONFIGURED error on Resolve. -// Production deployments inject a GLEIFClient via WithClient. +// Production deployments inject a GLEIFClient via WithClient and an +// AttestationJWKSource via WithAttestationSource. type Resolver struct { - client GLEIFClient - clock func() time.Time + client GLEIFClient + attestSource AttestationJWKSource + clock func() time.Time } // New constructs a Resolver with no GLEIF client. Format @@ -83,13 +85,23 @@ func New() *Resolver { // rate limiting, and any LOU mirror selection per // anchor-0c-lei.md §3.3. func (r *Resolver) WithClient(c GLEIFClient) *Resolver { - return &Resolver{client: c, clock: r.clock} + return &Resolver{client: c, attestSource: r.attestSource, clock: r.clock} +} + +// WithAttestationSource injects a source for the entity's +// attestation JWK and returns a copy of the resolver. The +// AttestationJWKSource is separate from the GLEIFClient because the +// GLEIF Public API does not carry an attestation key; deployments +// supply the key through vLEI infrastructure, an LOU custom-field +// mirror, or (for testbeds) a static map. +func (r *Resolver) WithAttestationSource(s AttestationJWKSource) *Resolver { + return &Resolver{client: r.client, attestSource: s, clock: r.clock} } // WithClock returns a copy of the resolver with a deterministic // clock. Tests use this so IssuedAt is reproducible. func (r *Resolver) WithClock(clock func() time.Time) *Resolver { - return &Resolver{client: r.client, clock: clock} + return &Resolver{client: r.client, attestSource: r.attestSource, clock: clock} } // SupportedProfiles satisfies port.AnchorResolver. @@ -143,17 +155,38 @@ func (r *Resolver) Resolve(ctx context.Context, input string) (*domain.IdentityC "entity status is "+record.EntityStatus+"; only ACTIVE LEIs admitted", ) } - if len(record.AttestationJWK) == 0 { + + // Attestation key resolution. The GLEIF Public API Level 1 + // record does not carry the entity's attestation JWK, so the + // resolver consults the configured AttestationJWKSource. The + // record's own AttestationJWK field stays usable for clients + // that already populate it (vLEI Option A clients may return + // the key inline); the source is the fallback path when the + // client does not. + jwk := record.AttestationJWK + if len(jwk) == 0 && r.attestSource != nil { + fromSource, sErr := r.attestSource.Lookup(ctx, canonical) + if sErr != nil { + return nil, domain.NewValidationError( + "LEI_ATTESTATION_LOOKUP_FAILED", + "attestation source error: "+sErr.Error(), + ) + } + jwk = fromSource + } + if len(jwk) == 0 { return nil, domain.NewValidationError( "LEI_NO_ATTESTATION_KEY", - "GLEIF record has no ANS attestation key registered for "+canonical, + "no ANS attestation key for "+canonical+ + ": configure WithAttestationSource or supply a vLEI-aware GLEIFClient", ) } + now := r.clock().UTC() return &domain.IdentityClaim{ AnchorType: domain.AnchorTypeLEI, ResolvedID: canonical, - PublicKeyJWK: record.AttestationJWK, + PublicKeyJWK: jwk, IssuedAt: now, ExpiresAt: now.Add(freshnessBudget), }, nil