Skip to content
Draft
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
18 changes: 17 additions & 1 deletion docs/security/signature_observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ Operations that cannot be attributed to a single identity — `AreMe` over a bat
instance — are reported with an empty principal and are never throttled. Charging a
batch to one of its members would let unrelated callers throttle each other.

### Rotating pseudonyms and the principal-key resolver

Some identity schemes rotate: Idemix lets a party present a fresh, unlinkable pseudonym
on every request. Keyed on the raw identity hash, each such request looks like a brand-new
principal with a full quota, so pseudonym rotation walks straight around the per-principal
limit. To close that, the gate keys on a **stable throttle key** when one can be resolved:
the party's **enrollment ID** (namespaced as `eid:<id>`), derived from locally known audit
information, so a party's rotating pseudonyms are charged to one quota. An identity whose
enrollment ID cannot be resolved cheaply from local state — an unregistered pseudonym, or
any identity scheme without enrollment IDs — falls back to the identity hash, the safe
default (an over-broad key would let unrelated identities throttle each other). Resolution
is only attempted when a gate is installed, so an un-gated service pays nothing for it.

## Where the gate applies

**Instrumentation is installed everywhere. The gate is consulted at exactly one place:
Expand Down Expand Up @@ -171,7 +184,10 @@ number of tracked principals *and* the number of token buckets held at once by c
When the cap is reached the oldest idle `normal` entry is evicted to make room; if only
throttled entries remain, a new identity is served as `normal` with a full bucket rather
than displacing a throttled one (which would silently restore its reduced quota). This
keeps the throttle's own footprint bounded under abuse.
keeps the throttle's own footprint bounded under abuse. Eviction picks its victim in
constant time (an intrusive LRU list per level), so a party presenting a fresh pseudonym
per request — the very traffic the cap exists for — adds no per-request scan of the
tracked set on the signature path.

## Handling a denial

Expand Down
75 changes: 75 additions & 0 deletions token/principalkey.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
Copyright IBM Corp. All Rights Reserved.

SPDX-License-Identifier: Apache-2.0
*/

package token

import (
"context"

"github.com/LFDT-Panurus/panurus/token/driver"
)

// enrollmentKeyPrefix namespaces a resolved enrollment-ID throttle key so it can never collide
// with the fallback keyspace, which is a raw Identity.UniqueID(). UniqueID() is a content hash and
// an enrollment ID is an operator-assigned string, so a collision is already implausible; the
// prefix keeps the two keyspaces provably disjoint regardless.
const enrollmentKeyPrefix = "eid:"

// enrollmentResolver is the slice of driver.IdentityProvider that the enrollment-ID resolver
// needs: a local audit-info lookup and the enrollment-ID extraction. It is declared on the
// consumer side (rather than taking the whole provider) so the resolver can be exercised with a
// small fake. driver.IdentityProvider satisfies it.
type enrollmentResolver interface {
// GetAuditInfo returns the locally stored audit information for identity, or an error / empty
// slice when none is known.
GetAuditInfo(ctx context.Context, identity driver.Identity) ([]byte, error)
// GetEIDAndRH extracts the enrollment ID and revocation handle carried by auditInfo.
GetEIDAndRH(ctx context.Context, identity driver.Identity, auditInfo []byte) (string, string, error)
}

// enrollmentKeyResolver is a PrincipalKeyResolver that folds a party's rotating pseudonyms onto its
// enrollment ID, so that a throttle quota follows the party rather than the pseudonym it presents.
//
// It resolves only from locally known audit information: it asks the identity provider for the
// identity's audit info and, when that is present, extracts the enrollment ID from it. An identity
// with no locally known audit info (the typical unregistered rotating pseudonym) resolves to "",
// which tells the gate to fall back to the identity hash — the safe default. It therefore does no
// network or remote resolution on the signature path, as PrincipalKeyResolver requires.
//
// One accepted cost: driver.IdentityProvider.GetAuditInfo is itself instrumented, so consulting it
// here emits one extra OpGetAuditInfo observation per gated operation. That is benign — it only
// inflates a principal's window sample total (OpEscalation events are ignored by the policy's own
// Observe, so there is no feedback loop) — and it is paid only when a gate is actually installed.
type enrollmentKeyResolver struct {
provider enrollmentResolver
}

// newEnrollmentKeyResolver returns a resolver backed by provider. provider is the identity
// provider the signature service already holds (token manager service's IdentityProvider()).
func newEnrollmentKeyResolver(provider enrollmentResolver) *enrollmentKeyResolver {
return &enrollmentKeyResolver{provider: provider}
}

// ThrottleKey returns the enrollment-ID throttle key for id, or "" to fall back to id.UniqueID().
// It returns "" whenever the identity cannot be resolved from local audit info — an unregistered
// pseudonym, an empty identity, or any lookup error — rather than chasing it on the hot path.
func (r *enrollmentKeyResolver) ThrottleKey(ctx context.Context, id Identity) string {
if r == nil || r.provider == nil {
return ""
}

auditInfo, err := r.provider.GetAuditInfo(ctx, id)
if err != nil || len(auditInfo) == 0 {
return ""
}

eid, _, err := r.provider.GetEIDAndRH(ctx, id, auditInfo)
if err != nil || eid == "" {
return ""
}

return enrollmentKeyPrefix + eid
}
109 changes: 109 additions & 0 deletions token/principalkey_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
Copyright IBM Corp. All Rights Reserved.

SPDX-License-Identifier: Apache-2.0
*/

package token

import (
"context"
"testing"

"github.com/LFDT-Panurus/panurus/token/driver"
"github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors"
"github.com/stretchr/testify/assert"
)

// fakeEnrollment is a minimal enrollmentResolver whose two lookups are preset per test case, and
// which counts its calls so the short-circuit (no EID lookup without audit info) can be asserted.
type fakeEnrollment struct {
auditInfo []byte
auditErr error
eid string
rh string
eidErr error
auditCalls int
eidCalls int
}

func (f *fakeEnrollment) GetAuditInfo(context.Context, driver.Identity) ([]byte, error) {
f.auditCalls++

return f.auditInfo, f.auditErr
}

func (f *fakeEnrollment) GetEIDAndRH(context.Context, driver.Identity, []byte) (string, string, error) {
f.eidCalls++

return f.eid, f.rh, f.eidErr
}

// TestEnrollmentKeyResolver pins the resolver's contract: a namespaced enrollment-ID key for an
// identity with locally known audit info, and "" (fall back to the identity hash) for every case
// it cannot resolve cheaply — no audit info, a lookup error, or an empty enrollment ID — without
// chasing the enrollment ID when there is no audit info to derive it from.
func TestEnrollmentKeyResolver(t *testing.T) {
tests := []struct {
name string
fake fakeEnrollment
wantKey string
wantEIDLookup bool
}{
{
name: "resolves to a namespaced enrollment id",
fake: fakeEnrollment{auditInfo: []byte("audit-info"), eid: "alice"},
wantKey: "eid:alice",
wantEIDLookup: true,
},
{
name: "no audit info falls back and never looks up the eid",
fake: fakeEnrollment{auditInfo: nil},
wantKey: "",
wantEIDLookup: false,
},
{
name: "audit-info error falls back and never looks up the eid",
fake: fakeEnrollment{auditErr: errors.New("boom")},
wantKey: "",
wantEIDLookup: false,
},
{
name: "empty enrollment id falls back",
fake: fakeEnrollment{auditInfo: []byte("audit-info"), eid: ""},
wantKey: "",
wantEIDLookup: true,
},
{
name: "enrollment-id error falls back",
fake: fakeEnrollment{auditInfo: []byte("audit-info"), eidErr: errors.New("boom")},
wantKey: "",
wantEIDLookup: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fake := test.fake
r := newEnrollmentKeyResolver(&fake)

key := r.ThrottleKey(t.Context(), Identity("a_pseudonym"))

assert.Equal(t, test.wantKey, key)
assert.Equal(t, 1, fake.auditCalls, "audit info must be looked up exactly once")
if test.wantEIDLookup {
assert.Equal(t, 1, fake.eidCalls, "the enrollment id must be derived from the audit info")
} else {
assert.Zero(t, fake.eidCalls, "the enrollment id must not be chased without audit info")
}
})
}
}

// TestEnrollmentKeyResolverToleratesNilProvider guards the defensive nil checks: a resolver with no
// provider resolves nothing rather than panicking on the signature path.
func TestEnrollmentKeyResolverToleratesNilProvider(t *testing.T) {
assert.Empty(t, newEnrollmentKeyResolver(nil).ThrottleKey(t.Context(), Identity("x")))

var r *enrollmentKeyResolver
assert.Empty(t, r.ThrottleKey(t.Context(), Identity("x")))
}
135 changes: 135 additions & 0 deletions token/services/identity/throttle/escalator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1105,3 +1105,138 @@ func TestEscalatorDetachedPrincipalDoesNotLeakGauge(t *testing.T) {
e.mu.Unlock()
assert.Equal(t, 1, n, "the principals map must not grow past MaxPrincipals")
}

// ids returns the principal ids in the list from most- to least-recently-seen.
func (l *lruList) ids() []string {
var out []string
for p := l.head.next; p != l.tail; p = p.next {
out = append(out, p.id)
}

return out
}

// assertEscalatorLRUConsistent pins the list/map invariant: every tracked LevelNormal principal is
// in lruNormal, every LevelSoft one in lruSoft, every LevelBlocked one in neither, each linked once
// and ordered most-recently-seen first. Callers must not hold e.mu.
func assertEscalatorLRUConsistent(t *testing.T, e *Escalator) {
t.Helper()
e.mu.Lock()
defer e.mu.Unlock()

seen := map[string]bool{}
for _, l := range []*lruList{e.lruNormal, e.lruSoft} {
var prev time.Time
first := true
for p := l.head.next; p != l.tail; p = p.next {
require.Contains(t, e.principals, p.id, "a listed principal must be in the map")
require.False(t, seen[p.id], "a principal must appear in a list at most once")
seen[p.id] = true
want := LevelNormal
if l == e.lruSoft {
want = LevelSoft
}
require.Equal(t, want, p.level, "a principal must be in the list matching its level")
if !first {
require.False(t, prev.Before(p.lastSeen), "the list must be ordered most-recently-seen first")
}
prev, first = p.lastSeen, false
}
}
for id, p := range e.principals {
switch p.level {
case LevelBlocked:
require.False(t, seen[id], "a blocked principal must be in no list")
default:
require.True(t, seen[id], "every normal or soft principal must be tracked in a list")
}
}
}

// TestEscalatorEvictionFollowsAccessRecency pins that evictToMakeRoom honours lastSeen as kept by
// the LRU list: re-seeing an older principal spares it, and the one that has gone untouched is
// evicted — not simply the first-created.
func TestEscalatorEvictionFollowsAccessRecency(t *testing.T) {
cfg := enforcing()
cfg.MaxPrincipals = 2
clock := newTestClock()
e := newTestEscalator(t, cfg, clock)
ctx := t.Context()

require.NoError(t, e.Allow(ctx, "p1", sigobserve.OpSign)) // t0
clock.advance(time.Second)
require.NoError(t, e.Allow(ctx, "p2", sigobserve.OpSign)) // t1
clock.advance(time.Second)
require.NoError(t, e.Allow(ctx, "p1", sigobserve.OpSign)) // t2: p1 re-seen, p2 now the LRU
clock.advance(time.Second)

require.NoError(t, e.Allow(ctx, "p3", sigobserve.OpSign)) // at cap: evicts p2, keeps p1

e.mu.Lock()
_, p1kept := e.principals["p1"]
_, p2kept := e.principals["p2"]
_, p3kept := e.principals["p3"]
e.mu.Unlock()
assert.True(t, p1kept, "a recently re-seen principal must not be evicted")
assert.False(t, p2kept, "the least-recently-seen principal must be evicted")
assert.True(t, p3kept, "the new arrival must be inserted")
assertEscalatorLRUConsistent(t, e)
}

// TestEscalatorLRUMembershipFollowsLevel pins that a principal moves between the two lists as its
// level changes and leaves both while blocked, so eviction never picks a blocked principal.
func TestEscalatorLRUMembershipFollowsLevel(t *testing.T) {
cfg := enforcing()
clock := newTestClock()
e := newTestEscalator(t, cfg, clock)

// A plain operation leaves "n" at normal, so it sits in lruNormal.
require.NoError(t, e.Allow(t.Context(), "n", sigobserve.OpSign))
assert.Equal(t, []string{"n"}, e.lruNormal.ids())
assert.Empty(t, e.lruSoft.ids())

// Driving "s" to soft moves it into lruSoft, out of lruNormal.
observeInvalid(t, e, "s", cfg.MinSamples)
require.Equal(t, LevelSoft, e.Level("s"))
assert.Equal(t, []string{"n"}, e.lruNormal.ids())
assert.Equal(t, []string{"s"}, e.lruSoft.ids())
assertEscalatorLRUConsistent(t, e)

// Driving "s" on to blocked removes it from every list: a block must never be evicted.
clock.advance(cfg.SoftDuration + time.Second)
observeInvalid(t, e, "s", cfg.MinSamples)
require.Equal(t, LevelBlocked, e.Level("s"))
assert.Equal(t, []string{"n"}, e.lruNormal.ids())
assert.Empty(t, e.lruSoft.ids(), "a blocked principal must be in no list")
assertEscalatorLRUConsistent(t, e)

// When the block expires it is released to soft, rejoining lruSoft.
clock.advance(cfg.BlockDuration + time.Second)
observeInvalid(t, e, "s", 1) // an observation drives maintainLevel, releasing the expired block
require.Equal(t, LevelSoft, e.Level("s"))
assert.Equal(t, []string{"s"}, e.lruSoft.ids())
assertEscalatorLRUConsistent(t, e)
}

// BenchmarkSaturatedEviction documents the hot-path win: with the map full of normal principals,
// every fresh principal pays only an O(1) list-tail eviction, not a full-map scan. It is a
// benchmark, not an assertion — constant time is not safely assertable in a unit test.
func BenchmarkSaturatedEviction(b *testing.B) {
cfg := enforcing()
cfg.MaxPrincipals = 10_000
require.NoError(b, cfg.Defaults())
clock := newTestClock()
e := New(cfg, func(e *Escalator) { e.now = clock.Now })
b.Cleanup(e.Stop)

ctx := b.Context()
for i := range 10_000 { // fill to the cap with normal principals
_ = e.Allow(ctx, strconv.Itoa(i), sigobserve.OpSign)
}

b.ResetTimer()
for i := range b.N {
// Each key is unseen, so every Allow evicts the LRU-normal tail to make room.
_ = e.Allow(ctx, "fresh-"+strconv.Itoa(i), sigobserve.OpSign)
}
}
Loading
Loading