Skip to content
Closed
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
131 changes: 130 additions & 1 deletion iam/keyproofs.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,35 @@ 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.
// Keys are also indexed by the (tenant, principal) pair Hilt reports for
// them, so a principal invalidation from the firehose can drop every store
// belonging to that principal without knowing which keys they are. See
// [KeyProofs.Bind] and [KeyProofs.InvalidatePrincipal].
type KeyProofs struct {
mu sync.Mutex
byKey *gocache.Cache // access-key DID string → *DelegationCache

// The principal index has its own mutex, deliberately not mu: go-cache
// calls the byKey eviction hook synchronously from Delete, and
// InvalidateHolders deletes while holding mu, so a hook that took mu
// would deadlock.
pmu sync.Mutex
byPrincipal map[PrincipalRef]map[string]struct{} // → set of access-key DID strings
keyPrincipal map[string]PrincipalRef // access-key DID string → its pair
}

// PrincipalRef identifies a principal: a userId is unique only within its
// tenant, so the tenant is part of the identity. It is what Hilt names in an
// authorize result and what a firehose principal event carries.
type PrincipalRef struct {
Tenant did.DID
Principal string
}

// Defined reports whether the reference names a principal. An undefined
// tenant or an empty userId is not a principal and never indexes anything.
func (r PrincipalRef) Defined() bool {
return r.Tenant.Defined() && r.Principal != ""
}

// keyProofsIdleTTL is how long a key's cache survives with no access before
Expand All @@ -36,7 +62,110 @@ const keyProofsIdleTTL = 24 * time.Hour

// NewKeyProofs returns an empty per-key proof store registry.
func NewKeyProofs() *KeyProofs {
return &KeyProofs{byKey: gocache.New(keyProofsIdleTTL, keyProofsIdleTTL)}
k := &KeyProofs{
byKey: gocache.New(keyProofsIdleTTL, keyProofsIdleTTL),
byPrincipal: map[PrincipalRef]map[string]struct{}{},
keyPrincipal: map[string]PrincipalRef{},
}
k.byKey.OnEvicted(k.unbind)
return k
}

// Bind records that key belongs to a principal, so
// [KeyProofs.InvalidatePrincipal] can find it. Idempotent: re-binding the
// same pair changes nothing. A key that moves to another principal (Hilt
// reporting a different pair) is re-indexed under the new one.
func (k *KeyProofs) Bind(key did.DID, ref PrincipalRef) {
if !ref.Defined() || !key.Defined() {
return
}
id := key.String()
k.pmu.Lock()
defer k.pmu.Unlock()
if cur, ok := k.keyPrincipal[id]; ok {
if cur == ref {
return
}
k.detachLocked(id, cur)
}
k.keyPrincipal[id] = ref
ids, ok := k.byPrincipal[ref]
if !ok {
ids = map[string]struct{}{}
k.byPrincipal[ref] = ids
}
ids[id] = struct{}{}
}

// InvalidatePrincipal drops the proof store of every access key bound to
// ref, returning the affected access-key DIDs. It mirrors
// [KeyProofs.InvalidateHolders]: the whole store goes, so the key's next
// request falls through to Hilt, which re-authorizes it against the
// principal's current access (or refuses). Idempotent — the bindings go
// with the stores, so a re-delivered event affects nothing — and a pair
// nothing is bound to is a no-op.
func (k *KeyProofs) InvalidatePrincipal(ref PrincipalRef) []did.DID {
if !ref.Defined() {
return nil
}
// Snapshot the bound keys and release the index lock before deleting:
// the delete runs the eviction hook, which takes this same lock.
k.pmu.Lock()
ids := make([]string, 0, len(k.byPrincipal[ref]))
for id := range k.byPrincipal[ref] {
ids = append(ids, id)
}
k.pmu.Unlock()

var affected []did.DID
for _, id := range ids {
key, err := did.Parse(id)
if err != nil {
continue // unreachable: ids are Bind(did, …).String()
}
k.mu.Lock()
k.byKey.Delete(id) // prunes the binding via unbind, when a store exists
k.mu.Unlock()
// A key whose store had already been evicted leaves no store to
// delete and so no hook to run; drop its binding here so the pair
// stays idempotent. The liveness guard in unbind keeps a store that
// a concurrent request re-created (and re-bound) after the delete.
k.unbind(id, nil)
affected = append(affected, key)
}
return affected
}

// unbind is the byKey eviction hook: a dropped store takes its principal
// binding with it. Guarded against the re-add race the same way
// [DelegationCache.removeFromIndex] is — an idle eviction that raced a
// fresh For(key) must not unbind the live store. The liveness check runs
// under pmu so a Bind that lands between the eviction and this hook cannot
// be undone: Bind takes pmu too, so either it ran before the check (and the
// store reads live) or it runs after the detach (and re-indexes the key).
func (k *KeyProofs) unbind(id string, _ any) {
k.pmu.Lock()
defer k.pmu.Unlock()
if _, live := k.byKey.Get(id); live {
return
}
if ref, ok := k.keyPrincipal[id]; ok {
k.detachLocked(id, ref)
}
}

// detachLocked removes id from ref's key set and forgets its binding. The
// caller holds pmu.
func (k *KeyProofs) detachLocked(id string, ref PrincipalRef) {
delete(k.keyPrincipal, id)
ids, ok := k.byPrincipal[ref]
if !ok {
return
}
delete(ids, id)
if len(ids) == 0 {
delete(k.byPrincipal, ref)
}
}

// For returns key's proof store, creating it on first use. The idle TTL is
Expand Down
40 changes: 40 additions & 0 deletions iam/keyproofs_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package iam

import (
"testing"

"github.com/fil-forge/ucantone/did"
"github.com/fil-forge/ucantone/multikey/ed25519"
"github.com/stretchr/testify/require"
)

// An eviction hook that fires after a request has already re-created and
// re-bound the key's store must leave the new binding alone; otherwise the
// live store would be missed by the next principal invalidation.
func TestUnbindKeepsLiveRebinding(t *testing.T) {
kp := NewKeyProofs()
key, err := ed25519.GenerateIssuer()
require.NoError(t, err)
ref := PrincipalRef{Tenant: key.DID(), Principal: "member"}
id := key.DID().String()

kp.For(key.DID())
kp.Bind(key.DID(), ref)

// The stale hook for an evicted store runs after the re-create and
// re-bind: the store is live, so the binding stays.
kp.unbind(id, nil)
require.Equal(t, []string{id}, didStrings(kp.InvalidatePrincipal(ref)))

// With no live store the hook drops the binding, and the pair is empty.
kp.unbind(id, nil)
require.Empty(t, kp.InvalidatePrincipal(ref))
}

func didStrings(ids []did.DID) []string {
out := make([]string, 0, len(ids))
for _, d := range ids {
out = append(out, d.String())
}
return out
}
94 changes: 94 additions & 0 deletions iam/keyproofs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"testing"

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

// TestKeyProofsPrincipalIndex covers the (tenant, principal) index a
// firehose principal event resolves against.
func TestKeyProofsPrincipalIndex(t *testing.T) {
tenant := did.MustParse("did:plc:ewvi7nxzyoun6zhxrhs64oiz")
ada := iam.PrincipalRef{Tenant: tenant, Principal: "ada"}
grace := iam.PrincipalRef{Tenant: tenant, Principal: "grace"}

t.Run("both stores for a pair drop, a sibling principal survives", func(t *testing.T) {
kp := iam.NewKeyProofs()
keyA, dlgA := boundKey(t, kp, ada)
keyB, dlgB := boundKey(t, kp, ada)
keyC, dlgC := boundKey(t, kp, grace)

affected := kp.InvalidatePrincipal(ada)
require.ElementsMatch(t, []did.DID{keyA, keyB}, affected)
require.False(t, kp.For(keyA).Contains(dlgA.Link()), "ada's first key must lose its store")
require.False(t, kp.For(keyB).Contains(dlgB.Link()), "ada's second key must lose its store")
require.True(t, kp.For(keyC).Contains(dlgC.Link()), "grace's key must be untouched")
})

t.Run("re-delivery is a no-op", func(t *testing.T) {
kp := iam.NewKeyProofs()
key, _ := boundKey(t, kp, ada)

require.Equal(t, []did.DID{key}, kp.InvalidatePrincipal(ada))
require.Empty(t, kp.InvalidatePrincipal(ada), "the binding goes with the store")
})

t.Run("an unknown pair is a no-op", func(t *testing.T) {
kp := iam.NewKeyProofs()
key, dlg := boundKey(t, kp, ada)

require.Empty(t, kp.InvalidatePrincipal(grace))
require.Empty(t, kp.InvalidatePrincipal(iam.PrincipalRef{
Tenant: did.MustParse("did:plc:44444444444444444444444444"),
Principal: "ada",
}), "the same userId under another tenant is another principal")
require.True(t, kp.For(key).Contains(dlg.Link()))
})

t.Run("binding is idempotent", func(t *testing.T) {
kp := iam.NewKeyProofs()
key, _ := boundKey(t, kp, ada)
kp.Bind(key, ada)

require.Equal(t, []did.DID{key}, kp.InvalidatePrincipal(ada))
})

t.Run("a key re-bound to another principal follows it", func(t *testing.T) {
kp := iam.NewKeyProofs()
key, _ := boundKey(t, kp, ada)
kp.Bind(key, grace)

require.Empty(t, kp.InvalidatePrincipal(ada), "the old pair must not still claim the key")
require.Equal(t, []did.DID{key}, kp.InvalidatePrincipal(grace))
})

t.Run("an incomplete reference indexes nothing", func(t *testing.T) {
kp := iam.NewKeyProofs()
key, dlg := boundKey(t, kp, iam.PrincipalRef{Tenant: tenant})
kp.Bind(key, iam.PrincipalRef{Principal: "ada"})

require.Empty(t, kp.InvalidatePrincipal(iam.PrincipalRef{Tenant: tenant}))
require.Empty(t, kp.InvalidatePrincipal(iam.PrincipalRef{Principal: "ada"}))
require.True(t, kp.For(key).Contains(dlg.Link()))
})

// The index must not deadlock against InvalidateHolders: go-cache runs
// the byKey eviction hook synchronously while that method holds the
// registry mutex, so the hook takes a different lock.
t.Run("a CID revocation also drops the binding", func(t *testing.T) {
kp := iam.NewKeyProofs()
key, dlg := boundKey(t, kp, ada)

require.Equal(t, []did.DID{key}, kp.InvalidateHolders(dlg.Link()))
require.Empty(t, kp.InvalidatePrincipal(ada), "the evicted store's binding must be gone")
})
}

// boundKey generates an access key, binds it to ref and deposits one
// delegation in its store, returning the key and that delegation. Whether
// the store survives an invalidation is read back through the delegation.
func boundKey(t *testing.T, kp *iam.KeyProofs, ref iam.PrincipalRef) (did.DID, ucan.Delegation) {
t.Helper()
signer, err := ed25519.GenerateIssuer()
require.NoError(t, err)
kp.Bind(signer.DID(), ref)
dlg, _ := grant(t)
kp.Deposit(signer.DID(), dlg)
require.True(t, kp.For(signer.DID()).Contains(dlg.Link()))
return signer.DID(), dlg
}
51 changes: 37 additions & 14 deletions iam/revoker.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ import (
"go.uber.org/zap"
)

// Revoker applies UCAN revocations to the local authorization caches: it is
// the bridge between the revocation-firehose consumer (which sees only
// delegation CIDs) and the per-access-key state the local fast path reads
// (see Service.authorizeLocal). After Revoke returns for a delegation in key
// K's chains, a request signed with K cannot authorize locally — its proof
// store is gone (defeating the chain probe) and so are its verification keys
// (defeating local signature verification) — so the request falls through to
// Hilt, which refuses for a deleted key.
// Revoker applies invalidations to the local authorization caches: it is the
// bridge between the firehose consumer (which sees delegation CIDs and
// principal references) and the per-access-key state the local fast path
// reads (see Service.authorizeLocal). After Revoke or InvalidatePrincipal
// returns for key K, a request signed with K cannot authorize locally — its
// proof store is gone (defeating the chain probe and the cached action set)
// and so are its verification keys (defeating local signature verification)
// — so the request falls through to Hilt, which re-authorizes it against the
// current access or refuses.
type Revoker struct {
proofs *KeyProofs
keys *VerificationKeyCache
Expand All @@ -39,15 +40,37 @@ func NewRevoker(proofs *KeyProofs, keys *VerificationKeyCache, tenants *TenantCa
// revocation is a no-op.
func (r *Revoker) Revoke(revoked cid.Cid) []did.DID {
affected := r.proofs.InvalidateHolders(revoked)
r.clearKeys(affected, "iam: access key caches cleared by revocation",
zap.Stringer("revoked", revoked))
return affected
}

// InvalidatePrincipal clears the caches of every access key bound to
// (tenant, principal), returning the affected key DIDs. Hilt publishes the
// invalidation before it commits a change to what the principal may do, so
// the next request signed with one of these keys is re-authorized against
// the new access. An empty result means no cached key belonged to the
// principal — nothing local depended on its access. Idempotent: re-delivery
// of an invalidation, and an invalidation for a principal ingot has never
// seen, are both no-ops.
func (r *Revoker) InvalidatePrincipal(tenant did.DID, principal string) []did.DID {
affected := r.proofs.InvalidatePrincipal(PrincipalRef{Tenant: tenant, Principal: principal})
r.clearKeys(affected, "iam: access key caches cleared by principal invalidation",
zap.Stringer("tenant", tenant), zap.String("principal", principal))
return affected
}

// clearKeys drops the verification key and tenant cached for each access
// key, which is what stops a local authorization: with no verification key
// the signature cannot be checked locally, and with no tenant the write
// path has nothing to encrypt to. Both caches are keyed by the accessKeyId
// — the key's did:key identifier with the prefix stripped (see
// GetUserAccountForRequest).
func (r *Revoker) clearKeys(affected []did.DID, reason string, fields ...zap.Field) {
for _, key := range affected {
// The verification-key cache is keyed by the accessKeyId — the key's
// did:key identifier with the prefix stripped (see
// GetUserAccountForRequest).
access := strings.TrimPrefix(key.String(), did.KeyPrefix)
r.keys.Delete(access)
r.tenants.Delete(access)
r.logger.Info("iam: access key caches cleared by revocation",
zap.Stringer("access", key), zap.Stringer("revoked", revoked))
r.logger.Info(reason, append([]zap.Field{zap.Stringer("access", key)}, fields...)...)
}
return affected
}
Loading
Loading