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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,17 @@ Internal:
`/s3/request/authorize` (derived SigV4 key, with a local fast
path over cached delegations), plus `KeyProofs`/`DelegationCache` — per-
access-key TTL caches of hilt-issued delegations that the uploader and the
network read tier consume via `internal/reqscope`.
network read tier consume via `internal/reqscope`. `KeyProofs` also
remembers every revoked CID for 48h past its last sighting; a response
carrying one is authorized but not cached.
- **`forgeclient/`** — carried-from-guppy sprue edge client: `/blob/add`
(with a deferrable conclude, or by digest code for a streamed blob), `/ucan/conclude`, `/blob/abort`,
`/blob/remove`, `/index/add`, receipt polling. The `/access` login and
`/provider/add` flows are dormant (no CLI drives them).
- **`revocation/`** — the Swarf firehose consumer (optional,
`revocation_service_url`/`_did`): streams UCAN revocations and clears the
affected access key's iam caches via `iam.Revoker`; resumes from the
`revocation_service_url`/`_did`): streams UCAN revocations, records each
revoked CID and clears the affected access key's iam caches via
`iam.Revoker`; resumes from the
`registry.RevocationCursorStore` cursor (no cursor → subscribe from now).
- **`tokenstore/`** — carried-from-guppy delegation store (`tokens.cbor`);
empty today, read only by the dormant login paths.
Expand Down
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ require (
github.com/cenkalti/backoff/v5 v5.0.3
github.com/cloud-portable/s3tests/packages/go v0.0.0-20260916120817-ce77c26e7f6d
github.com/exaring/otelpgx v0.12.0
github.com/fil-forge/hilt v0.0.1-0.20260930162955-d21578eb378c
github.com/fil-forge/hilt v0.0.1-0.20261014112039-42dd070e6a95
github.com/fil-forge/indexing-service v1.13.5-0.20260820151722-9eb620409a4e
github.com/fil-forge/libforge v0.0.0-20261012140631-fbc83e2e745b
github.com/fil-forge/libforge v0.0.0-20261014110314-6d9e32d4eeef
github.com/fil-forge/smelt v0.0.0-20260915151825-96fc212b8d91
github.com/fil-forge/swarf v0.0.1-0.20260821142121-d5d1a0a56f00
github.com/fil-forge/swarf v0.0.1-0.20260922133653-0b2e5b2e1c6c
github.com/fil-forge/ucantone v0.0.0-20260924160040-c31dec73d9b3
github.com/fil-forge/versitygw v0.0.0-20261012091619-8f0754b48e4e
github.com/filecoin-project/go-fee v0.1.1-0.20260930093805-a26324dfe6ad
Expand Down
12 changes: 6 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -183,16 +183,16 @@ 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/go-ipni-tools v0.0.0-20260820150418-78ca0c16834d h1:iEXJC5aJgRHq2iM4X6usfow436OQGauLjDuga1Hiq4o=
github.com/fil-forge/go-ipni-tools v0.0.0-20260820150418-78ca0c16834d/go.mod h1:fwLjqz34DvfoWcuzyH8Wr7dTJdr3mzYjxZRweysHOH4=
github.com/fil-forge/hilt v0.0.1-0.20260930162955-d21578eb378c h1:34pzvgn86df29VPNYPM4rRIL/OXWtzYTCS6Tk+XMcms=
github.com/fil-forge/hilt v0.0.1-0.20260930162955-d21578eb378c/go.mod h1:/8yw/TEeOn15MsKuLVW1GiSkaOOcHp69azdU8mPC/DE=
github.com/fil-forge/hilt v0.0.1-0.20261014112039-42dd070e6a95 h1:l9vpKD8BMlJTXiwD96aM4AYgz9WBWbQr/F7syI2LV8k=
github.com/fil-forge/hilt v0.0.1-0.20261014112039-42dd070e6a95/go.mod h1:hTtzruVHk3+aAfrGd40LwanAniWsO0TB26vzFIZ5bvg=
github.com/fil-forge/indexing-service v1.13.5-0.20260820151722-9eb620409a4e h1:+A26/La8EgF1bsGhSGicYV97ckec1SJwNYLWee3Xo8g=
github.com/fil-forge/indexing-service v1.13.5-0.20260820151722-9eb620409a4e/go.mod h1:hrry50c9bNciNj9Z63O2QFrzidnTjkYYG2UgH4/rmVo=
github.com/fil-forge/libforge v0.0.0-20261012140631-fbc83e2e745b h1:GkjKtf1UXEsUfC+DV32qM1lyzG08hXwpHZThEGxQdHo=
github.com/fil-forge/libforge v0.0.0-20261012140631-fbc83e2e745b/go.mod h1:ZtPwUgXm7gqQSmjmIcfMy5AwWpFIR02WMZvbgNOwi/4=
github.com/fil-forge/libforge v0.0.0-20261014110314-6d9e32d4eeef h1:06Rjco8gNxlNHlxI2QJosvV3p6ydqCM7KKv6p1UOmoQ=
github.com/fil-forge/libforge v0.0.0-20261014110314-6d9e32d4eeef/go.mod h1:ZtPwUgXm7gqQSmjmIcfMy5AwWpFIR02WMZvbgNOwi/4=
github.com/fil-forge/smelt v0.0.0-20260915151825-96fc212b8d91 h1:p6NNlrLKGmxwsfNMcnzrm4PR1M598d89TBu39rbxC6Y=
github.com/fil-forge/smelt v0.0.0-20260915151825-96fc212b8d91/go.mod h1:OUO2GxgUihmYfywNIfvWTQocg6jqaNiTd04cG5BbdII=
github.com/fil-forge/swarf v0.0.1-0.20260821142121-d5d1a0a56f00 h1:uSJ2YCxOJQA7PDnDiBJAOstoJcw2IPnbpssKYCihsxg=
github.com/fil-forge/swarf v0.0.1-0.20260821142121-d5d1a0a56f00/go.mod h1:k1gay5Byjloml4k1SylxhRff1rMH4LcGKpbMEDLZR1o=
github.com/fil-forge/swarf v0.0.1-0.20260922133653-0b2e5b2e1c6c h1:uzLK4I8rD2SXBTeb/qOMbnfD/oAr2RoFVP730abzzyo=
github.com/fil-forge/swarf v0.0.1-0.20260922133653-0b2e5b2e1c6c/go.mod h1:sSe3eJWknrWxnldIpU46aWI2Rvq9PwiA3nUiSFn9Rxk=
github.com/fil-forge/ucantone v0.0.0-20260924160040-c31dec73d9b3 h1:Ta5ODMWR9mM4zMfRp/K1unAKvnt7f5dhehRA+YHiv7w=
github.com/fil-forge/ucantone v0.0.0-20260924160040-c31dec73d9b3/go.mod h1:aX35jlUs3hnWAhigjO8z9kMPiYnkW3XOW8iVoJyz7cg=
github.com/fil-forge/versitygw v0.0.0-20261012091619-8f0754b48e4e h1:QSDhEWn4UyCuB/gTUJou5+uY35ppk5OUcyuUnDtFtCg=
Expand Down
52 changes: 39 additions & 13 deletions iam/keyproofs.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,20 @@ import (
// TTL is refreshed on every access, so an active key is never evicted
// mid-use; a caller already holding a *DelegationCache is unaffected by
// eviction regardless.
//
// It also remembers every delegation CID a revocation named, for revokedTTL
// past the last time Hilt served it. A Hilt write publishes its revocations
// before it commits, so a write whose commit failed leaves the revoked delegations
// stored at Hilt, and Hilt's next authorize response for the key carries
// them again. Each key's store refuses a batch naming this set (see
// [DelegationCache.Add]), and the service then authorizes without caching,
// so the key returns to Hilt on every request until a committed write issues
// fresh delegations (which carry new CIDs: ucantone gives every delegation a
// random nonce).
type KeyProofs struct {
mu sync.Mutex
byKey *gocache.Cache // access-key DID string → *DelegationCache
mu sync.Mutex
byKey *gocache.Cache // access-key DID string → *DelegationCache
revoked *gocache.Cache // revoked delegation CID string → struct{}
}

// keyProofsIdleTTL is how long a key's cache survives with no access before
Expand All @@ -34,9 +45,18 @@ type KeyProofs struct {
// disappears out from under it.
const keyProofsIdleTTL = 24 * time.Hour

// revokedTTL is how long a revoked CID is remembered past its last sighting.
// A revocation names only the CID, and the grant behind it may never expire,
// so the entry must outlive the authorize horizon. It matters only while Hilt
// still serves the grant (a write whose commit failed); each refusal renews it.
const revokedTTL = 48 * time.Hour

// NewKeyProofs returns an empty per-key proof store registry.
func NewKeyProofs() *KeyProofs {
return &KeyProofs{byKey: gocache.New(keyProofsIdleTTL, keyProofsIdleTTL)}
return &KeyProofs{
byKey: gocache.New(keyProofsIdleTTL, keyProofsIdleTTL),
revoked: gocache.New(gocache.NoExpiration, cacheJanitorInterval),
Comment thread
pyropy marked this conversation as resolved.
}
}

// For returns key's proof store, creating it on first use. The idle TTL is
Expand All @@ -50,27 +70,33 @@ func (k *KeyProofs) For(key did.DID) *DelegationCache {
k.byKey.Set(id, dc, gocache.DefaultExpiration) // refresh idle window
return dc
}
dc := NewDelegationCache()
dc := NewDelegationCache(k.revoked)
k.byKey.Set(id, dc, gocache.DefaultExpiration)
return dc
}

// Deposit adds delegations to key's proof store.
// Deposit adds delegations to key's proof store, unless any has been named
// by a revocation (see [DelegationCache.Add]).
func (k *KeyProofs) Deposit(key did.DID, dlgs ...ucan.Delegation) {
k.For(key).Add(dlgs...)
}

// InvalidateHolders drops every per-key proof store holding the delegation
// with CID link, returning the affected access-key DIDs. Dropping the whole
// store rather than the one entry is deliberate: a revoked delegation is a
// hop in every chain the local fast path could assemble for that key, so no
// partial state is worth keeping — the key's next request falls through to
// Hilt, which re-authorizes (or, for a deleted key, refuses). A caller
// already holding the dropped *DelegationCache (an in-flight request) is
// unaffected; the next For(key) starts a fresh empty store.
// InvalidateHolders records link as revoked and drops every per-key proof
// store holding the delegation with that CID, returning the affected
// access-key DIDs. Dropping the whole store rather than the one entry is
// deliberate: a revoked delegation is a hop in every chain the local fast
// path could assemble for that key, so no partial state is worth keeping —
// the key's next request falls through to Hilt, which re-authorizes (or, for
// a deleted key, refuses). A caller already holding the dropped
// *DelegationCache (an in-flight request) is unaffected; the next For(key)
// starts a fresh empty store. A link no store holds is still recorded: the
// revocation may precede the response that would have cached it.
func (k *KeyProofs) InvalidateHolders(link cid.Cid) []did.DID {
k.mu.Lock()
defer k.mu.Unlock()
// A failed Hilt write whose grant goes unserved for revokedTTL is
// forgotten; Hilt refusing to serve grants it revoked would close that.
k.revoked.Set(link.String(), struct{}{}, revokedTTL)
var affected []did.DID
// Items() already skips expired entries; expired stores are gone anyway.
for id, item := range k.byKey.Items() {
Expand Down
37 changes: 37 additions & 0 deletions iam/keyproofs_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package iam

import (
"testing"
"time"

"github.com/fil-forge/ucantone/ucan/delegation"
"github.com/stretchr/testify/require"
)

// TestRevocationTTL: a revocation names only a CID, and the grant behind it
// may never expire, so the revoked set keeps it for revokedTTL — past the
// authorize horizon — and renews it each time Hilt serves the CID again.
func TestRevocationTTL(t *testing.T) {
kp := NewKeyProofs()
dlg := mintDelegation(t, delegation.WithNoExpiration())
id := dlg.Link().String()
expiresIn := func() time.Duration {
item, ok := kp.revoked.Items()[id]
require.True(t, ok, "the revocation is recorded")
return time.Until(time.Unix(0, item.Expiration))
}

kp.InvalidateHolders(dlg.Link())
require.InDelta(t, revokedTTL, expiresIn(), float64(time.Minute), "recorded for revokedTTL, not until midnight")

// A refusal while the entry is young leaves it alone: a read is not a write.
fresh := kp.revoked.Items()[id].Expiration
require.False(t, kp.For(dlg.Audience()).Add(dlg))
require.Equal(t, fresh, kp.revoked.Items()[id].Expiration, "a young revocation is not rewritten")

// Hilt serving the revoked CID again once the entry has used up half its
// life renews it.
kp.revoked.Set(id, struct{}{}, time.Minute)
require.False(t, kp.For(dlg.Audience()).Add(dlg))
require.InDelta(t, revokedTTL, expiresIn(), float64(time.Minute), "a refusal renews an ageing revocation")
}
51 changes: 51 additions & 0 deletions iam/keyproofs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package iam_test

import (
"context"
"sync"
"testing"

contentcmds "github.com/fil-forge/libforge/commands/content"
Expand Down Expand Up @@ -64,3 +65,53 @@ func TestKeyProofsForIsStable(t *testing.T) {
require.NoError(t, err)
require.Len(t, chain, 1)
}

func TestKeyProofsStoreRefusesRevoked(t *testing.T) {
kp := iam.NewKeyProofs()
key, err := ed25519.GenerateIssuer()
require.NoError(t, err)
store := kp.For(key.DID())

// A delegation no store holds: the revocation still has to be remembered.
dlg, _ := grant(t)
require.Empty(t, kp.InvalidateHolders(dlg.Link()))

other, _ := grant(t)
require.False(t, store.Add(other, dlg), "any revoked delegation in the batch refuses the add")
require.False(t, store.Contains(other.Link()), "a refused add adds nothing")
require.True(t, store.Add(other))
require.True(t, store.Contains(other.Link()))
}

// TestRequestScopedStoreKeepsRevoked: a request authorized from a response
// Hilt served after a failed commit still needs that response's delegations
// for its onward retrieval, so a store built outside KeyProofs adds them.
func TestRequestScopedStoreKeepsRevoked(t *testing.T) {
kp := iam.NewKeyProofs()
dlg, _ := grant(t)
kp.InvalidateHolders(dlg.Link())

scoped := iam.NewDelegationCache(nil)
require.True(t, scoped.Add(dlg))
require.True(t, scoped.Contains(dlg.Link()))
}

// TestKeyProofsDepositRacesRevocation races a deposit against the revocation
// of the delegation it carries: whichever lands first, the key's store must
// not end up holding the revoked delegation.
func TestKeyProofsDepositRacesRevocation(t *testing.T) {
for range 200 {
kp := iam.NewKeyProofs()
key, err := ed25519.GenerateIssuer()
require.NoError(t, err)
store := kp.For(key.DID())
dlg, _ := grant(t)

var wg sync.WaitGroup
wg.Go(func() { store.Add(dlg) })
wg.Go(func() { kp.InvalidateHolders(dlg.Link()) })
wg.Wait()

require.False(t, kp.For(key.DID()).Contains(dlg.Link()))
}
}
58 changes: 47 additions & 11 deletions iam/proofcache.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ type DelegationCache struct {
index map[indexKey]map[string]ucan.Delegation // → CID string → delegation

perms *gocache.Cache // bucket DID string → map[string]struct{} of S3 actions

// revoked is the revoked-CID set [KeyProofs] shares across every key's
// store; Add refuses a batch naming any of it. Nil for a request-scoped
// store, which keeps a response's delegations for its onward retrieval.
revoked *gocache.Cache
}

// indexKey is the exact-match probe the delegation matcher uses. Powerline
Expand All @@ -68,22 +73,52 @@ var _ ucanlib.ProofStore = (*DelegationCache)(nil)
// the janitor just bounds memory.
const cacheJanitorInterval = 10 * time.Minute

// NewDelegationCache returns an empty cache.
func NewDelegationCache() *DelegationCache {
// NewDelegationCache returns an empty cache. With revocations, the set of
// revoked delegation CIDs [KeyProofs] keeps, Add refuses any batch naming one
// of them; nil builds a request-scoped store that adds everything.
func NewDelegationCache(revocations *gocache.Cache) *DelegationCache {
d := &DelegationCache{
data: gocache.New(gocache.NoExpiration, cacheJanitorInterval),
index: map[indexKey]map[string]ucan.Delegation{},
perms: gocache.New(gocache.NoExpiration, cacheJanitorInterval),
data: gocache.New(gocache.NoExpiration, cacheJanitorInterval),
index: map[indexKey]map[string]ucan.Delegation{},
perms: gocache.New(gocache.NoExpiration, cacheJanitorInterval),
revoked: revocations,
}
d.data.OnEvicted(d.removeFromIndex)
return d
}

// Add caches each delegation for its own lifetime: TTL = expiration - now,
// no expiration → cached until restart, already expired → skipped.
func (d *DelegationCache) Add(dlgs ...ucan.Delegation) {
// Add caches one Hilt response's proofs, each for its own lifetime: TTL =
// expiration - now, no expiration → cached until restart, already expired →
// skipped. In a key's store (see [KeyProofs.For]) the batch goes in all or
// nothing: if any proof has been named by a revocation, Add caches none of
// them, renews that revocation when it has used up half its life, and
// reports false.
//
// The check and the inserts hold d.mu, and [DelegationCache.Contains] takes
// it too, which orders Add against [KeyProofs.InvalidateHolders]: that
// records the CID before it scans, so a revocation lands either before (the
// check sees it) or after (the scan finds the CID and drops the store), never
// between — where it would find the store without the CID, clear nothing, and
// leave the revoked delegation cached until the horizon.
func (d *DelegationCache) Add(proofs ...ucan.Delegation) bool {
d.mu.Lock()
defer d.mu.Unlock()
if d.revoked != nil {
for _, p := range proofs {
link := p.Link().String()
if _, exp, ok := d.revoked.GetWithExpiration(link); ok {
// Hilt still serves it: keep remembering, renewing the entry only
// once it has used up half its life, so a refusal is not always
// a write.
if time.Until(exp) < revokedTTL/2 {
d.revoked.Set(link, struct{}{}, revokedTTL)
}
return false
}
}
}
now := ucan.Now()
for _, dlg := range dlgs {
for _, dlg := range proofs {
ttl := gocache.NoExpiration
if exp := dlg.Expiration(); exp != nil {
remaining := time.Duration(int64(*exp)-int64(now)) * time.Second
Expand All @@ -97,15 +132,14 @@ func (d *DelegationCache) Add(dlgs ...ucan.Delegation) {
// the cache doesn't hold.
d.data.Set(link, dlg, ttl)
key := keyOf(dlg)
d.mu.Lock()
byLink, ok := d.index[key]
if !ok {
byLink = map[string]ucan.Delegation{}
d.index[key] = byLink
}
byLink[link] = dlg
d.mu.Unlock()
}
return true
}

// removeFromIndex is the go-cache OnEvicted hook: it prunes the evicted
Expand Down Expand Up @@ -142,6 +176,8 @@ func keyOf(dlg ucan.Delegation) indexKey {
// with this CID. Revocation processing uses it to find which access keys'
// stores a revoked delegation participates in.
func (d *DelegationCache) Contains(link cid.Cid) bool {
d.mu.RLock()
defer d.mu.RUnlock()
_, ok := d.data.Get(link.String())
return ok
}
Expand Down
12 changes: 6 additions & 6 deletions iam/proofcache_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func (d *DelegationCache) indexSize() int {
}

func TestIndexTracksAddAndLookup(t *testing.T) {
d := NewDelegationCache()
d := NewDelegationCache(nil)
dlg := mintDelegation(t, delegation.WithNoExpiration())
d.Add(dlg)

Expand All @@ -61,7 +61,7 @@ func TestIndexTracksAddAndLookup(t *testing.T) {
// TestExpiredEntryIsLiveCheckedBeforeJanitor: an expired-but-unswept entry
// stays in the index (janitor hasn't run) but must not yield from lookups.
func TestExpiredEntryIsLiveCheckedBeforeJanitor(t *testing.T) {
d := NewDelegationCache()
d := NewDelegationCache(nil)
dlg := mintDelegation(t, delegation.WithExpiration(ucan.Now()+1))
d.Add(dlg)
require.Equal(t, 1, d.indexSize())
Expand All @@ -80,7 +80,7 @@ func TestExpiredEntryIsLiveCheckedBeforeJanitor(t *testing.T) {
}

func TestExplicitDeletePrunesIndex(t *testing.T) {
d := NewDelegationCache()
d := NewDelegationCache(nil)
dlg := mintDelegation(t, delegation.WithNoExpiration())
d.Add(dlg)
require.Equal(t, 1, d.indexSize())
Expand All @@ -94,7 +94,7 @@ func TestExplicitDeletePrunesIndex(t *testing.T) {
// stale value). The handler must keep the index entry when the cache holds a
// live entry again.
func TestEvictionReAddRace(t *testing.T) {
d := NewDelegationCache()
d := NewDelegationCache(nil)
dlg := mintDelegation(t, delegation.WithNoExpiration())
d.Add(dlg)

Expand Down Expand Up @@ -139,7 +139,7 @@ func TestListDelegationsPrefersTheLongestLived(t *testing.T) {

// Added shortest-first, so insertion order cannot be what puts the
// longest-lived in front.
c := NewDelegationCache()
c := NewDelegationCache(nil)
c.Add(soon, middle, later)

var got []ucan.Delegation
Expand All @@ -166,7 +166,7 @@ func TestListDelegationsRanksAnUnexpiringDelegationFirst(t *testing.T) {
delegation.WithExpiration(ucan.UnixTimestamp(time.Now().Add(20*time.Hour).Unix())))
forever := mintDelegationFor(t, iss, aud.DID(), sub, delegation.WithNoExpiration())

c := NewDelegationCache()
c := NewDelegationCache(nil)
c.Add(bounded, forever)

var first ucan.Delegation
Expand Down
Loading
Loading