From 1b502cb700b418f9141b58c3b20b67de2c3b208c Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Thu, 14 May 2026 17:33:22 +0100 Subject: [PATCH 1/4] feat: port DID resolvers --- capabilities/blob/datamodel/accept.go | 4 +- capabilities/blob/datamodel/allocate.go | 4 +- capabilities/blob/datamodel/replicate.go | 4 +- .../blob/replica/datamodel/allocate.go | 3 +- .../blob/replica/datamodel/transfer.go | 5 +- didresolver/cacheresolver.go | 33 + didresolver/cacheresolver_test.go | 324 +++++++++ didresolver/httpresolver.go | 277 ++++++++ didresolver/httpresolver_test.go | 637 ++++++++++++++++++ didresolver/mapresolver.go | 43 ++ didresolver/mapresolver_test.go | 30 + didresolver/tieredresolver.go | 31 + didresolver/tieredresolver_test.go | 196 ++++++ go.mod | 5 +- go.sum | 6 +- receipt/client.go | 5 +- ucan/attestations.go | 11 +- ucan/attestations_test.go | 54 +- ucan/proof_chain.go | 18 +- ucan/proof_chain_test.go | 50 +- 20 files changed, 1660 insertions(+), 80 deletions(-) create mode 100644 didresolver/cacheresolver.go create mode 100644 didresolver/cacheresolver_test.go create mode 100644 didresolver/httpresolver.go create mode 100644 didresolver/httpresolver_test.go create mode 100644 didresolver/mapresolver.go create mode 100644 didresolver/mapresolver_test.go create mode 100644 didresolver/tieredresolver.go create mode 100644 didresolver/tieredresolver_test.go diff --git a/capabilities/blob/datamodel/accept.go b/capabilities/blob/datamodel/accept.go index b75cb1f..f5033aa 100644 --- a/capabilities/blob/datamodel/accept.go +++ b/capabilities/blob/datamodel/accept.go @@ -1,8 +1,8 @@ package datamodel import ( - "github.com/fil-forge/ucantone/ucan" "github.com/fil-forge/ucantone/ucan/promise" + cid "github.com/ipfs/go-cid" ) type AcceptArgumentsModel struct { @@ -11,5 +11,5 @@ type AcceptArgumentsModel struct { } type AcceptOKModel struct { - Site ucan.Link `cborgen:"site" dagjsongen:"site"` + Site cid.Cid `cborgen:"site" dagjsongen:"site"` } diff --git a/capabilities/blob/datamodel/allocate.go b/capabilities/blob/datamodel/allocate.go index 4747a38..4dcaa0a 100644 --- a/capabilities/blob/datamodel/allocate.go +++ b/capabilities/blob/datamodel/allocate.go @@ -2,12 +2,12 @@ package datamodel import ( "github.com/fil-forge/libforge/capabilities" - "github.com/fil-forge/ucantone/ucan" + cid "github.com/ipfs/go-cid" ) type AllocateArgumentsModel struct { Blob BlobModel `cborgen:"blob" dagjsongen:"blob"` - Cause ucan.Link `cborgen:"cause" dagjsongen:"cause"` + Cause cid.Cid `cborgen:"cause" dagjsongen:"cause"` } type AllocateOKModel struct { diff --git a/capabilities/blob/datamodel/replicate.go b/capabilities/blob/datamodel/replicate.go index 5835d75..72d1b08 100644 --- a/capabilities/blob/datamodel/replicate.go +++ b/capabilities/blob/datamodel/replicate.go @@ -1,8 +1,8 @@ package datamodel import ( - "github.com/fil-forge/ucantone/ucan" "github.com/fil-forge/ucantone/ucan/promise" + cid "github.com/ipfs/go-cid" ) type ReplicateArgumentsModel struct { @@ -13,7 +13,7 @@ type ReplicateArgumentsModel struct { Replicas uint64 `cborgen:"replicas" dagjsongen:"replicas"` // Site is a link to a location commitment indicating where the Blob must be // fetched from. - Site ucan.Link `cborgen:"site" dagjsongen:"site"` + Site cid.Cid `cborgen:"site" dagjsongen:"site"` } type ReplicateOKModel struct { diff --git a/capabilities/blob/replica/datamodel/allocate.go b/capabilities/blob/replica/datamodel/allocate.go index 83e1c30..d4ce122 100644 --- a/capabilities/blob/replica/datamodel/allocate.go +++ b/capabilities/blob/replica/datamodel/allocate.go @@ -1,7 +1,6 @@ package datamodel import ( - "github.com/fil-forge/ucantone/ucan" "github.com/fil-forge/ucantone/ucan/promise" "github.com/ipfs/go-cid" "github.com/multiformats/go-multihash" @@ -17,7 +16,7 @@ type AllocateArgumentsModel struct { Blob BlobModel `cborgen:"blob"` // Site is a link to a location commitment indicating where the Blob must be // fetched from. - Site ucan.Link `cborgen:"site"` + Site cid.Cid `cborgen:"site"` // Cause is a link to the `/blob/replicate` task that caused this allocation. Cause cid.Cid `cborgen:"cause"` } diff --git a/capabilities/blob/replica/datamodel/transfer.go b/capabilities/blob/replica/datamodel/transfer.go index 41fff1f..33c1796 100644 --- a/capabilities/blob/replica/datamodel/transfer.go +++ b/capabilities/blob/replica/datamodel/transfer.go @@ -1,7 +1,6 @@ package datamodel import ( - "github.com/fil-forge/ucantone/ucan" "github.com/fil-forge/ucantone/ucan/promise" "github.com/ipfs/go-cid" ) @@ -11,7 +10,7 @@ type TransferArgumentsModel struct { Blob BlobModel `cborgen:"blob"` // Site is a link to a location commitment indicating where the Blob must be // fetched from. - Site ucan.Link `cborgen:"site"` + Site cid.Cid `cborgen:"site"` // Cause links to the `/blob/replica/allocate` task that initiated this transfer. Cause cid.Cid `cborgen:"cause"` } @@ -19,7 +18,7 @@ type TransferArgumentsModel struct { type TransferOKModel struct { // Site links to the location commitment that indicate where the Blob has been // transferred to. - Site ucan.Link `cborgen:"site"` + Site cid.Cid `cborgen:"site"` // PDP links to the /pdp/accept task that will resolve when aggregation // is complete and the piece is accepted. PDP promise.AwaitOK `cborgen:"pdp"` diff --git a/didresolver/cacheresolver.go b/didresolver/cacheresolver.go new file mode 100644 index 0000000..6b1a4d9 --- /dev/null +++ b/didresolver/cacheresolver.go @@ -0,0 +1,33 @@ +package didresolver + +import ( + "context" + "time" + + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/ucan" + "github.com/patrickmn/go-cache" +) + +type CachedResolver struct { + wrapped DIDVerifierResolverFunc + cache *cache.Cache +} + +func NewCachedResolver(wrapped DIDVerifierResolverFunc, ttl time.Duration) (*CachedResolver, error) { + // items remain in the cache for `ttl`, expired items are purged every hour. + return &CachedResolver{wrapped: wrapped, cache: cache.New(ttl, time.Hour)}, nil +} + +func (c *CachedResolver) Resolve(ctx context.Context, input did.DID) (ucan.Verifier, error) { + if out, found := c.cache.Get(input.String()); found { + return out.(ucan.Verifier), nil + } + out, err := c.wrapped(ctx, input) + if err != nil { + return nil, err + } + c.cache.Set(input.String(), out, cache.DefaultExpiration) + + return out, nil +} diff --git a/didresolver/cacheresolver_test.go b/didresolver/cacheresolver_test.go new file mode 100644 index 0000000..07b9bc6 --- /dev/null +++ b/didresolver/cacheresolver_test.go @@ -0,0 +1,324 @@ +package didresolver_test + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/fil-forge/libforge/didresolver" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/principal/ed25519/verifier" + "github.com/fil-forge/ucantone/ucan" + verrs "github.com/fil-forge/ucantone/validator/errors" + "github.com/stretchr/testify/require" +) + +type mockResolver struct { + resolveFn didresolver.DIDVerifierResolverFunc + callCount int32 +} + +func (m *mockResolver) ResolveDIDKey(ctx context.Context, input did.DID) (ucan.Verifier, error) { + atomic.AddInt32(&m.callCount, 1) + if m.resolveFn != nil { + return m.resolveFn(ctx, input) + } + return nil, fmt.Errorf("mock error") +} + +func (m *mockResolver) getCallCount() int { + return int(atomic.LoadInt32(&m.callCount)) +} + +func TestNewCachedResolver(t *testing.T) { + t.Run("creates resolver with valid TTL", func(t *testing.T) { + mockWrapped := &mockResolver{} + resolver, err := didresolver.NewCachedResolver(mockWrapped.ResolveDIDKey, 5*time.Minute) + require.NoError(t, err) + require.NotNil(t, resolver) + }) + + t.Run("creates resolver with zero TTL", func(t *testing.T) { + mockWrapped := &mockResolver{} + resolver, err := didresolver.NewCachedResolver(mockWrapped.ResolveDIDKey, 0) + require.NoError(t, err) + require.NotNil(t, resolver) + }) +} + +func TestCachedResolver_ResolveDIDKey(t *testing.T) { + t.Run("caches successful resolution", func(t *testing.T) { + didWeb, err := did.Parse("did:web:example.com") + require.NoError(t, err) + + didKey, err := verifier.Parse("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") + require.NoError(t, err) + + mock := &mockResolver{ + resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { + if input.String() == didWeb.String() { + return didKey, nil + } + return nil, fmt.Errorf("not found") + }, + } + + resolver, err := didresolver.NewCachedResolver(mock.ResolveDIDKey, 100*time.Millisecond) + require.NoError(t, err) + + // First call should hit the wrapped resolver + result1, err1 := resolver.Resolve(t.Context(), didWeb) + require.Nil(t, err1) + require.Equal(t, didKey, result1) + require.Equal(t, 1, mock.getCallCount()) + + // Second call should use cache + result2, err2 := resolver.Resolve(t.Context(), didWeb) + require.Nil(t, err2) + require.Equal(t, didKey, result2) + require.Equal(t, 1, mock.getCallCount()) // No additional call + + // Wait for cache to expire + time.Sleep(150 * time.Millisecond) + + // Third call should hit the wrapped resolver again + result3, err3 := resolver.Resolve(t.Context(), didWeb) + require.Nil(t, err3) + require.Equal(t, didKey, result3) + require.Equal(t, 2, mock.getCallCount()) + }) + + t.Run("does not cache errors", func(t *testing.T) { + didWeb, err := did.Parse("did:web:example.com") + require.NoError(t, err) + + mock := &mockResolver{ + resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { + return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("resolution failed")) + }, + } + + resolver, err := didresolver.NewCachedResolver(mock.ResolveDIDKey, 100*time.Millisecond) + require.NoError(t, err) + + // First call + result1, err1 := resolver.Resolve(t.Context(), didWeb) + require.NotNil(t, err1) + require.Nil(t, result1) + require.Equal(t, 1, mock.getCallCount()) + + // Second call should still hit the wrapped resolver (errors not cached) + result2, err2 := resolver.Resolve(t.Context(), didWeb) + require.NotNil(t, err2) + require.Nil(t, result2) + require.Equal(t, 2, mock.getCallCount()) + }) + + t.Run("handles concurrent access", func(t *testing.T) { + didWeb, err := did.Parse("did:web:example.com") + require.NoError(t, err) + + didKey, err := verifier.Parse("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") + require.NoError(t, err) + + var resolverCalls int32 + mock := &mockResolver{ + resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { + atomic.AddInt32(&resolverCalls, 1) + time.Sleep(10 * time.Millisecond) // Simulate slow resolution + return didKey, nil + }, + } + + resolver, err := didresolver.NewCachedResolver(mock.ResolveDIDKey, 1*time.Second) + require.NoError(t, err) + + var wg sync.WaitGroup + results := make([]ucan.Verifier, 10) + errors := make([]error, 10) + + // Launch 10 concurrent requests + for i := 0; i < 10; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + results[idx], errors[idx] = resolver.Resolve(t.Context(), didWeb) + }(i) + } + + wg.Wait() + + // All should succeed with the same result + for i := 0; i < 10; i++ { + require.Nil(t, errors[i]) + require.Equal(t, didKey, results[i]) + } + + // Due to caching, we expect fewer calls than requests + // But with very fast concurrent access, all 10 might hit before the first one finishes + actualCalls := atomic.LoadInt32(&resolverCalls) + require.LessOrEqual(t, actualCalls, int32(10)) + // But at least we should have gotten some caching benefit on subsequent calls + // Let's do another call to verify caching is working + result, err := resolver.Resolve(t.Context(), didWeb) + require.Nil(t, err) + require.Equal(t, didKey, result) + // This call should definitely use the cache + finalCalls := atomic.LoadInt32(&resolverCalls) + require.Equal(t, actualCalls, finalCalls) + }) + + t.Run("handles different DIDs independently", func(t *testing.T) { + did1, err := did.Parse("did:web:example1.com") + require.NoError(t, err) + + did2, err := did.Parse("did:web:example2.com") + require.NoError(t, err) + + didKey1, err := verifier.Parse("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") + require.NoError(t, err) + + didKey2, err := verifier.Parse("did:key:z6Mkfriq1MqLBoPWecGoDLjguo1sB9brj6wT3qZ5BxkKpuP6") + require.NoError(t, err) + + mock := &mockResolver{ + resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { + switch input.String() { + case did1.String(): + return didKey1, nil + case did2.String(): + return didKey2, nil + default: + return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("unknown DID")) + } + }, + } + + resolver, err := didresolver.NewCachedResolver(mock.ResolveDIDKey, 1*time.Second) + require.NoError(t, err) + + // Resolve first DID + result1, err1 := resolver.Resolve(t.Context(), did1) + require.Nil(t, err1) + require.Equal(t, didKey1, result1) + require.Equal(t, 1, mock.getCallCount()) + + // Resolve second DID + result2, err2 := resolver.Resolve(t.Context(), did2) + require.Nil(t, err2) + require.Equal(t, didKey2, result2) + require.Equal(t, 2, mock.getCallCount()) + + // Resolve first DID again (should be cached) + result3, err3 := resolver.Resolve(t.Context(), did1) + require.Nil(t, err3) + require.Equal(t, didKey1, result3) + require.Equal(t, 2, mock.getCallCount()) // No additional call + + // Resolve second DID again (should be cached) + result4, err4 := resolver.Resolve(t.Context(), did2) + require.Nil(t, err4) + require.Equal(t, didKey2, result4) + require.Equal(t, 2, mock.getCallCount()) // No additional call + }) +} + +func TestCachedResolver_WithFixedImplementation(t *testing.T) { + // This test verifies the bug is fixed + t.Run("wrapped resolver works correctly", func(t *testing.T) { + didWeb, err := did.Parse("did:web:example.com") + require.NoError(t, err) + + didKey, err := verifier.Parse("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") + require.NoError(t, err) + + mock := &mockResolver{ + resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { + return didKey, nil + }, + } + + resolver, err := didresolver.NewCachedResolver(mock.ResolveDIDKey, 1*time.Second) + require.NoError(t, err) + + // Should not panic and should return the expected result + result, unresolvedErr := resolver.Resolve(t.Context(), didWeb) + require.Nil(t, unresolvedErr) + require.Equal(t, didKey, result) + }) +} + +func TestCachedResolver_WithMapResolver(t *testing.T) { + t.Run("caches MapResolver lookups", func(t *testing.T) { + // Create a mapping of DIDs + mapping := map[string]string{ + "did:web:alice.example.com": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "did:web:bob.example.com": "did:key:z6Mkfriq1MqLBoPWecGoDLjguo1sB9brj6wT3qZ5BxkKpuP6", + "did:web:carol.example.com": "did:key:z6MkwXG2WjeQnNxSoynSGYU8V9j3QzP3JSqhdmkHc6SaVWoV", + } + + // Create MapResolver + mapResolver, err := didresolver.NewMapResolver(mapping) + require.NoError(t, err) + + // Wrap it with CacheResolver + cachedResolver, err := didresolver.NewCachedResolver(mapResolver.Resolve, 200*time.Millisecond) + require.NoError(t, err) + + // Test alice + aliceDID, err := did.Parse("did:web:alice.example.com") + require.NoError(t, err) + aliceKey, err := verifier.Parse("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") + require.NoError(t, err) + + // First call - should hit MapResolver + result1, err1 := cachedResolver.Resolve(t.Context(), aliceDID) + require.Nil(t, err1) + require.Equal(t, aliceKey, result1) + + // Second call - should use cache (we can't directly verify this without instrumentation) + result2, err2 := cachedResolver.Resolve(t.Context(), aliceDID) + require.Nil(t, err2) + require.Equal(t, aliceKey, result2) + + // Test bob while alice is still cached + bobDID, err := did.Parse("did:web:bob.example.com") + require.NoError(t, err) + bobKey, err := verifier.Parse("did:key:z6Mkfriq1MqLBoPWecGoDLjguo1sB9brj6wT3qZ5BxkKpuP6") + require.NoError(t, err) + + result3, err3 := cachedResolver.Resolve(t.Context(), bobDID) + require.Nil(t, err3) + require.Equal(t, bobKey, result3) + + // Wait for cache to expire + time.Sleep(250 * time.Millisecond) + + // Alice's entry should have expired, this should hit MapResolver again + result4, err4 := cachedResolver.Resolve(t.Context(), aliceDID) + require.Nil(t, err4) + require.Equal(t, aliceKey, result4) + + // Test non-existent DID + unknownDID, err := did.Parse("did:web:unknown.example.com") + require.NoError(t, err) + + result5, err5 := cachedResolver.Resolve(t.Context(), unknownDID) + require.NotNil(t, err5) + require.Nil(t, result5) + require.Contains(t, err5.Error(), "unable to resolve") + }) + + t.Run("handles invalid mappings gracefully", func(t *testing.T) { + // Test with invalid DID in mapping + invalidMapping := map[string]string{ + "invalid-did": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + } + + _, err := didresolver.NewMapResolver(invalidMapping) + require.Error(t, err) + }) +} diff --git a/didresolver/httpresolver.go b/didresolver/httpresolver.go new file mode 100644 index 0000000..cd85d13 --- /dev/null +++ b/didresolver/httpresolver.go @@ -0,0 +1,277 @@ +package didresolver + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/principal/ed25519/verifier" + "github.com/fil-forge/ucantone/ucan" + verrs "github.com/fil-forge/ucantone/validator/errors" + "github.com/gobwas/glob" +) + +// FlexibleContext handles both string and []string formats for @context field +// as allowed by the DID Core specification +type FlexibleContext []string + +func (fc *FlexibleContext) UnmarshalJSON(data []byte) error { + // Try array first (most common format) + var arr []string + if err := json.Unmarshal(data, &arr); err == nil { + *fc = FlexibleContext(arr) + return nil + } + + // Fall back to single string format + var str string + if err := json.Unmarshal(data, &str); err == nil { + *fc = FlexibleContext([]string{str}) + return nil + } + + return fmt.Errorf("@context must be string or array of strings") +} + +// Document is a did document that describes a did subject. +// See https://www.w3.org/TR/did-core/#dfn-did-documents. +// Copied from: https://github.com/storacha/indexing-service/blob/fe8f2211a15d851f2672bfeb64dcfc65c52e6011/pkg/server/server.go#L238 +type Document struct { + Context FlexibleContext `json:"@context"` // https://w3id.org/did/v1 + ID string `json:"id"` + Controller []string `json:"controller,omitempty"` + VerificationMethod []VerificationMethod `json:"verificationMethod,omitempty"` + Authentication []string `json:"authentication,omitempty"` + AssertionMethod []string `json:"assertionMethod,omitempty"` +} + +// VerificationMethod describes how to authenticate or authorize interactions +// with a did subject. +// See https://www.w3.org/TR/did-core/#dfn-verification-method. +type VerificationMethod struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Controller string `json:"controller,omitempty"` + PublicKeyMultibase string `json:"publicKeyMultibase,omitempty"` +} + +type HTTPResolver struct { + // mapping of did:web to url of service, where we fetch .well-known/did.json to obtain their did:key key + webKeys map[did.DID]url.URL + cfg config +} + +type config struct { + timeout time.Duration + insecure bool + globs map[string]glob.Glob +} + +type Option func(*config) error + +func WithTimeout(timeout time.Duration) Option { + return func(c *config) error { + if timeout == 0 { + return fmt.Errorf("timeout cannot be zero") + } + c.timeout = timeout + return nil + } +} + +func InsecureResolution() Option { + return func(c *config) error { + c.insecure = true + return nil + } +} + +// WithPatterns allows resolving of did:web's that match the provided glob +// pattern(s). +// +// Note: the pattern does not need to include the "did:web:" prefix. +func WithPatterns(patterns ...string) Option { + return func(c *config) error { + for _, p := range patterns { + g, err := glob.Compile(p) + if err != nil { + return fmt.Errorf("compiling pattern %q: %w", p, err) + } + if c.globs == nil { + c.globs = map[string]glob.Glob{} + } + c.globs[p] = g + } + return nil + } +} + +const didWebPrefix = "did:web:" + +// ExtractDomainFromDID extracts the domain from a DID web string +func ExtractDomainFromDID(didWeb did.DID) (string, error) { + // Check if it starts with the required prefix + if !strings.HasPrefix(didWeb.String(), didWebPrefix) { + return "", fmt.Errorf("invalid DID web format: must start with '%s'", didWebPrefix) + } + + // Extract the domain part + domain := strings.TrimPrefix(didWeb.String(), didWebPrefix) + + // Check if domain is empty + if domain == "" { + return "", fmt.Errorf("invalid DID web format: no domain specified") + } + + // Validate the domain format + if err := validateDomain(domain); err != nil { + return "", fmt.Errorf("invalid domain '%s': %w", domain, err) + } + + return domain, nil +} + +// validateDomain checks if a string is a valid domain name +func validateDomain(domain string) error { + // Basic length check + if len(domain) > 253 { + return fmt.Errorf("domain too long (max 253 characters)") + } + + // TODO we could do further checking that the domain is valid, length seems fine for now. + + return nil +} + +func WellKnownEndpointFromDID(didWeb did.DID, insecure bool) (url.URL, error) { + domain, err := ExtractDomainFromDID(didWeb) + if err != nil { + return url.URL{}, err + } + + schema := "https" + if insecure { + schema = "http" + } + + endpoint := url.URL{ + Scheme: schema, + Host: domain, + Path: WellKnownDIDPath, + } + + if _, err := url.Parse(endpoint.String()); err != nil { + return url.URL{}, fmt.Errorf("invalid did domain: %w", err) + } + + return endpoint, nil +} + +const WellKnownDIDPath = "/.well-known/did.json" + +func NewHTTPResolver(webKeys []did.DID, opts ...Option) (*HTTPResolver, error) { + cfg := &config{ + timeout: 10 * time.Second, + insecure: false, + } + for _, opt := range opts { + if err := opt(cfg); err != nil { + return nil, err + } + } + + // Convert string map to DID/URL map + didMap := make(map[did.DID]url.URL) + for _, w := range webKeys { + if _, ok := didMap[w]; ok { + return nil, fmt.Errorf("duplicate did's provided") + } + endpoint, err := WellKnownEndpointFromDID(w, cfg.insecure) + if err != nil { + return nil, err + } + didMap[w] = endpoint + } + // default timeout of 10 seconds, options can override + resolver := &HTTPResolver{webKeys: didMap, cfg: *cfg} + return resolver, nil +} + +func (r *HTTPResolver) Resolve(ctx context.Context, input did.DID) (ucan.Verifier, error) { + endpoint, ok := r.webKeys[input] + if !ok { // if not in allowed web keys, try globs + for _, g := range r.cfg.globs { + ok = g.Match(strings.TrimPrefix(input.String(), didWebPrefix)) + if ok { + u, err := WellKnownEndpointFromDID(input, r.cfg.insecure) + if err != nil { + return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("invalid DID: %w", err)) + } + endpoint = u + break + } + } + } + if !ok { + return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("resolution via HTTP not permitted")) + } + ctx, cancel := context.WithTimeout(ctx, r.cfg.timeout) + defer cancel() + didDoc, err := fetchDIDDocument(ctx, endpoint) + if err != nil { + return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("failed to fetch DID document: %w", err)) + } + if len(didDoc.VerificationMethod) == 0 { + return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("missing verificationMethod in DID document")) + } + + pubKeyStr := didDoc.VerificationMethod[0].PublicKeyMultibase + if pubKeyStr == "" { + return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("missing publicKeyMultibase in DID document")) + } + + // TODO: multiple verification methods when https://github.com/fil-forge/ucantone/pull/7 lands + didKey, err := verifier.Parse(fmt.Sprintf("did:key:%s", pubKeyStr)) + if err != nil { + return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("parsing multibase key: %w", err)) + } + + return didKey, nil +} + +func fetchDIDDocument(ctx context.Context, endpoint url.URL) (*Document, error) { + req, err := http.NewRequestWithContext(ctx, "GET", endpoint.String(), nil) + if err != nil { + return nil, fmt.Errorf("creating HTTP request: %w", err) + } + + client := &http.Client{} + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("sending HTTP request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + var didDoc Document + if err := json.Unmarshal(body, &didDoc); err != nil { + return nil, fmt.Errorf("parsing DID document JSON: %w", err) + } + + return &didDoc, nil +} diff --git a/didresolver/httpresolver_test.go b/didresolver/httpresolver_test.go new file mode 100644 index 0000000..d4524a3 --- /dev/null +++ b/didresolver/httpresolver_test.go @@ -0,0 +1,637 @@ +package didresolver_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/fil-forge/libforge/didresolver" + "github.com/fil-forge/ucantone/did" + "github.com/stretchr/testify/require" +) + +func TestNewHTTPResolver(t *testing.T) { + t.Run("creates resolver with default timeout", func(t *testing.T) { + mapping := make([]did.DID, 0) + resolver, err := didresolver.NewHTTPResolver(mapping) + require.NoError(t, err) + require.NotNil(t, resolver) + }) + + t.Run("creates resolver with custom timeout", func(t *testing.T) { + mapping := make([]did.DID, 0) + resolver, err := didresolver.NewHTTPResolver(mapping, didresolver.WithTimeout(5*time.Second), didresolver.InsecureResolution()) + require.NoError(t, err) + require.NotNil(t, resolver) + }) + + t.Run("fails with zero timeout", func(t *testing.T) { + mapping := make([]did.DID, 0) + resolver, err := didresolver.NewHTTPResolver(mapping, didresolver.WithTimeout(0)) + require.Error(t, err) + require.Contains(t, err.Error(), "timeout cannot be zero") + require.Nil(t, resolver) + }) + + t.Run("fails with duplicate DIDs", func(t *testing.T) { + didWeb, _ := did.Parse("did:web:example.com") + mapping := []did.DID{didWeb, didWeb} + resolver, err := didresolver.NewHTTPResolver(mapping) + require.Error(t, err) + require.Contains(t, err.Error(), "duplicate did's provided") + require.Nil(t, resolver) + }) + + t.Run("fails with invalid DID format", func(t *testing.T) { + didWeb, _ := did.Parse("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") + mapping := []did.DID{didWeb} + resolver, err := didresolver.NewHTTPResolver(mapping) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid DID web format") + require.Nil(t, resolver) + }) +} + +func TestHTTPResolver_ResolveDIDKey(t *testing.T) { + testCases := []struct { + name string + setupServer func() *httptest.Server + setupMapping func(serverURL string) []did.DID + setupGlobbing func(serverURL string) []string + inputDID string + expectedDIDKey string + expectError bool + errorContains string + }{ + { + name: "successful resolution", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != didresolver.WellKnownDIDPath { + w.WriteHeader(http.StatusNotFound) + return + } + doc := didresolver.Document{ + Context: []string{"https://w3id.org/did/v1"}, + ID: "did:web:example.com", + VerificationMethod: []didresolver.VerificationMethod{ + { + ID: "did:web:example.com#key1", + Type: "Ed25519VerificationKey2018", + Controller: "did:web:example.com", + PublicKeyMultibase: "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(doc) + })) + }, + setupMapping: func(serverURL string) []did.DID { + // Extract domain from server URL to create matching did:web + u, _ := url.Parse(serverURL) + didWeb, _ := did.Parse("did:web:" + u.Host) + return []did.DID{didWeb} + }, + inputDID: "", // Will be set based on server URL + expectedDIDKey: "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + expectError: false, + }, + { + name: "successful resolution with pattern", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != didresolver.WellKnownDIDPath { + w.WriteHeader(http.StatusNotFound) + return + } + doc := didresolver.Document{ + Context: []string{"https://w3id.org/did/v1"}, + ID: "did:web:example.com", + VerificationMethod: []didresolver.VerificationMethod{ + { + ID: "did:web:example.com#key1", + Type: "Ed25519VerificationKey2018", + Controller: "did:web:example.com", + PublicKeyMultibase: "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(doc) + })) + }, + setupMapping: func(serverURL string) []did.DID { + return []did.DID{} + }, + setupGlobbing: func(serverURL string) []string { + return []string{"*"} + }, + inputDID: "", // Will be set based on server URL + expectedDIDKey: "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + expectError: false, + }, + { + name: "DID resolution not permitted", + setupServer: func() *httptest.Server { return nil }, + setupMapping: func(serverURL string) []did.DID { + return []did.DID{} + }, + inputDID: "did:web:notfound.com", + expectError: true, + errorContains: "resolution via HTTP not permitted", + }, + { + name: "invalid domain when matching against glob", + setupServer: func() *httptest.Server { return nil }, + setupMapping: func(serverURL string) []did.DID { + return []did.DID{} + }, + setupGlobbing: func(serverURL string) []string { + return []string{"*.storacha.network"} + }, + // make too long + inputDID: fmt.Sprintf("did:web:%s.storacha.network", strings.Repeat("a", 254)), + expectError: true, + errorContains: "invalid DID", + }, + { + name: "HTTP error response", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + }, + setupMapping: func(serverURL string) []did.DID { + u, _ := url.Parse(serverURL) + didWeb, _ := did.Parse("did:web:" + u.Host) + return []did.DID{didWeb} + }, + inputDID: "", // Will be set based on server URL + expectError: true, + errorContains: "unexpected status: 404", + }, + { + name: "invalid JSON response", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != didresolver.WellKnownDIDPath { + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("invalid json")) + })) + }, + setupMapping: func(serverURL string) []did.DID { + u, _ := url.Parse(serverURL) + didWeb, _ := did.Parse("did:web:" + u.Host) + return []did.DID{didWeb} + }, + inputDID: "", // Will be set based on server URL + expectError: true, + errorContains: "parsing DID document JSON", + }, + { + name: "no verification methods", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != didresolver.WellKnownDIDPath { + w.WriteHeader(http.StatusNotFound) + return + } + doc := didresolver.Document{ + Context: []string{"https://w3id.org/did/v1"}, + ID: "did:web:example.com", + VerificationMethod: []didresolver.VerificationMethod{}, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(doc) + })) + }, + setupMapping: func(serverURL string) []did.DID { + u, _ := url.Parse(serverURL) + didWeb, _ := did.Parse("did:web:" + u.Host) + return []did.DID{didWeb} + }, + inputDID: "", // Will be set based on server URL + expectError: true, + errorContains: "missing verificationMethod", + }, + { + name: "empty public key", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != didresolver.WellKnownDIDPath { + w.WriteHeader(http.StatusNotFound) + return + } + doc := didresolver.Document{ + Context: []string{"https://w3id.org/did/v1"}, + ID: "did:web:example.com", + VerificationMethod: []didresolver.VerificationMethod{ + { + ID: "did:web:example.com#key1", + Type: "Ed25519VerificationKey2018", + Controller: "did:web:example.com", + PublicKeyMultibase: "", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(doc) + })) + }, + setupMapping: func(serverURL string) []did.DID { + u, _ := url.Parse(serverURL) + didWeb, _ := did.Parse("did:web:" + u.Host) + return []did.DID{didWeb} + }, + inputDID: "", // Will be set based on server URL + expectError: true, + errorContains: "missing publicKeyMultibase", + }, + { + name: "invalid public key format", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != didresolver.WellKnownDIDPath { + w.WriteHeader(http.StatusNotFound) + return + } + doc := didresolver.Document{ + Context: []string{"https://w3id.org/did/v1"}, + ID: "did:web:example.com", + VerificationMethod: []didresolver.VerificationMethod{ + { + ID: "did:web:example.com#key1", + Type: "Ed25519VerificationKey2018", + Controller: "did:web:example.com", + PublicKeyMultibase: "invalid-key", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(doc) + })) + }, + setupMapping: func(serverURL string) []did.DID { + u, _ := url.Parse(serverURL) + didWeb, _ := did.Parse("did:web:" + u.Host) + return []did.DID{didWeb} + }, + inputDID: "", // Will be set based on server URL + expectError: true, + errorContains: "parsing multibase key", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var server *httptest.Server + if tc.setupServer != nil { + server = tc.setupServer() + if server != nil { + defer server.Close() + } + } + + var serverURL string + if server != nil { + serverURL = server.URL + } + mapping := tc.setupMapping(serverURL) + var patterns []string + if tc.setupGlobbing != nil { + patterns = tc.setupGlobbing(serverURL) + } + + resolver, err := didresolver.NewHTTPResolver( + mapping, + didresolver.InsecureResolution(), + didresolver.WithPatterns(patterns...), + ) + require.NoError(t, err) + + // For tests where inputDID is empty, derive it from server URL + var inputDID did.DID + if tc.inputDID == "" && server != nil { + u, _ := url.Parse(serverURL) + inputDID, err = did.Parse("did:web:" + u.Host) + require.NoError(t, err) + } else { + inputDID, err = did.Parse(tc.inputDID) + require.NoError(t, err) + } + + result, unresolvedErr := resolver.Resolve(t.Context(), inputDID) + + if tc.expectError { + require.NotNil(t, unresolvedErr) + require.Contains(t, unresolvedErr.Error(), "unable to resolve") + require.Nil(t, result) + if tc.errorContains != "" { + require.Contains(t, unresolvedErr.Error(), tc.errorContains) + } + } else { + require.Nil(t, unresolvedErr) + expectedDID, err := did.Parse(tc.expectedDIDKey) + require.NoError(t, err) + require.Equal(t, expectedDID, result.DID()) + } + }) + } +} + +func TestHTTPResolver_ResolveDIDKey_Timeout(t *testing.T) { + slowServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(100 * time.Millisecond) + w.WriteHeader(http.StatusOK) + })) + defer slowServer.Close() + + u, err := url.Parse(slowServer.URL) + require.NoError(t, err) + + didWeb, err := did.Parse("did:web:" + u.Host) + require.NoError(t, err) + + mapping := []did.DID{didWeb} + + resolver, err := didresolver.NewHTTPResolver(mapping, didresolver.WithTimeout(50*time.Millisecond), didresolver.InsecureResolution()) + require.NoError(t, err) + + result, unresolvedErr := resolver.Resolve(t.Context(), didWeb) + require.NotNil(t, unresolvedErr) + require.Contains(t, unresolvedErr.Error(), "unable to resolve") + require.Nil(t, result) +} + +func TestHTTPResolver_ResolveDIDKey_Context(t *testing.T) { + requestReceived := make(chan bool, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case requestReceived <- true: + default: + } + + if r.URL.Path != didresolver.WellKnownDIDPath { + w.WriteHeader(http.StatusNotFound) + return + } + + doc := didresolver.Document{ + Context: []string{"https://w3id.org/did/v1"}, + ID: "did:web:example.com", + VerificationMethod: []didresolver.VerificationMethod{ + { + ID: "did:web:example.com#key1", + Type: "Ed25519VerificationKey2018", + Controller: "did:web:example.com", + PublicKeyMultibase: "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(doc) + })) + defer server.Close() + + u, err := url.Parse(server.URL) + require.NoError(t, err) + + didWeb, err := did.Parse("did:web:" + u.Host) + require.NoError(t, err) + + mapping := []did.DID{didWeb} + + resolver, err := didresolver.NewHTTPResolver(mapping, didresolver.InsecureResolution()) + require.NoError(t, err) + + result, unresolvedErr := resolver.Resolve(t.Context(), didWeb) + require.Nil(t, unresolvedErr) + require.NotEqual(t, did.Undef, result) + + select { + case <-requestReceived: + case <-time.After(time.Second): + t.Fatal("request was not received by server") + } +} + +func TestFlexibleContext_UnmarshalJSON(t *testing.T) { + testCases := []struct { + name string + input string + expectedValue didresolver.FlexibleContext + expectError bool + errorContains string + }{ + { + name: "single string context", + input: `"https://w3id.org/did/v1"`, + expectedValue: didresolver.FlexibleContext{"https://w3id.org/did/v1"}, + expectError: false, + }, + { + name: "array of strings context", + input: `["https://w3id.org/did/v1", "https://w3id.org/security/v1"]`, + expectedValue: didresolver.FlexibleContext{"https://w3id.org/did/v1", "https://w3id.org/security/v1"}, + expectError: false, + }, + { + name: "empty array context", + input: `[]`, + expectedValue: didresolver.FlexibleContext{}, + expectError: false, + }, + { + name: "invalid type - number", + input: `123`, + expectError: true, + errorContains: "@context must be string or array of strings", + }, + { + name: "invalid type - object", + input: `{"foo": "bar"}`, + expectError: true, + errorContains: "@context must be string or array of strings", + }, + { + name: "invalid type - boolean", + input: `true`, + expectError: true, + errorContains: "@context must be string or array of strings", + }, + { + name: "array with non-string elements", + input: `["https://w3id.org/did/v1", 123]`, + expectError: true, + errorContains: "@context must be string or array of strings", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + var fc didresolver.FlexibleContext + err := json.Unmarshal([]byte(tc.input), &fc) + + if tc.expectError { + require.Error(t, err) + require.Contains(t, err.Error(), tc.errorContains) + } else { + require.NoError(t, err) + require.Equal(t, tc.expectedValue, fc) + } + }) + } +} + +func TestHTTPResolver_ResolveDIDKey_ContextFormats(t *testing.T) { + testCases := []struct { + name string + setupServer func() *httptest.Server + expectedDIDKey string + }{ + { + name: "DID document with string context", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != didresolver.WellKnownDIDPath { + w.WriteHeader(http.StatusNotFound) + return + } + // Using raw JSON to ensure we send a string context, not array + docJSON := `{ + "@context": "https://w3id.org/did/v1", + "id": "did:web:example.com", + "verificationMethod": [{ + "id": "did:web:example.com#key1", + "type": "Ed25519VerificationKey2018", + "controller": "did:web:example.com", + "publicKeyMultibase": "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + }] + }` + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(docJSON)) + })) + }, + expectedDIDKey: "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + }, + { + name: "DID document with array context", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != didresolver.WellKnownDIDPath { + w.WriteHeader(http.StatusNotFound) + return + } + doc := didresolver.Document{ + Context: didresolver.FlexibleContext{"https://w3id.org/did/v1", "https://w3id.org/security/v1"}, + ID: "did:web:example.com", + VerificationMethod: []didresolver.VerificationMethod{ + { + ID: "did:web:example.com#key1", + Type: "Ed25519VerificationKey2018", + Controller: "did:web:example.com", + PublicKeyMultibase: "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(doc) + })) + }, + expectedDIDKey: "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + server := tc.setupServer() + defer server.Close() + + u, err := url.Parse(server.URL) + require.NoError(t, err) + + didWeb, err := did.Parse("did:web:" + u.Host) + require.NoError(t, err) + + resolver, err := didresolver.NewHTTPResolver([]did.DID{didWeb}, didresolver.InsecureResolution()) + require.NoError(t, err) + + result, unresolvedErr := resolver.Resolve(t.Context(), didWeb) + require.Nil(t, unresolvedErr) + + expectedDID, err := did.Parse(tc.expectedDIDKey) + require.NoError(t, err) + require.Equal(t, expectedDID, result.DID()) + }) + } +} + +func TestExtractDomainFromDID(t *testing.T) { + testCases := []struct { + name string + did string + expectedDomain string + expectError bool + errorContains string + }{ + { + name: "valid did:web", + did: "did:web:example.com", + expectedDomain: "example.com", + expectError: false, + }, + { + name: "valid did:web with subdomain", + did: "did:web:api.example.com", + expectedDomain: "api.example.com", + expectError: false, + }, + { + name: "invalid prefix", + did: "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + expectError: true, + errorContains: "invalid DID web format: must start with 'did:web:'", + }, + { + name: "empty domain", + did: "did:web:", + expectError: true, + errorContains: "invalid DID web format: no domain specified", + }, + { + name: "domain too long", + did: "did:web:" + string(make([]byte, 254)), + expectError: true, + errorContains: "domain too long", + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + did, err := did.Parse(tc.did) + require.NoError(t, err) + + domain, err := didresolver.ExtractDomainFromDID(did) + + if tc.expectError { + require.Error(t, err) + require.Contains(t, err.Error(), tc.errorContains) + } else { + require.NoError(t, err) + require.Equal(t, tc.expectedDomain, domain) + } + }) + } +} diff --git a/didresolver/mapresolver.go b/didresolver/mapresolver.go new file mode 100644 index 0000000..a947e3a --- /dev/null +++ b/didresolver/mapresolver.go @@ -0,0 +1,43 @@ +package didresolver + +import ( + "context" + "fmt" + + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/principal/ed25519/verifier" + "github.com/fil-forge/ucantone/ucan" + verrs "github.com/fil-forge/ucantone/validator/errors" +) + +type MapResolver struct { + Mapping map[did.DID]ucan.Verifier +} + +func (r *MapResolver) Resolve(_ context.Context, input did.DID) (ucan.Verifier, error) { + // ctx is unused; this implementation only looks in a local mapping. + dk, ok := r.Mapping[input] + if !ok { + return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("not found in mapping: %s", input)) + } + return dk, nil +} + +// NewMapResolver creates a new MapResolver from a mapping of DID string to +// verifier string. +func NewMapResolver(smap map[string]string) (*MapResolver, error) { + dmap := map[did.DID]ucan.Verifier{} + for k, v := range smap { + dk, err := did.Parse(k) + if err != nil { + return nil, err + } + // TODO: multiple verification methods when https://github.com/fil-forge/ucantone/pull/7 lands + dv, err := verifier.Parse(v) + if err != nil { + return nil, err + } + dmap[dk] = dv + } + return &MapResolver{Mapping: dmap}, nil +} diff --git a/didresolver/mapresolver_test.go b/didresolver/mapresolver_test.go new file mode 100644 index 0000000..4dba48d --- /dev/null +++ b/didresolver/mapresolver_test.go @@ -0,0 +1,30 @@ +package didresolver_test + +import ( + "testing" + + "github.com/fil-forge/libforge/didresolver" + "github.com/fil-forge/ucantone/did" + "github.com/stretchr/testify/require" +) + +func TestPrincipalResolver(t *testing.T) { + p0, err := did.Parse("did:web:example.com") + require.NoError(t, err) + r, err := did.Parse("did:key:z6MkghfetkhrBZwUupJrv8MmYDH1JhKCQCGj1trbaZPA3dAd") + require.NoError(t, err) + p1, err := did.Parse("did:web:example.org") + require.NoError(t, err) + + pm := map[string]string{p0.String(): r.String()} + ppr, err := didresolver.NewMapResolver(pm) + require.NoError(t, err) + + resolved, err := ppr.Resolve(t.Context(), p0) + require.NoError(t, err) + require.Equal(t, r, resolved.DID()) + + // cannot resolve DID not in mapping + _, err = ppr.Resolve(t.Context(), p1) + require.ErrorContains(t, err, "not found in mapping") +} diff --git a/didresolver/tieredresolver.go b/didresolver/tieredresolver.go new file mode 100644 index 0000000..daee663 --- /dev/null +++ b/didresolver/tieredresolver.go @@ -0,0 +1,31 @@ +package didresolver + +import ( + "context" + "errors" + "fmt" + + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/ucan" + verrs "github.com/fil-forge/ucantone/validator/errors" +) + +// FIXME: remove when https://github.com/fil-forge/ucantone/pull/7 lands +type DIDVerifierResolverFunc func(ctx context.Context, did did.DID) (ucan.Verifier, error) + +type TieredResolver struct { + Tiers []DIDVerifierResolverFunc +} + +func (r *TieredResolver) ResolveDIDKey(ctx context.Context, input did.DID) (ucan.Verifier, error) { + var errs error + for _, tier := range r.Tiers { + verifier, err := tier(ctx, input) + if err != nil { + errs = errors.Join(errs, err) + continue + } + return verifier, nil + } + return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("not resolvable by any tier: %w", errs)) +} diff --git a/didresolver/tieredresolver_test.go b/didresolver/tieredresolver_test.go new file mode 100644 index 0000000..70ffbc0 --- /dev/null +++ b/didresolver/tieredresolver_test.go @@ -0,0 +1,196 @@ +package didresolver_test + +import ( + "context" + "fmt" + "testing" + + "github.com/fil-forge/libforge/didresolver" + "github.com/fil-forge/ucantone/did" + "github.com/fil-forge/ucantone/principal/ed25519/verifier" + "github.com/fil-forge/ucantone/ucan" + "github.com/stretchr/testify/require" +) + +func TestTieredResolver_ResolveDIDKey(t *testing.T) { + didWeb, err := did.Parse("did:web:example.com") + require.NoError(t, err) + + didKey, err := verifier.Parse("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") + require.NoError(t, err) + + t.Run("returns from the first tier when it resolves", func(t *testing.T) { + tier1 := &mockResolver{ + resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { + return didKey, nil + }, + } + tier2 := &mockResolver{ + resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { + t.Fatal("second tier should not be called when first tier resolves") + return nil, nil + }, + } + + resolver := &didresolver.TieredResolver{ + Tiers: []didresolver.DIDVerifierResolverFunc{tier1.ResolveDIDKey, tier2.ResolveDIDKey}, + } + + result, err := resolver.ResolveDIDKey(t.Context(), didWeb) + require.NoError(t, err) + require.Equal(t, didKey, result) + require.Equal(t, 1, tier1.getCallCount()) + require.Equal(t, 0, tier2.getCallCount()) + }) + + t.Run("falls through to later tier when earlier tiers fail", func(t *testing.T) { + tier1 := &mockResolver{ + resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { + return nil, fmt.Errorf("tier1 failed") + }, + } + tier2 := &mockResolver{ + resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { + return nil, fmt.Errorf("tier2 failed") + }, + } + tier3 := &mockResolver{ + resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { + return didKey, nil + }, + } + + resolver := &didresolver.TieredResolver{ + Tiers: []didresolver.DIDVerifierResolverFunc{ + tier1.ResolveDIDKey, + tier2.ResolveDIDKey, + tier3.ResolveDIDKey, + }, + } + + result, err := resolver.ResolveDIDKey(t.Context(), didWeb) + require.NoError(t, err) + require.Equal(t, didKey, result) + require.Equal(t, 1, tier1.getCallCount()) + require.Equal(t, 1, tier2.getCallCount()) + require.Equal(t, 1, tier3.getCallCount()) + }) + + t.Run("returns joined error when all tiers fail", func(t *testing.T) { + tier1 := &mockResolver{ + resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { + return nil, fmt.Errorf("tier1 specific error") + }, + } + tier2 := &mockResolver{ + resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { + return nil, fmt.Errorf("tier2 specific error") + }, + } + + resolver := &didresolver.TieredResolver{ + Tiers: []didresolver.DIDVerifierResolverFunc{tier1.ResolveDIDKey, tier2.ResolveDIDKey}, + } + + result, err := resolver.ResolveDIDKey(t.Context(), didWeb) + require.Error(t, err) + require.Nil(t, result) + require.Contains(t, err.Error(), "unable to resolve") + require.Contains(t, err.Error(), "not resolvable by any tier") + require.Contains(t, err.Error(), "tier1 specific error") + require.Contains(t, err.Error(), "tier2 specific error") + require.Equal(t, 1, tier1.getCallCount()) + require.Equal(t, 1, tier2.getCallCount()) + }) + + t.Run("returns error with no tiers configured", func(t *testing.T) { + resolver := &didresolver.TieredResolver{ + Tiers: []didresolver.DIDVerifierResolverFunc{}, + } + + result, err := resolver.ResolveDIDKey(t.Context(), didWeb) + require.Error(t, err) + require.Nil(t, result) + require.Contains(t, err.Error(), "unable to resolve") + require.Contains(t, err.Error(), "not resolvable by any tier") + }) + + t.Run("works with a single tier", func(t *testing.T) { + tier1 := &mockResolver{ + resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { + return didKey, nil + }, + } + + resolver := &didresolver.TieredResolver{ + Tiers: []didresolver.DIDVerifierResolverFunc{tier1.ResolveDIDKey}, + } + + result, err := resolver.ResolveDIDKey(t.Context(), didWeb) + require.NoError(t, err) + require.Equal(t, didKey, result) + require.Equal(t, 1, tier1.getCallCount()) + }) + + t.Run("composes with MapResolver tiers", func(t *testing.T) { + didA, err := did.Parse("did:web:alice.example.com") + require.NoError(t, err) + didB, err := did.Parse("did:web:bob.example.com") + require.NoError(t, err) + didC, err := did.Parse("did:web:carol.example.com") + require.NoError(t, err) + + keyA, err := verifier.Parse("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") + require.NoError(t, err) + keyB, err := verifier.Parse("did:key:z6Mkfriq1MqLBoPWecGoDLjguo1sB9brj6wT3qZ5BxkKpuP6") + require.NoError(t, err) + + mapA, err := didresolver.NewMapResolver(map[string]string{didA.String(): keyA.DID().String()}) + require.NoError(t, err) + mapB, err := didresolver.NewMapResolver(map[string]string{didB.String(): keyB.DID().String()}) + require.NoError(t, err) + + resolver := &didresolver.TieredResolver{ + Tiers: []didresolver.DIDVerifierResolverFunc{mapA.Resolve, mapB.Resolve}, + } + + // Resolves via the first tier + resA, err := resolver.ResolveDIDKey(t.Context(), didA) + require.NoError(t, err) + require.Equal(t, keyA, resA) + + // Falls through to the second tier + resB, err := resolver.ResolveDIDKey(t.Context(), didB) + require.NoError(t, err) + require.Equal(t, keyB, resB) + + // Not in any tier + _, err = resolver.ResolveDIDKey(t.Context(), didC) + require.Error(t, err) + require.Contains(t, err.Error(), "not resolvable by any tier") + }) + + t.Run("propagates context to tiers", func(t *testing.T) { + type ctxKey string + key := ctxKey("marker") + ctx := context.WithValue(t.Context(), key, "value") + + var seen string + tier1 := &mockResolver{ + resolveFn: func(ctx context.Context, input did.DID) (ucan.Verifier, error) { + if v, ok := ctx.Value(key).(string); ok { + seen = v + } + return didKey, nil + }, + } + + resolver := &didresolver.TieredResolver{ + Tiers: []didresolver.DIDVerifierResolverFunc{tier1.ResolveDIDKey}, + } + + _, err := resolver.ResolveDIDKey(ctx, didWeb) + require.NoError(t, err) + require.Equal(t, "value", seen) + }) +} diff --git a/go.mod b/go.mod index da9041f..b952ba6 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,13 @@ go 1.25.3 require ( github.com/alanshaw/dag-json-gen v0.0.5 github.com/fil-forge/automobile v0.0.1 - github.com/fil-forge/ucantone v0.0.0-20260512173820-ea7128569686 + github.com/fil-forge/ucantone v0.0.0-20260514155828-101376a82f4f + github.com/gobwas/glob v0.2.3 github.com/ipfs/go-cid v0.6.1 github.com/ipfs/go-log/v2 v2.9.1 github.com/multiformats/go-multibase v0.3.0 github.com/multiformats/go-multihash v0.2.3 + github.com/patrickmn/go-cache v2.1.0+incompatible github.com/stretchr/testify v1.11.1 github.com/whyrusleeping/cbor-gen v0.3.1 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da @@ -17,7 +19,6 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/gobwas/glob v0.2.3 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/sha256-simd v1.0.1 // indirect diff --git a/go.sum b/go.sum index 165f449..66d9658 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fil-forge/automobile v0.0.1 h1:9xB3yc4l5b9EdRJSJcNwudgBFNHoMPEAdcb7GfobLhA= github.com/fil-forge/automobile v0.0.1/go.mod h1:TsO7jlO8ykJZY5tF8j4GsUcu3F02lEzxO7ULoB61hRA= -github.com/fil-forge/ucantone v0.0.0-20260512173820-ea7128569686 h1:Weu36rT9cQrmM41qX1EL3ML1K1a+bdidVGacy75HSro= -github.com/fil-forge/ucantone v0.0.0-20260512173820-ea7128569686/go.mod h1:vqgVEsy6LEEsY24Zyjxem0vSofj1XTIx29GbV635f+I= +github.com/fil-forge/ucantone v0.0.0-20260514155828-101376a82f4f h1:SG3okr6NZPwuGHDJvMA1fMb3x8pYYks1NhsxjsSKWs4= +github.com/fil-forge/ucantone v0.0.0-20260514155828-101376a82f4f/go.mod h1:vqgVEsy6LEEsY24Zyjxem0vSofj1XTIx29GbV635f+I= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/ipfs/go-cid v0.6.1 h1:T5TnNb08+ueovG76Z5gx1L4Y7QOaGTXHg1F6raWFxIc= @@ -36,6 +36,8 @@ github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7B github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= diff --git a/receipt/client.go b/receipt/client.go index 4b56ec4..00dd90f 100644 --- a/receipt/client.go +++ b/receipt/client.go @@ -10,6 +10,7 @@ import ( "github.com/fil-forge/ucantone/transport" "github.com/fil-forge/ucantone/ucan" + "github.com/ipfs/go-cid" ) type ResponseDecoder[Res any] interface { @@ -59,7 +60,7 @@ func NewClient(endpoint *url.URL, options ...Option) *Client { // Fetch a receipt from the receipt API. Returns [ErrNotFound] if the API // responds with [http.StatusNotFound]. -func (c *Client) Fetch(ctx context.Context, task ucan.Link) (ucan.Receipt, ucan.Container, error) { +func (c *Client) Fetch(ctx context.Context, task cid.Cid) (ucan.Receipt, ucan.Container, error) { receiptURL := c.endpoint.JoinPath(task.String()) req, err := http.NewRequestWithContext(ctx, http.MethodGet, receiptURL.String(), nil) if err != nil { @@ -119,7 +120,7 @@ func WithRetries(n int) PollOption { // Poll attempts to fetch a receipt from the endpoint until a non-404 response // is encountered or until the configured maximum retries are made. -func (c *Client) Poll(ctx context.Context, task ucan.Link, options ...PollOption) (ucan.Receipt, ucan.Container, error) { +func (c *Client) Poll(ctx context.Context, task cid.Cid, options ...PollOption) (ucan.Receipt, ucan.Container, error) { conf := pollConfig{} for _, o := range options { o(&conf) diff --git a/ucan/attestations.go b/ucan/attestations.go index aa44d7c..15ddf48 100644 --- a/ucan/attestations.go +++ b/ucan/attestations.go @@ -7,19 +7,20 @@ import ( "iter" "github.com/fil-forge/libforge/capabilities/ucan/attest" + "github.com/fil-forge/ucantone/did" "github.com/fil-forge/ucantone/ucan" "github.com/fil-forge/ucantone/varsig/algorithm/nonstandard" ) // InvocationListerFunc lists invocations that match EXACTLY the given audience, // command, and subject. -type InvocationListerFunc func(ctx context.Context, aud ucan.Principal, cmd ucan.Command, sub ucan.Subject) iter.Seq2[ucan.Invocation, error] +type InvocationListerFunc func(ctx context.Context, aud did.DID, cmd ucan.Command, sub did.DID) iter.Seq2[ucan.Invocation, error] // ProofAttestations returns a list of attestations for proofs that need them. // i.e. if a proof is signed with a non-standard signature this function will // fetch an attestation for it, and fail if it cannot. The authority parameter // is the DID of the service we trust to be issuing attestations. -func ProofAttestations(ctx context.Context, listInvocations InvocationListerFunc, proofs []ucan.Delegation, authority ucan.Principal) ([]ucan.Invocation, error) { +func ProofAttestations(ctx context.Context, listInvocations InvocationListerFunc, proofs []ucan.Delegation, authority did.DID) ([]ucan.Invocation, error) { var attestations []ucan.Invocation for _, proof := range proofs { if proof.Signature().Header().SignatureAlgorithm().Code() != nonstandard.Code { @@ -28,10 +29,10 @@ func ProofAttestations(ctx context.Context, listInvocations InvocationListerFunc var attestation ucan.Invocation for inv, err := range listInvocations(ctx, proof.Audience(), attest.ProofCommand, authority) { if err != nil { - return nil, fmt.Errorf("listing invocations for proof signed by %q: %w", proof.Issuer().DID(), err) + return nil, fmt.Errorf("listing invocations for proof signed by %q: %w", proof.Issuer(), err) } // unlikely since all attestations should be self-signed by the authority - if inv.Issuer().DID() != authority.DID() { + if inv.Issuer() != authority { continue } if ucan.IsExpired(inv) { @@ -49,7 +50,7 @@ func ProofAttestations(ctx context.Context, listInvocations InvocationListerFunc break } if attestation == nil { - return nil, fmt.Errorf("no attestation found for proof signed by %q", proof.Issuer().DID()) + return nil, fmt.Errorf("no attestation found for proof signed by %q", proof.Issuer()) } attestations = append(attestations, attestation) } diff --git a/ucan/attestations_test.go b/ucan/attestations_test.go index 5c2c329..66d777b 100644 --- a/ucan/attestations_test.go +++ b/ucan/attestations_test.go @@ -10,26 +10,28 @@ import ( "github.com/fil-forge/libforge/didmailto" "github.com/fil-forge/libforge/testutil" ucanlib "github.com/fil-forge/libforge/ucan" + "github.com/fil-forge/ucantone/did" "github.com/fil-forge/ucantone/principal/absentee" "github.com/fil-forge/ucantone/ucan" "github.com/fil-forge/ucantone/ucan/command" "github.com/fil-forge/ucantone/ucan/delegation" "github.com/fil-forge/ucantone/ucan/invocation" + "github.com/ipfs/go-cid" "github.com/stretchr/testify/require" ) // recordedCall captures arguments passed to a stub AttestationGetterFunc. type recordedCall struct { - aud ucan.Principal + aud did.DID cmd ucan.Command - sub ucan.Subject + sub did.DID } // stubAttestationLister returns an AttestationGetterFunc that produces a fresh // attestation invocation per call (signed by authority) and records each call. -func stubAttestationLister(authority ucan.Signer, proofs []ucan.Link, calls *[]recordedCall) ucanlib.InvocationListerFunc { +func stubAttestationLister(authority ucan.Signer, proofs []cid.Cid, calls *[]recordedCall) ucanlib.InvocationListerFunc { i := 0 - return func(ctx context.Context, aud ucan.Principal, cmd ucan.Command, sub ucan.Subject) iter.Seq2[ucan.Invocation, error] { + return func(ctx context.Context, aud did.DID, cmd ucan.Command, sub did.DID) iter.Seq2[ucan.Invocation, error] { *calls = append(*calls, recordedCall{aud: aud, cmd: cmd, sub: sub}) return func(yield func(ucan.Invocation, error) bool) { if i >= len(proofs) { @@ -57,7 +59,7 @@ func TestProofAttestations(t *testing.T) { var calls []recordedCall lister := stubAttestationLister(service, nil, &calls) - attestations, err := ucanlib.ProofAttestations(t.Context(), lister, nil, service) + attestations, err := ucanlib.ProofAttestations(t.Context(), lister, nil, service.DID()) require.NoError(t, err) require.Empty(t, attestations) require.Empty(t, calls) @@ -70,12 +72,12 @@ func TestProofAttestations(t *testing.T) { cmd := testutil.Must(command.Parse("/test/do"))(t) // ed25519-signed proof — should be filtered out (no attestation needed). - dlg := testutil.Must(delegation.Delegate(space, alice, space, cmd))(t) + dlg := testutil.Must(delegation.Delegate(space, alice.DID(), space.DID(), cmd))(t) var calls []recordedCall lister := stubAttestationLister(service, nil, &calls) - attestations, err := ucanlib.ProofAttestations(t.Context(), lister, []ucan.Delegation{dlg}, service) + attestations, err := ucanlib.ProofAttestations(t.Context(), lister, []ucan.Delegation{dlg}, service.DID()) require.NoError(t, err) require.Empty(t, attestations) require.Empty(t, calls, "lister should not be called for standard signatures") @@ -90,21 +92,21 @@ func TestProofAttestations(t *testing.T) { cmd := testutil.Must(command.Parse("/test/do"))(t) // account (absentee, did:mailto) → agent — this proof needs an attestation. - dlg := testutil.Must(delegation.Delegate(account, agent, space, cmd))(t) + dlg := testutil.Must(delegation.Delegate(account, agent.DID(), space.DID(), cmd))(t) var calls []recordedCall - lister := stubAttestationLister(service, []ucan.Link{dlg.Link()}, &calls) + lister := stubAttestationLister(service, []cid.Cid{dlg.Link()}, &calls) - attestations, err := ucanlib.ProofAttestations(t.Context(), lister, []ucan.Delegation{dlg}, service) + attestations, err := ucanlib.ProofAttestations(t.Context(), lister, []ucan.Delegation{dlg}, service.DID()) require.NoError(t, err) require.Len(t, attestations, 1) require.Len(t, calls, 1) // Lister should be called with the proof's audience, the /ucan/attest/proof // command, and the authority as subject. - require.Equal(t, agent.DID(), calls[0].aud.DID()) + require.Equal(t, agent.DID(), calls[0].aud) require.Equal(t, ucan.Command(attest.ProofCommand), calls[0].cmd) - require.Equal(t, service.DID(), calls[0].sub.DID()) + require.Equal(t, service.DID(), calls[0].sub) }) t.Run("mixed standard and absentee proofs", func(t *testing.T) { @@ -117,18 +119,18 @@ func TestProofAttestations(t *testing.T) { cmd := testutil.Must(command.Parse("/test/do"))(t) // standard signature — no attestation needed - standardDlg := testutil.Must(delegation.Delegate(space, bob, space, cmd))(t) + standardDlg := testutil.Must(delegation.Delegate(space, bob.DID(), space.DID(), cmd))(t) // absentee signature — needs attestation - absenteeDlg := testutil.Must(delegation.Delegate(account, agent, space, cmd))(t) + absenteeDlg := testutil.Must(delegation.Delegate(account, agent.DID(), space.DID(), cmd))(t) var calls []recordedCall - lister := stubAttestationLister(service, []ucan.Link{absenteeDlg.Link()}, &calls) + lister := stubAttestationLister(service, []cid.Cid{absenteeDlg.Link()}, &calls) - attestations, err := ucanlib.ProofAttestations(t.Context(), lister, []ucan.Delegation{standardDlg, absenteeDlg}, service) + attestations, err := ucanlib.ProofAttestations(t.Context(), lister, []ucan.Delegation{standardDlg, absenteeDlg}, service.DID()) require.NoError(t, err) require.Len(t, attestations, 1, "only the absentee-signed proof needs an attestation") require.Len(t, calls, 1) - require.Equal(t, agent.DID(), calls[0].aud.DID()) + require.Equal(t, agent.DID(), calls[0].aud) }) t.Run("multiple absentee-signed proofs", func(t *testing.T) { @@ -143,18 +145,18 @@ func TestProofAttestations(t *testing.T) { space := testutil.RandomSigner(t) cmd := testutil.Must(command.Parse("/test/do"))(t) - dlgA := testutil.Must(delegation.Delegate(aliceAccount, agentA, space, cmd))(t) - dlgB := testutil.Must(delegation.Delegate(bobAccount, agentB, space, cmd))(t) + dlgA := testutil.Must(delegation.Delegate(aliceAccount, agentA.DID(), space.DID(), cmd))(t) + dlgB := testutil.Must(delegation.Delegate(bobAccount, agentB.DID(), space.DID(), cmd))(t) var calls []recordedCall - lister := stubAttestationLister(service, []ucan.Link{dlgA.Link(), dlgB.Link()}, &calls) + lister := stubAttestationLister(service, []cid.Cid{dlgA.Link(), dlgB.Link()}, &calls) - attestations, err := ucanlib.ProofAttestations(t.Context(), lister, []ucan.Delegation{dlgA, dlgB}, service) + attestations, err := ucanlib.ProofAttestations(t.Context(), lister, []ucan.Delegation{dlgA, dlgB}, service.DID()) require.NoError(t, err) require.Len(t, attestations, 2) require.Len(t, calls, 2) - require.Equal(t, agentA.DID(), calls[0].aud.DID()) - require.Equal(t, agentB.DID(), calls[1].aud.DID()) + require.Equal(t, agentA.DID(), calls[0].aud) + require.Equal(t, agentB.DID(), calls[1].aud) }) t.Run("lister error is propagated", func(t *testing.T) { @@ -165,16 +167,16 @@ func TestProofAttestations(t *testing.T) { space := testutil.RandomSigner(t) cmd := testutil.Must(command.Parse("/test/do"))(t) - dlg := testutil.Must(delegation.Delegate(account, agent, space, cmd))(t) + dlg := testutil.Must(delegation.Delegate(account, agent.DID(), space.DID(), cmd))(t) wantErr := errors.New("boom") - lister := func(ctx context.Context, aud ucan.Principal, cmd ucan.Command, sub ucan.Subject) iter.Seq2[ucan.Invocation, error] { + lister := func(ctx context.Context, aud did.DID, cmd ucan.Command, sub did.DID) iter.Seq2[ucan.Invocation, error] { return func(yield func(ucan.Invocation, error) bool) { yield(nil, wantErr) } } - attestations, err := ucanlib.ProofAttestations(t.Context(), lister, []ucan.Delegation{dlg}, service) + attestations, err := ucanlib.ProofAttestations(t.Context(), lister, []ucan.Delegation{dlg}, service.DID()) require.ErrorIs(t, err, wantErr) require.Nil(t, attestations) }) diff --git a/ucan/proof_chain.go b/ucan/proof_chain.go index 9c26d81..69a9ea8 100644 --- a/ucan/proof_chain.go +++ b/ucan/proof_chain.go @@ -5,8 +5,10 @@ import ( "iter" "slices" + "github.com/fil-forge/ucantone/did" "github.com/fil-forge/ucantone/ucan" "github.com/fil-forge/ucantone/ucan/command" + "github.com/ipfs/go-cid" ) // DelegationMatcherFunc finds all delegations matching the given audience, @@ -16,20 +18,20 @@ import ( // powerline delegations (with nil subject) and delegations where command is a // matching parent of the passed command e.g. if passed command is "/read/file", // delegations with command "/read", and "/" may be returned. -type DelegationMatcherFunc func(ctx context.Context, aud ucan.Principal, cmd ucan.Command, sub ucan.Subject) iter.Seq2[ucan.Delegation, error] +type DelegationMatcherFunc func(ctx context.Context, aud did.DID, cmd ucan.Command, sub did.DID) iter.Seq2[ucan.Delegation, error] // DelegationListerFunc lists delegations for the given audience, command, and // subject. It differs from [DelegationMatcherFunc] in that it only retrieves // delegations for the EXACT audience, command and subject. // // Note: the subject parameter MAY be nil to indicate powerline. -type DelegationListerFunc func(ctx context.Context, aud ucan.Principal, cmd ucan.Command, sub ucan.Subject) iter.Seq2[ucan.Delegation, error] +type DelegationListerFunc func(ctx context.Context, aud did.DID, cmd ucan.Command, sub did.DID) iter.Seq2[ucan.Delegation, error] // NewDelegationMatcher creates a simple delegation matcher that queries the // passed finder to retrieve delegations matching the given audience, command, // and subject. func NewDelegationMatcher(listDelegations DelegationListerFunc) DelegationMatcherFunc { - return func(ctx context.Context, aud ucan.Principal, cmd ucan.Command, sub ucan.Principal) iter.Seq2[ucan.Delegation, error] { + return func(ctx context.Context, aud did.DID, cmd ucan.Command, sub did.DID) iter.Seq2[ucan.Delegation, error] { return func(yield func(ucan.Delegation, error) bool) { cmdVariations := []ucan.Command{} segs := cmd.Segments() @@ -51,7 +53,7 @@ func NewDelegationMatcher(listDelegations DelegationListerFunc) DelegationMatche } // try powerline // TODO: stop early if we already found delegations? - for dlg, err := range listDelegations(ctx, aud, cmd, nil) { + for dlg, err := range listDelegations(ctx, aud, cmd, did.Undef) { if err != nil { yield(nil, err) return @@ -71,7 +73,7 @@ func NewDelegationMatcher(listDelegations DelegationListerFunc) DelegationMatche // invocation. i.e. starting from the root Delegation (issued by the Subject), // in strict sequence where the aud of the previous Delegation matches the iss // of the next Delegation. -func ProofChain(ctx context.Context, matchDelegations DelegationMatcherFunc, aud ucan.Principal, cmd ucan.Command, sub ucan.Principal) ([]ucan.Delegation, []ucan.Link, error) { +func ProofChain(ctx context.Context, matchDelegations DelegationMatcherFunc, aud did.DID, cmd ucan.Command, sub did.DID) ([]ucan.Delegation, []cid.Cid, error) { proofs, links, err := proofChain(ctx, matchDelegations, aud, cmd, sub) if err != nil { return nil, nil, err @@ -84,15 +86,15 @@ func ProofChain(ctx context.Context, matchDelegations DelegationMatcherFunc, aud // proofChain returns the delegations and links from the audience toward the // subject, i.e. in reverse of the invocation order. [ProofChain] reverses the // result before returning it to the caller. -func proofChain(ctx context.Context, matchDelegations DelegationMatcherFunc, aud ucan.Principal, cmd ucan.Command, sub ucan.Principal) ([]ucan.Delegation, []ucan.Link, error) { +func proofChain(ctx context.Context, matchDelegations DelegationMatcherFunc, aud did.DID, cmd ucan.Command, sub did.DID) ([]ucan.Delegation, []cid.Cid, error) { var proofs []ucan.Delegation - var links []ucan.Link + var links []cid.Cid for d, err := range matchDelegations(ctx, aud, cmd, sub) { if err != nil { return nil, nil, err } - if d.Subject() != nil && d.Subject().DID() == d.Issuer().DID() { + if d.Subject() == d.Issuer() { proofs = append(proofs, d) links = append(links, d.Link()) break diff --git a/ucan/proof_chain_test.go b/ucan/proof_chain_test.go index a429509..49719df 100644 --- a/ucan/proof_chain_test.go +++ b/ucan/proof_chain_test.go @@ -8,9 +8,11 @@ import ( "github.com/fil-forge/libforge/testutil" ucanlib "github.com/fil-forge/libforge/ucan" + "github.com/fil-forge/ucantone/did" "github.com/fil-forge/ucantone/ucan" "github.com/fil-forge/ucantone/ucan/command" "github.com/fil-forge/ucantone/ucan/delegation" + "github.com/ipfs/go-cid" "github.com/stretchr/testify/require" ) @@ -19,21 +21,21 @@ type memLister struct { delegations []ucan.Delegation } -func (f *memLister) List(ctx context.Context, aud ucan.Principal, cmd ucan.Command, sub ucan.Subject) iter.Seq2[ucan.Delegation, error] { +func (f *memLister) List(ctx context.Context, aud did.DID, cmd ucan.Command, sub did.DID) iter.Seq2[ucan.Delegation, error] { return func(yield func(ucan.Delegation, error) bool) { for _, d := range f.delegations { - if d.Audience().DID() != aud.DID() { + if d.Audience() != aud { continue } if d.Command() != cmd { continue } - if sub == nil { - if d.Subject() != nil { + if sub == did.Undef { + if d.Subject() != did.Undef { continue } } else { - if d.Subject() == nil || d.Subject().DID() != sub.DID() { + if d.Subject() == did.Undef || d.Subject() != sub { continue } } @@ -44,7 +46,7 @@ func (f *memLister) List(ctx context.Context, aud ucan.Principal, cmd ucan.Comma } } -func assertChain(t *testing.T, proofs []ucan.Delegation, links []ucan.Link, want []ucan.Delegation) { +func assertChain(t *testing.T, proofs []ucan.Delegation, links []cid.Cid, want []ucan.Delegation) { t.Helper() require.Len(t, proofs, len(want), "proof chain length") require.Len(t, links, len(want), "link chain length") @@ -60,12 +62,12 @@ func TestProofChain_SelfIssued(t *testing.T) { cmd := testutil.Must(command.Parse("/test/do"))(t) // space delegates to alice (root of chain, subject is the space). - root := testutil.Must(delegation.Delegate(space, alice, space, cmd))(t) + root := testutil.Must(delegation.Delegate(space, alice.DID(), space.DID(), cmd))(t) finder := &memLister{delegations: []ucan.Delegation{root}} matcher := ucanlib.NewDelegationMatcher(finder.List) - proofs, links, err := ucanlib.ProofChain(t.Context(), matcher, alice, cmd, space) + proofs, links, err := ucanlib.ProofChain(t.Context(), matcher, alice.DID(), cmd, space.DID()) require.NoError(t, err) assertChain(t, proofs, links, []ucan.Delegation{root}) } @@ -78,16 +80,16 @@ func TestProofChain_MultiHop(t *testing.T) { cmd := testutil.Must(command.Parse("/test/do"))(t) // space → alice (root, subject is the space) - sa := testutil.Must(delegation.Delegate(space, alice, space, cmd))(t) + sa := testutil.Must(delegation.Delegate(space, alice.DID(), space.DID(), cmd))(t) // alice → bob (re-delegates the space's authority) - ab := testutil.Must(delegation.Delegate(alice, bob, space, cmd))(t) + ab := testutil.Must(delegation.Delegate(alice, bob.DID(), space.DID(), cmd))(t) // bob → carol (re-delegates the space's authority) - bc := testutil.Must(delegation.Delegate(bob, carol, space, cmd))(t) + bc := testutil.Must(delegation.Delegate(bob, carol.DID(), space.DID(), cmd))(t) finder := &memLister{delegations: []ucan.Delegation{sa, ab, bc}} matcher := ucanlib.NewDelegationMatcher(finder.List) - proofs, links, err := ucanlib.ProofChain(t.Context(), matcher, carol, cmd, space) + proofs, links, err := ucanlib.ProofChain(t.Context(), matcher, carol.DID(), cmd, space.DID()) require.NoError(t, err) // Expected order: root first, then in sequence so aud of prev = iss of next. assertChain(t, proofs, links, []ucan.Delegation{sa, ab, bc}) @@ -99,7 +101,7 @@ func TestProofChain_NoDelegations(t *testing.T) { cmd := testutil.Must(command.Parse("/test/do"))(t) matcher := ucanlib.NewDelegationMatcher((&memLister{}).List) - proofs, links, err := ucanlib.ProofChain(t.Context(), matcher, alice, cmd, space) + proofs, links, err := ucanlib.ProofChain(t.Context(), matcher, alice.DID(), cmd, space.DID()) require.NoError(t, err) require.Empty(t, proofs) require.Empty(t, links) @@ -112,12 +114,12 @@ func TestProofChain_BrokenChain(t *testing.T) { cmd := testutil.Must(command.Parse("/test/do"))(t) // alice → bob exists, but no space → alice root. - ab := testutil.Must(delegation.Delegate(alice, bob, space, cmd))(t) + ab := testutil.Must(delegation.Delegate(alice, bob.DID(), space.DID(), cmd))(t) finder := &memLister{delegations: []ucan.Delegation{ab}} matcher := ucanlib.NewDelegationMatcher(finder.List) - proofs, links, err := ucanlib.ProofChain(t.Context(), matcher, bob, cmd, space) + proofs, links, err := ucanlib.ProofChain(t.Context(), matcher, bob.DID(), cmd, space.DID()) require.NoError(t, err) require.Empty(t, proofs) require.Empty(t, links) @@ -130,13 +132,13 @@ func TestProofChain_ParentCommand(t *testing.T) { child := testutil.Must(command.Parse("/test/do"))(t) // space delegates to alice for the parent command. - root := testutil.Must(delegation.Delegate(space, alice, space, parent))(t) + root := testutil.Must(delegation.Delegate(space, alice.DID(), space.DID(), parent))(t) finder := &memLister{delegations: []ucan.Delegation{root}} matcher := ucanlib.NewDelegationMatcher(finder.List) // Invocation for the child command should still resolve via the parent. - proofs, links, err := ucanlib.ProofChain(t.Context(), matcher, alice, child, space) + proofs, links, err := ucanlib.ProofChain(t.Context(), matcher, alice.DID(), child, space.DID()) require.NoError(t, err) assertChain(t, proofs, links, []ucan.Delegation{root}) } @@ -148,14 +150,14 @@ func TestProofChain_Powerline(t *testing.T) { cmd := testutil.Must(command.Parse("/test/do"))(t) // space delegates to alice (root). - root := testutil.Must(delegation.Delegate(space, alice, space, cmd))(t) + root := testutil.Must(delegation.Delegate(space, alice.DID(), space.DID(), cmd))(t) // powerline: alice → bob with nil subject. - powerline := testutil.Must(delegation.Delegate(alice, bob, nil, cmd))(t) + powerline := testutil.Must(delegation.Delegate(alice, bob.DID(), did.Undef, cmd))(t) finder := &memLister{delegations: []ucan.Delegation{root, powerline}} matcher := ucanlib.NewDelegationMatcher(finder.List) - proofs, links, err := ucanlib.ProofChain(t.Context(), matcher, bob, cmd, space) + proofs, links, err := ucanlib.ProofChain(t.Context(), matcher, bob.DID(), cmd, space.DID()) require.NoError(t, err) assertChain(t, proofs, links, []ucan.Delegation{root, powerline}) } @@ -167,12 +169,12 @@ func TestProofChain_UnrelatedCommandIgnored(t *testing.T) { other := testutil.Must(command.Parse("/other/op"))(t) // delegation exists but for an unrelated command path. - dlg := testutil.Must(delegation.Delegate(space, alice, space, other))(t) + dlg := testutil.Must(delegation.Delegate(space, alice.DID(), space.DID(), other))(t) finder := &memLister{delegations: []ucan.Delegation{dlg}} matcher := ucanlib.NewDelegationMatcher(finder.List) - proofs, links, err := ucanlib.ProofChain(t.Context(), matcher, alice, cmd, space) + proofs, links, err := ucanlib.ProofChain(t.Context(), matcher, alice.DID(), cmd, space.DID()) require.NoError(t, err) require.Empty(t, proofs) require.Empty(t, links) @@ -185,13 +187,13 @@ func TestProofChain_FinderError(t *testing.T) { wantErr := errors.New("boom") matcher := ucanlib.NewDelegationMatcher( - func(ctx context.Context, aud ucan.Principal, cmd ucan.Command, sub ucan.Subject) iter.Seq2[ucan.Delegation, error] { + func(ctx context.Context, aud did.DID, cmd ucan.Command, sub did.DID) iter.Seq2[ucan.Delegation, error] { return func(yield func(ucan.Delegation, error) bool) { yield(nil, wantErr) } }, ) - _, _, err := ucanlib.ProofChain(t.Context(), matcher, alice, cmd, space) + _, _, err := ucanlib.ProofChain(t.Context(), matcher, alice.DID(), cmd, space.DID()) require.ErrorIs(t, err, wantErr) } From 60daa13ba64698bbba31302ffe416c130077921b Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Fri, 15 May 2026 10:09:44 +0100 Subject: [PATCH 2/4] refactor: address feedback --- blobindex/datamodel/cbor_gen.maps.go | 4 +- blobindex/datamodel/cbor_gen.tuples.go | 8 +-- blobindex/datamodel/json_gen.maps.go | 4 +- blobindex/datamodel/json_gen.tuples.go | 8 +-- didresolver/httpresolver.go | 59 +++++++----------- didresolver/httpresolver_test.go | 83 +++----------------------- go.mod | 2 +- go.sum | 2 + 8 files changed, 44 insertions(+), 126 deletions(-) diff --git a/blobindex/datamodel/cbor_gen.maps.go b/blobindex/datamodel/cbor_gen.maps.go index b2d833f..46fc422 100644 --- a/blobindex/datamodel/cbor_gen.maps.go +++ b/blobindex/datamodel/cbor_gen.maps.go @@ -35,7 +35,7 @@ func (t *ShardedDagIndexModel) MarshalCBOR(w io.Writer) error { return err } - // t.DagO_1 (datamodeltype.ShardedDagIndexModel_0_1) (struct) + // t.DagO_1 (datamodel.ShardedDagIndexModel_0_1) (struct) if t.DagO_1 != nil { if len("index/sharded/dag@0.1") > 8192 { @@ -97,7 +97,7 @@ func (t *ShardedDagIndexModel) UnmarshalCBOR(r io.Reader) (err error) { } switch string(nameBuf[:nameLen]) { - // t.DagO_1 (datamodeltype.ShardedDagIndexModel_0_1) (struct) + // t.DagO_1 (datamodel.ShardedDagIndexModel_0_1) (struct) case "index/sharded/dag@0.1": { diff --git a/blobindex/datamodel/cbor_gen.tuples.go b/blobindex/datamodel/cbor_gen.tuples.go index d573657..3e50011 100644 --- a/blobindex/datamodel/cbor_gen.tuples.go +++ b/blobindex/datamodel/cbor_gen.tuples.go @@ -160,7 +160,7 @@ func (t *BlobSliceModel) MarshalCBOR(w io.Writer) error { return err } - // t.Range (datamodeltype.RangeModel) (struct) + // t.Range (datamodel.RangeModel) (struct) if err := t.Range.MarshalCBOR(cw); err != nil { return err } @@ -212,7 +212,7 @@ func (t *BlobSliceModel) UnmarshalCBOR(r io.Reader) (err error) { return err } - // t.Range (datamodeltype.RangeModel) (struct) + // t.Range (datamodel.RangeModel) (struct) { @@ -251,7 +251,7 @@ func (t *BlobIndexModel) MarshalCBOR(w io.Writer) error { return err } - // t.Slices ([]datamodeltype.BlobSliceModel) (slice) + // t.Slices ([]datamodel.BlobSliceModel) (slice) if len(t.Slices) > 8192 { return xerrors.Errorf("Slice value in field t.Slices was too long") } @@ -313,7 +313,7 @@ func (t *BlobIndexModel) UnmarshalCBOR(r io.Reader) (err error) { return err } - // t.Slices ([]datamodeltype.BlobSliceModel) (slice) + // t.Slices ([]datamodel.BlobSliceModel) (slice) maj, extra, err = cr.ReadHeader() if err != nil { diff --git a/blobindex/datamodel/json_gen.maps.go b/blobindex/datamodel/json_gen.maps.go index 32e79b9..7881091 100644 --- a/blobindex/datamodel/json_gen.maps.go +++ b/blobindex/datamodel/json_gen.maps.go @@ -28,7 +28,7 @@ func (t *ShardedDagIndexModel) MarshalDagJSON(w io.Writer) error { return err } - // t.DagO_1 (datamodeltype.ShardedDagIndexModel_0_1) (struct) + // t.DagO_1 (datamodel.ShardedDagIndexModel_0_1) (struct) if t.DagO_1 != nil { if len("index/sharded/dag@0.1") > 8192 { return fmt.Errorf("string in field \"index/sharded/dag@0.1\" was too long") @@ -82,7 +82,7 @@ func (t *ShardedDagIndexModel) UnmarshalDagJSON(r io.Reader) (err error) { } switch name { - // t.DagO_1 (datamodeltype.ShardedDagIndexModel_0_1) (struct) + // t.DagO_1 (datamodel.ShardedDagIndexModel_0_1) (struct) case "index/sharded/dag@0.1": { diff --git a/blobindex/datamodel/json_gen.tuples.go b/blobindex/datamodel/json_gen.tuples.go index 242257b..3406b90 100644 --- a/blobindex/datamodel/json_gen.tuples.go +++ b/blobindex/datamodel/json_gen.tuples.go @@ -134,7 +134,7 @@ func (t *BlobSliceModel) MarshalDagJSON(w io.Writer) error { return fmt.Errorf("writing comma for field Range: %w", err) } - // t.Range (datamodeltype.RangeModel) (struct) + // t.Range (datamodel.RangeModel) (struct) if err := t.Range.MarshalDagJSON(jw); err != nil { return fmt.Errorf("marshaling field t.Range: %w", err) } @@ -191,7 +191,7 @@ func (t *BlobSliceModel) UnmarshalDagJSON(r io.Reader) (err error) { } } - // t.Range (datamodeltype.RangeModel) (struct) + // t.Range (datamodel.RangeModel) (struct) if err := t.Range.UnmarshalDagJSON(jr); err != nil { return fmt.Errorf("unmarshaling t.Range: %w", err) @@ -227,7 +227,7 @@ func (t *BlobIndexModel) MarshalDagJSON(w io.Writer) error { return fmt.Errorf("writing comma for field Slices: %w", err) } - // t.Slices ([]datamodeltype.BlobSliceModel) (slice) + // t.Slices ([]datamodel.BlobSliceModel) (slice) if len(t.Slices) > 8192 { return fmt.Errorf("slice value in field t.Slices was too long") } @@ -302,7 +302,7 @@ func (t *BlobIndexModel) UnmarshalDagJSON(r io.Reader) (err error) { } } - // t.Slices ([]datamodeltype.BlobSliceModel) (slice) + // t.Slices ([]datamodel.BlobSliceModel) (slice) { diff --git a/didresolver/httpresolver.go b/didresolver/httpresolver.go index cd85d13..6db6347 100644 --- a/didresolver/httpresolver.go +++ b/didresolver/httpresolver.go @@ -7,7 +7,6 @@ import ( "io" "net/http" "net/url" - "strings" "time" "github.com/fil-forge/ucantone/did" @@ -62,9 +61,7 @@ type VerificationMethod struct { } type HTTPResolver struct { - // mapping of did:web to url of service, where we fetch .well-known/did.json to obtain their did:key key - webKeys map[did.DID]url.URL - cfg config + cfg config } type config struct { @@ -92,10 +89,10 @@ func InsecureResolution() Option { } } -// WithPatterns allows resolving of did:web's that match the provided glob +// WithPatterns restricts resolving of did:web's that match the provided glob // pattern(s). // -// Note: the pattern does not need to include the "did:web:" prefix. +// Note: the pattern should not include the "did:web:" prefix. func WithPatterns(patterns ...string) Option { return func(c *config) error { for _, p := range patterns { @@ -112,17 +109,15 @@ func WithPatterns(patterns ...string) Option { } } -const didWebPrefix = "did:web:" - // ExtractDomainFromDID extracts the domain from a DID web string func ExtractDomainFromDID(didWeb did.DID) (string, error) { // Check if it starts with the required prefix - if !strings.HasPrefix(didWeb.String(), didWebPrefix) { - return "", fmt.Errorf("invalid DID web format: must start with '%s'", didWebPrefix) + if didWeb.Method() != "web" { + return "", fmt.Errorf("invalid DID web format: must start with 'did:web:'") } // Extract the domain part - domain := strings.TrimPrefix(didWeb.String(), didWebPrefix) + domain := didWeb.Identifier() // Check if domain is empty if domain == "" { @@ -175,52 +170,38 @@ func WellKnownEndpointFromDID(didWeb did.DID, insecure bool) (url.URL, error) { const WellKnownDIDPath = "/.well-known/did.json" -func NewHTTPResolver(webKeys []did.DID, opts ...Option) (*HTTPResolver, error) { +func NewHTTPResolver(options ...Option) (*HTTPResolver, error) { cfg := &config{ timeout: 10 * time.Second, insecure: false, } - for _, opt := range opts { + for _, opt := range options { if err := opt(cfg); err != nil { return nil, err } } - - // Convert string map to DID/URL map - didMap := make(map[did.DID]url.URL) - for _, w := range webKeys { - if _, ok := didMap[w]; ok { - return nil, fmt.Errorf("duplicate did's provided") - } - endpoint, err := WellKnownEndpointFromDID(w, cfg.insecure) - if err != nil { - return nil, err - } - didMap[w] = endpoint - } // default timeout of 10 seconds, options can override - resolver := &HTTPResolver{webKeys: didMap, cfg: *cfg} - return resolver, nil + return &HTTPResolver{cfg: *cfg}, nil } func (r *HTTPResolver) Resolve(ctx context.Context, input did.DID) (ucan.Verifier, error) { - endpoint, ok := r.webKeys[input] - if !ok { // if not in allowed web keys, try globs + if r.cfg.globs != nil { + match := false for _, g := range r.cfg.globs { - ok = g.Match(strings.TrimPrefix(input.String(), didWebPrefix)) - if ok { - u, err := WellKnownEndpointFromDID(input, r.cfg.insecure) - if err != nil { - return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("invalid DID: %w", err)) - } - endpoint = u + if match = g.Match(input.Identifier()); match { break } } + if !match { + return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("resolution via HTTP not permitted")) + } } - if !ok { - return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("resolution via HTTP not permitted")) + + endpoint, err := WellKnownEndpointFromDID(input, r.cfg.insecure) + if err != nil { + return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("invalid DID: %w", err)) } + ctx, cancel := context.WithTimeout(ctx, r.cfg.timeout) defer cancel() didDoc, err := fetchDIDDocument(ctx, endpoint) diff --git a/didresolver/httpresolver_test.go b/didresolver/httpresolver_test.go index d4524a3..f999505 100644 --- a/didresolver/httpresolver_test.go +++ b/didresolver/httpresolver_test.go @@ -17,51 +17,29 @@ import ( func TestNewHTTPResolver(t *testing.T) { t.Run("creates resolver with default timeout", func(t *testing.T) { - mapping := make([]did.DID, 0) - resolver, err := didresolver.NewHTTPResolver(mapping) + resolver, err := didresolver.NewHTTPResolver() require.NoError(t, err) require.NotNil(t, resolver) }) t.Run("creates resolver with custom timeout", func(t *testing.T) { - mapping := make([]did.DID, 0) - resolver, err := didresolver.NewHTTPResolver(mapping, didresolver.WithTimeout(5*time.Second), didresolver.InsecureResolution()) + resolver, err := didresolver.NewHTTPResolver(didresolver.WithTimeout(5*time.Second), didresolver.InsecureResolution()) require.NoError(t, err) require.NotNil(t, resolver) }) t.Run("fails with zero timeout", func(t *testing.T) { - mapping := make([]did.DID, 0) - resolver, err := didresolver.NewHTTPResolver(mapping, didresolver.WithTimeout(0)) + resolver, err := didresolver.NewHTTPResolver(didresolver.WithTimeout(0)) require.Error(t, err) require.Contains(t, err.Error(), "timeout cannot be zero") require.Nil(t, resolver) }) - - t.Run("fails with duplicate DIDs", func(t *testing.T) { - didWeb, _ := did.Parse("did:web:example.com") - mapping := []did.DID{didWeb, didWeb} - resolver, err := didresolver.NewHTTPResolver(mapping) - require.Error(t, err) - require.Contains(t, err.Error(), "duplicate did's provided") - require.Nil(t, resolver) - }) - - t.Run("fails with invalid DID format", func(t *testing.T) { - didWeb, _ := did.Parse("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") - mapping := []did.DID{didWeb} - resolver, err := didresolver.NewHTTPResolver(mapping) - require.Error(t, err) - require.Contains(t, err.Error(), "invalid DID web format") - require.Nil(t, resolver) - }) } func TestHTTPResolver_ResolveDIDKey(t *testing.T) { testCases := []struct { name string setupServer func() *httptest.Server - setupMapping func(serverURL string) []did.DID setupGlobbing func(serverURL string) []string inputDID string expectedDIDKey string @@ -92,12 +70,6 @@ func TestHTTPResolver_ResolveDIDKey(t *testing.T) { json.NewEncoder(w).Encode(doc) })) }, - setupMapping: func(serverURL string) []did.DID { - // Extract domain from server URL to create matching did:web - u, _ := url.Parse(serverURL) - didWeb, _ := did.Parse("did:web:" + u.Host) - return []did.DID{didWeb} - }, inputDID: "", // Will be set based on server URL expectedDIDKey: "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", expectError: false, @@ -126,9 +98,6 @@ func TestHTTPResolver_ResolveDIDKey(t *testing.T) { json.NewEncoder(w).Encode(doc) })) }, - setupMapping: func(serverURL string) []did.DID { - return []did.DID{} - }, setupGlobbing: func(serverURL string) []string { return []string{"*"} }, @@ -137,10 +106,10 @@ func TestHTTPResolver_ResolveDIDKey(t *testing.T) { expectError: false, }, { - name: "DID resolution not permitted", + name: "DID resolution not permitted by pattern", setupServer: func() *httptest.Server { return nil }, - setupMapping: func(serverURL string) []did.DID { - return []did.DID{} + setupGlobbing: func(serverURL string) []string { + return []string{"*.storacha.network"} }, inputDID: "did:web:notfound.com", expectError: true, @@ -149,9 +118,6 @@ func TestHTTPResolver_ResolveDIDKey(t *testing.T) { { name: "invalid domain when matching against glob", setupServer: func() *httptest.Server { return nil }, - setupMapping: func(serverURL string) []did.DID { - return []did.DID{} - }, setupGlobbing: func(serverURL string) []string { return []string{"*.storacha.network"} }, @@ -167,11 +133,6 @@ func TestHTTPResolver_ResolveDIDKey(t *testing.T) { w.WriteHeader(http.StatusNotFound) })) }, - setupMapping: func(serverURL string) []did.DID { - u, _ := url.Parse(serverURL) - didWeb, _ := did.Parse("did:web:" + u.Host) - return []did.DID{didWeb} - }, inputDID: "", // Will be set based on server URL expectError: true, errorContains: "unexpected status: 404", @@ -188,11 +149,6 @@ func TestHTTPResolver_ResolveDIDKey(t *testing.T) { w.Write([]byte("invalid json")) })) }, - setupMapping: func(serverURL string) []did.DID { - u, _ := url.Parse(serverURL) - didWeb, _ := did.Parse("did:web:" + u.Host) - return []did.DID{didWeb} - }, inputDID: "", // Will be set based on server URL expectError: true, errorContains: "parsing DID document JSON", @@ -214,11 +170,6 @@ func TestHTTPResolver_ResolveDIDKey(t *testing.T) { json.NewEncoder(w).Encode(doc) })) }, - setupMapping: func(serverURL string) []did.DID { - u, _ := url.Parse(serverURL) - didWeb, _ := did.Parse("did:web:" + u.Host) - return []did.DID{didWeb} - }, inputDID: "", // Will be set based on server URL expectError: true, errorContains: "missing verificationMethod", @@ -247,11 +198,6 @@ func TestHTTPResolver_ResolveDIDKey(t *testing.T) { json.NewEncoder(w).Encode(doc) })) }, - setupMapping: func(serverURL string) []did.DID { - u, _ := url.Parse(serverURL) - didWeb, _ := did.Parse("did:web:" + u.Host) - return []did.DID{didWeb} - }, inputDID: "", // Will be set based on server URL expectError: true, errorContains: "missing publicKeyMultibase", @@ -280,11 +226,6 @@ func TestHTTPResolver_ResolveDIDKey(t *testing.T) { json.NewEncoder(w).Encode(doc) })) }, - setupMapping: func(serverURL string) []did.DID { - u, _ := url.Parse(serverURL) - didWeb, _ := did.Parse("did:web:" + u.Host) - return []did.DID{didWeb} - }, inputDID: "", // Will be set based on server URL expectError: true, errorContains: "parsing multibase key", @@ -305,14 +246,12 @@ func TestHTTPResolver_ResolveDIDKey(t *testing.T) { if server != nil { serverURL = server.URL } - mapping := tc.setupMapping(serverURL) var patterns []string if tc.setupGlobbing != nil { patterns = tc.setupGlobbing(serverURL) } resolver, err := didresolver.NewHTTPResolver( - mapping, didresolver.InsecureResolution(), didresolver.WithPatterns(patterns...), ) @@ -361,9 +300,7 @@ func TestHTTPResolver_ResolveDIDKey_Timeout(t *testing.T) { didWeb, err := did.Parse("did:web:" + u.Host) require.NoError(t, err) - mapping := []did.DID{didWeb} - - resolver, err := didresolver.NewHTTPResolver(mapping, didresolver.WithTimeout(50*time.Millisecond), didresolver.InsecureResolution()) + resolver, err := didresolver.NewHTTPResolver(didresolver.WithTimeout(50*time.Millisecond), didresolver.InsecureResolution()) require.NoError(t, err) result, unresolvedErr := resolver.Resolve(t.Context(), didWeb) @@ -408,9 +345,7 @@ func TestHTTPResolver_ResolveDIDKey_Context(t *testing.T) { didWeb, err := did.Parse("did:web:" + u.Host) require.NoError(t, err) - mapping := []did.DID{didWeb} - - resolver, err := didresolver.NewHTTPResolver(mapping, didresolver.InsecureResolution()) + resolver, err := didresolver.NewHTTPResolver(didresolver.InsecureResolution()) require.NoError(t, err) result, unresolvedErr := resolver.Resolve(t.Context(), didWeb) @@ -564,7 +499,7 @@ func TestHTTPResolver_ResolveDIDKey_ContextFormats(t *testing.T) { didWeb, err := did.Parse("did:web:" + u.Host) require.NoError(t, err) - resolver, err := didresolver.NewHTTPResolver([]did.DID{didWeb}, didresolver.InsecureResolution()) + resolver, err := didresolver.NewHTTPResolver(didresolver.InsecureResolution()) require.NoError(t, err) result, unresolvedErr := resolver.Resolve(t.Context(), didWeb) diff --git a/go.mod b/go.mod index b952ba6..d65d9bb 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.3 require ( github.com/alanshaw/dag-json-gen v0.0.5 github.com/fil-forge/automobile v0.0.1 - github.com/fil-forge/ucantone v0.0.0-20260514155828-101376a82f4f + github.com/fil-forge/ucantone v0.0.0-20260514184915-8bebe15b0096 github.com/gobwas/glob v0.2.3 github.com/ipfs/go-cid v0.6.1 github.com/ipfs/go-log/v2 v2.9.1 diff --git a/go.sum b/go.sum index 66d9658..65b90fd 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/fil-forge/automobile v0.0.1 h1:9xB3yc4l5b9EdRJSJcNwudgBFNHoMPEAdcb7Gf github.com/fil-forge/automobile v0.0.1/go.mod h1:TsO7jlO8ykJZY5tF8j4GsUcu3F02lEzxO7ULoB61hRA= github.com/fil-forge/ucantone v0.0.0-20260514155828-101376a82f4f h1:SG3okr6NZPwuGHDJvMA1fMb3x8pYYks1NhsxjsSKWs4= github.com/fil-forge/ucantone v0.0.0-20260514155828-101376a82f4f/go.mod h1:vqgVEsy6LEEsY24Zyjxem0vSofj1XTIx29GbV635f+I= +github.com/fil-forge/ucantone v0.0.0-20260514184915-8bebe15b0096 h1:T/JkfzRNu4/9OnqgggVWbn+OXs/y12xDSVEen0NS7EY= +github.com/fil-forge/ucantone v0.0.0-20260514184915-8bebe15b0096/go.mod h1:vqgVEsy6LEEsY24Zyjxem0vSofj1XTIx29GbV635f+I= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/ipfs/go-cid v0.6.1 h1:T5TnNb08+ueovG76Z5gx1L4Y7QOaGTXHg1F6raWFxIc= From 504915607124ba44766aab5fc075de60783d9a57 Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Fri, 15 May 2026 10:11:49 +0100 Subject: [PATCH 3/4] chore: mod tidy --- go.sum | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.sum b/go.sum index 65b90fd..6d728cc 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fil-forge/automobile v0.0.1 h1:9xB3yc4l5b9EdRJSJcNwudgBFNHoMPEAdcb7GfobLhA= github.com/fil-forge/automobile v0.0.1/go.mod h1:TsO7jlO8ykJZY5tF8j4GsUcu3F02lEzxO7ULoB61hRA= -github.com/fil-forge/ucantone v0.0.0-20260514155828-101376a82f4f h1:SG3okr6NZPwuGHDJvMA1fMb3x8pYYks1NhsxjsSKWs4= -github.com/fil-forge/ucantone v0.0.0-20260514155828-101376a82f4f/go.mod h1:vqgVEsy6LEEsY24Zyjxem0vSofj1XTIx29GbV635f+I= github.com/fil-forge/ucantone v0.0.0-20260514184915-8bebe15b0096 h1:T/JkfzRNu4/9OnqgggVWbn+OXs/y12xDSVEen0NS7EY= github.com/fil-forge/ucantone v0.0.0-20260514184915-8bebe15b0096/go.mod h1:vqgVEsy6LEEsY24Zyjxem0vSofj1XTIx29GbV635f+I= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= From 92e9f054ecafaf7438c3bf5e61d06bb8acb1e2df Mon Sep 17 00:00:00 2001 From: Alan Shaw Date: Fri, 15 May 2026 10:57:54 +0100 Subject: [PATCH 4/4] chore: address copilot feedback --- didresolver/httpresolver_test.go | 2 +- didresolver/tieredresolver.go | 7 +++++-- didresolver/tieredresolver_test.go | 26 +++++++++++++------------- ucan/proof_chain.go | 11 ++++++----- 4 files changed, 25 insertions(+), 21 deletions(-) diff --git a/didresolver/httpresolver_test.go b/didresolver/httpresolver_test.go index f999505..9c00595 100644 --- a/didresolver/httpresolver_test.go +++ b/didresolver/httpresolver_test.go @@ -546,7 +546,7 @@ func TestExtractDomainFromDID(t *testing.T) { }, { name: "domain too long", - did: "did:web:" + string(make([]byte, 254)), + did: "did:web:" + strings.Repeat("a", 254), expectError: true, errorContains: "domain too long", }, diff --git a/didresolver/tieredresolver.go b/didresolver/tieredresolver.go index daee663..7c8a960 100644 --- a/didresolver/tieredresolver.go +++ b/didresolver/tieredresolver.go @@ -17,7 +17,10 @@ type TieredResolver struct { Tiers []DIDVerifierResolverFunc } -func (r *TieredResolver) ResolveDIDKey(ctx context.Context, input did.DID) (ucan.Verifier, error) { +func (r *TieredResolver) Resolve(ctx context.Context, input did.DID) (ucan.Verifier, error) { + if len(r.Tiers) == 0 { + return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("no resolvers configured")) + } var errs error for _, tier := range r.Tiers { verifier, err := tier(ctx, input) @@ -27,5 +30,5 @@ func (r *TieredResolver) ResolveDIDKey(ctx context.Context, input did.DID) (ucan } return verifier, nil } - return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("not resolvable by any tier: %w", errs)) + return nil, verrs.NewDIDKeyResolutionError(input, fmt.Errorf("not resolvable by any resolver: %w", errs)) } diff --git a/didresolver/tieredresolver_test.go b/didresolver/tieredresolver_test.go index 70ffbc0..714455e 100644 --- a/didresolver/tieredresolver_test.go +++ b/didresolver/tieredresolver_test.go @@ -36,7 +36,7 @@ func TestTieredResolver_ResolveDIDKey(t *testing.T) { Tiers: []didresolver.DIDVerifierResolverFunc{tier1.ResolveDIDKey, tier2.ResolveDIDKey}, } - result, err := resolver.ResolveDIDKey(t.Context(), didWeb) + result, err := resolver.Resolve(t.Context(), didWeb) require.NoError(t, err) require.Equal(t, didKey, result) require.Equal(t, 1, tier1.getCallCount()) @@ -68,7 +68,7 @@ func TestTieredResolver_ResolveDIDKey(t *testing.T) { }, } - result, err := resolver.ResolveDIDKey(t.Context(), didWeb) + result, err := resolver.Resolve(t.Context(), didWeb) require.NoError(t, err) require.Equal(t, didKey, result) require.Equal(t, 1, tier1.getCallCount()) @@ -92,11 +92,11 @@ func TestTieredResolver_ResolveDIDKey(t *testing.T) { Tiers: []didresolver.DIDVerifierResolverFunc{tier1.ResolveDIDKey, tier2.ResolveDIDKey}, } - result, err := resolver.ResolveDIDKey(t.Context(), didWeb) + result, err := resolver.Resolve(t.Context(), didWeb) require.Error(t, err) require.Nil(t, result) require.Contains(t, err.Error(), "unable to resolve") - require.Contains(t, err.Error(), "not resolvable by any tier") + require.Contains(t, err.Error(), "not resolvable by any resolver") require.Contains(t, err.Error(), "tier1 specific error") require.Contains(t, err.Error(), "tier2 specific error") require.Equal(t, 1, tier1.getCallCount()) @@ -108,11 +108,11 @@ func TestTieredResolver_ResolveDIDKey(t *testing.T) { Tiers: []didresolver.DIDVerifierResolverFunc{}, } - result, err := resolver.ResolveDIDKey(t.Context(), didWeb) + result, err := resolver.Resolve(t.Context(), didWeb) require.Error(t, err) require.Nil(t, result) require.Contains(t, err.Error(), "unable to resolve") - require.Contains(t, err.Error(), "not resolvable by any tier") + require.Contains(t, err.Error(), "no resolvers configured") }) t.Run("works with a single tier", func(t *testing.T) { @@ -126,7 +126,7 @@ func TestTieredResolver_ResolveDIDKey(t *testing.T) { Tiers: []didresolver.DIDVerifierResolverFunc{tier1.ResolveDIDKey}, } - result, err := resolver.ResolveDIDKey(t.Context(), didWeb) + result, err := resolver.Resolve(t.Context(), didWeb) require.NoError(t, err) require.Equal(t, didKey, result) require.Equal(t, 1, tier1.getCallCount()) @@ -155,19 +155,19 @@ func TestTieredResolver_ResolveDIDKey(t *testing.T) { } // Resolves via the first tier - resA, err := resolver.ResolveDIDKey(t.Context(), didA) + resA, err := resolver.Resolve(t.Context(), didA) require.NoError(t, err) require.Equal(t, keyA, resA) // Falls through to the second tier - resB, err := resolver.ResolveDIDKey(t.Context(), didB) + resB, err := resolver.Resolve(t.Context(), didB) require.NoError(t, err) require.Equal(t, keyB, resB) - // Not in any tier - _, err = resolver.ResolveDIDKey(t.Context(), didC) + // Not resolvable by any tier + _, err = resolver.Resolve(t.Context(), didC) require.Error(t, err) - require.Contains(t, err.Error(), "not resolvable by any tier") + require.Contains(t, err.Error(), "not resolvable by any resolver") }) t.Run("propagates context to tiers", func(t *testing.T) { @@ -189,7 +189,7 @@ func TestTieredResolver_ResolveDIDKey(t *testing.T) { Tiers: []didresolver.DIDVerifierResolverFunc{tier1.ResolveDIDKey}, } - _, err := resolver.ResolveDIDKey(ctx, didWeb) + _, err := resolver.Resolve(ctx, didWeb) require.NoError(t, err) require.Equal(t, "value", seen) }) diff --git a/ucan/proof_chain.go b/ucan/proof_chain.go index 69a9ea8..0361f24 100644 --- a/ucan/proof_chain.go +++ b/ucan/proof_chain.go @@ -14,17 +14,18 @@ import ( // DelegationMatcherFunc finds all delegations matching the given audience, // command, and subject. // -// The subject parameter MUST not be nil, but matching delegations MAY include -// powerline delegations (with nil subject) and delegations where command is a -// matching parent of the passed command e.g. if passed command is "/read/file", -// delegations with command "/read", and "/" may be returned. +// The subject parameter MUST not be [did.Undef], but matching delegations MAY +// include powerline delegations (with [did.Undef] subject) and delegations +// where command is a matching parent of the passed command e.g. if passed +// command is "/read/file", delegations with command "/read", and "/" may be +// returned. type DelegationMatcherFunc func(ctx context.Context, aud did.DID, cmd ucan.Command, sub did.DID) iter.Seq2[ucan.Delegation, error] // DelegationListerFunc lists delegations for the given audience, command, and // subject. It differs from [DelegationMatcherFunc] in that it only retrieves // delegations for the EXACT audience, command and subject. // -// Note: the subject parameter MAY be nil to indicate powerline. +// Note: the subject parameter MAY be [did.Undef] to indicate powerline. type DelegationListerFunc func(ctx context.Context, aud did.DID, cmd ucan.Command, sub did.DID) iter.Seq2[ucan.Delegation, error] // NewDelegationMatcher creates a simple delegation matcher that queries the