From 0b2e5b2e1c6c959587209cd99480ddac2e365e6a Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Tue, 22 Sep 2026 15:36:53 +0200 Subject: [PATCH] feat(client): publish a batch of revocations in one request Hilt rotates a principal-bound key's delegations on every policy change and needs every revocation of one write sent together, so that the write waits on one round trip regardless of how many keys it touches. PublishBatch builds one self-signed /ucan/revoke invocation per revoked delegation and sends them in a single container through ucantone's ExecuteBatch, failing on the first refused receipt. ucantone is bumped to the version that exposes batch execution. Co-Authored-By: Claude Fable 5.1 --- go.mod | 2 +- go.sum | 4 +-- pkg/client/client.go | 49 +++++++++++++++++++++++++++++- pkg/client/client_test.go | 64 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 93ce250..bcb0843 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/cenkalti/backoff/v5 v5.0.3 github.com/docker/docker v28.5.2+incompatible github.com/fil-forge/libforge v0.0.0-20260807225550-3e6895b41be5 - github.com/fil-forge/ucantone v0.0.0-20260827134420-25cf8340b9a1 + github.com/fil-forge/ucantone v0.0.0-20260921155257-b59f1d1fcbb6 github.com/ipfs/go-cid v0.6.2 github.com/jackc/pgx/v5 v5.10.0 github.com/labstack/echo/v4 v4.15.0 diff --git a/go.sum b/go.sum index efb1e40..4eaa309 100644 --- a/go.sum +++ b/go.sum @@ -44,8 +44,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fil-forge/libforge v0.0.0-20260807225550-3e6895b41be5 h1:Pu7Xk/LdaQyv1560k1MbL6CQUdWxZJIG9elEE0vkgLY= github.com/fil-forge/libforge v0.0.0-20260807225550-3e6895b41be5/go.mod h1:aL/KcXZ0KVs1ymAYqIwQyCnl2p481InN+gYbRLNM8fI= -github.com/fil-forge/ucantone v0.0.0-20260827134420-25cf8340b9a1 h1:Tzy3lZ7+LAyVK4+qyWM97UMOtnXx05Wdn+rcTDLdBDE= -github.com/fil-forge/ucantone v0.0.0-20260827134420-25cf8340b9a1/go.mod h1:aX35jlUs3hnWAhigjO8z9kMPiYnkW3XOW8iVoJyz7cg= +github.com/fil-forge/ucantone v0.0.0-20260921155257-b59f1d1fcbb6 h1:NDhDcNPYVtnppbvBtUP9qEow5lsevM2NO+m4jOa+PWE= +github.com/fil-forge/ucantone v0.0.0-20260921155257-b59f1d1fcbb6/go.mod h1:aX35jlUs3hnWAhigjO8z9kMPiYnkW3XOW8iVoJyz7cg= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= diff --git a/pkg/client/client.go b/pkg/client/client.go index 815676b..59835d7 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -21,6 +21,7 @@ import ( "github.com/fil-forge/ucantone/client" "github.com/fil-forge/ucantone/did" "github.com/fil-forge/ucantone/execution" + "github.com/fil-forge/ucantone/execution/batch" "github.com/fil-forge/ucantone/ucan" "github.com/fil-forge/ucantone/ucan/delegation" "github.com/fil-forge/ucantone/ucan/invocation" @@ -31,7 +32,7 @@ import ( type Client struct { ServiceID did.DID serviceURL url.URL - executor execution.Executor + executor *client.HTTPClient httpClient *http.Client } @@ -119,6 +120,52 @@ func (c *Client) Publish(ctx context.Context, revoker ucan.Issuer, revoked ucan. return nil } +// PublishBatch submits one /ucan/revoke invocation per revoked delegation, +// all self-signed by revoker and sent in a single request. The revoker must be +// the issuer of every revoked delegation; no witness path is carried. Every +// invocation must succeed, otherwise the first failure is returned. A repeat of +// the same delegation is a duplicate the service records once. +func (c *Client) PublishBatch(ctx context.Context, revoker ucan.Issuer, revoked []ucan.Delegation) error { + if revoker == nil { + return errors.New("revoker is required") + } + if len(revoked) == 0 { + return nil + } + invocations := make([]ucan.Invocation, 0, len(revoked)) + for _, d := range revoked { + if d == nil { + return errors.New("revoked delegation is required") + } + inv, err := ucancmd.Revoke.Invoke( + revoker, + revoker.DID(), + &ucancmd.RevokeArguments{Revoke: d.Link()}, + invocation.WithAudience(c.ServiceID), + invocation.WithNoNonce(), + invocation.WithNoExpiration(), + ) + if err != nil { + return fmt.Errorf("creating revoke invocation: %w", err) + } + invocations = append(invocations, inv) + } + response, err := c.executor.ExecuteBatch(batch.NewRequest(ctx, invocations, batch.WithDelegations(revoked...))) + if err != nil { + return fmt.Errorf("publishing revocations: %w", err) + } + for i, inv := range invocations { + receipt, ok := response.Receipt(inv.Task().Link()) + if !ok { + return fmt.Errorf("no receipt for the revocation of %s", revoked[i].Link()) + } + if _, err := ucancmd.Revoke.Unpack(receipt); err != nil { + return fmt.Errorf("unpacking revoke receipt: %w", err) + } + } + return nil +} + // Get retrieves the most recent revocation for delegation. func (c *Client) Get(ctx context.Context, delegationCID cid.Cid) (store.RevocationRecord, error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint("revocation", delegationCID.String()), nil) diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index fab67d6..35b6d92 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -3,6 +3,7 @@ package client import ( "bytes" "context" + "errors" "fmt" "io" "net/http" @@ -22,6 +23,7 @@ import ( "github.com/fil-forge/ucantone/did" "github.com/fil-forge/ucantone/multikey/ed25519" "github.com/fil-forge/ucantone/server" + "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" @@ -103,6 +105,68 @@ func TestPublish(t *testing.T) { }) } +func TestPublishBatch(t *testing.T) { + service, err := ed25519.GenerateIssuer() + require.NoError(t, err) + alice, err := ed25519.GenerateIssuer() + require.NoError(t, err) + bob, err := ed25519.GenerateIssuer() + require.NoError(t, err) + + cmd, err := command.Parse("/test/invoke") + require.NoError(t, err) + first, err := delegation.Delegate(alice, bob.DID(), alice.DID(), cmd) + require.NoError(t, err) + second, err := delegation.Delegate(alice, bob.DID(), alice.DID(), cmd) + require.NoError(t, err) + + var requests int + var revoked []cid.Cid + srv := server.NewHTTP(service) + srv.Handle(ucancmd.Revoke.Command, ucancmd.Revoke.Handler( + func(req *binding.Request[*ucancmd.RevokeArguments], res *binding.Response[*ucancmd.RevokeOK]) error { + revoked = append(revoked, req.Task().Arguments().Revoke) + return res.SetSuccess(&ucancmd.RevokeOK{}) + })) + transport := http.RoundTripper(roundTripFunc(func(r *http.Request) (*http.Response, error) { + requests++ + return srv.RoundTrip(r) + })) + + serviceURL, err := url.Parse("http://swarf.test") + require.NoError(t, err) + client, err := New(service.DID(), *serviceURL, WithHTTPClient(&http.Client{Transport: transport})) + require.NoError(t, err) + + require.NoError(t, client.PublishBatch(context.Background(), alice, []ucan.Delegation{first, second})) + require.Equal(t, 1, requests, "one request carries every revocation") + require.ElementsMatch(t, []cid.Cid{first.Link(), second.Link()}, revoked) + + t.Run("publishes nothing for no delegations", func(t *testing.T) { + requests = 0 + require.NoError(t, client.PublishBatch(context.Background(), alice, nil)) + require.Equal(t, 0, requests) + }) + + t.Run("fails when a revocation is refused", func(t *testing.T) { + refusing := server.NewHTTP(service) + refusing.Handle(ucancmd.Revoke.Command, ucancmd.Revoke.Handler( + func(req *binding.Request[*ucancmd.RevokeArguments], res *binding.Response[*ucancmd.RevokeOK]) error { + if req.Task().Arguments().Revoke == second.Link() { + return res.SetFailure(errors.New("refused")) + } + return res.SetSuccess(&ucancmd.RevokeOK{}) + })) + client, err := New(service.DID(), *serviceURL, WithHTTPClient(&http.Client{Transport: refusing})) + require.NoError(t, err) + require.Error(t, client.PublishBatch(context.Background(), alice, []ucan.Delegation{first, second})) + }) +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + func TestGetAndStream(t *testing.T) { issuer, err := identity.New("", "") require.NoError(t, err)