feat(store): expire, cap and rate-limit the anonymous registration path - #28
Conversation
|
Merge order. All four of #26, #27, #28 and #29 merge cleanly into
This PR is third in that order. Its |
f6e083b to
cfd791f
Compare
|
Rebased onto Inside the markers
Outside the markers — three things git resolved "cleanly" into a broken tree:
Verification (macOS arm64): Next in the posted order: #27 rebases onto this once it merges. |
push/register needs no credentials, so the handle registry was a map an unauthenticated caller could grow without bound, and every mutation reserialised the whole map under the write lock. Four changes, in order of how much they matter. Expiry is the root-cause fix. A handle is inert until its VTA provisions a trigger, so HandleRecord gains created_at (#[serde(default)], so existing snapshots load) and a tokio sweeper drops handles still unprovisioned after GATEWAY_UNPROVISIONED_TTL_SECS, default 24h. Anonymous growth becomes bounded churn. A provisioned handle is never swept, however old. Caps: GATEWAY_MAX_HANDLES in total, and GATEWAY_MAX_HANDLES_PER_TOKEN live handles per device token or Web Push endpoint, so one token cannot occupy the registry. The per-token count is an index maintained under the same lock as the map, not a scan, so the check stays O(1) under the flood it exists to stop; it keys on a truncated SHA-256 rather than the token, to avoid a second copy of a bearer credential in another map's keys. Rate limits in two layers, because DIDComm bypasses HTTP middleware and is the preferred transport. A tower_governor layer limits POST /trust-tasks per peer IP, which needs into_make_service_with_connect_info; and the transport-agnostic dispatch core limits register against a global budget and provision/wake against a budget keyed by the authenticated DID. The keyed buckets are reclaimed on a timer, since they are themselves keyed by caller-chosen input. Persistence is debounced: mutations set a dirty flag and a background flusher writes at most once per GATEWAY_SNAPSHOT_FLUSH_MS, keeping temp-file + fsync + rename. Drop flushes, so a clean shutdown is durable. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
cfd791f to
67cf60a
Compare
|
Rebased again — #27 merged while the previous rebase was in flight, so this is now on
One real semantic conflict, which the tests caught. #27 moved Verification (macOS arm64): |
The rest of PG-2.
push/registertakes no credentials, so the handle registrywas a map any unauthenticated caller could grow without bound — and every
mutation reserialised the whole map under the write lock, so the cost per
anonymous request was O(n) in the number of handles already registered.
Four changes, deliberately in this order of importance.
1. Unprovisioned handles expire — the root-cause fix
A freshly registered handle is inert: it wakes nobody until its controller
VTA provisions a trigger onto it. So a handle whose allowlist is still empty long
after it was issued is junk, and the flood PoC produced nothing but junk.
HandleRecordgainscreated_at, and a tokio sweeper drops handles that arestill unprovisioned after
GATEWAY_UNPROVISIONED_TTL_SECS(default 24 h).Anonymous growth becomes bounded churn rather than a monotonic leak, which is
what makes the caps below a backstop rather than the whole defence.
created_atis#[serde(default)]so existing snapshots load. Those recordsread as
created_at = 0and, if unprovisioned, are swept on the first pass —which is the intended outcome rather than a quirk: a handle in an old snapshot
that was never provisioned is exactly what the sweep is for. One that was
provisioned has a non-empty allowlist and survives untouched.
A provisioned handle is never swept, at any age. Devices do not re-register on
a schedule, and expiring a live wake binding would break the thing the gateway
exists to do.
2. Caps
GATEWAY_MAX_HANDLES(default 100k) — total, refused asgateway at capacity.GATEWAY_MAX_HANDLES_PER_TOKEN(default 4) — live handles sharing one devicetoken or Web Push endpoint, so one device (or one stolen token) cannot occupy
the registry on its own.
The per-token count is an index maintained under the same lock as the map,
not a scan. A scan would be O(n) per registration, i.e. most expensive during
exactly the flood it is meant to stop. Handles and index live in one
Statestruct behind the single
RwLockso they cannot drift apart.The index keys on a truncated SHA-256 of the token rather than the token itself.
That is not about collision resistance (128 bits is far beyond what a counting
index needs) — it avoids keeping a second copy of a bearer credential in another
map, and in the keys of one, where it is easy to log by accident.
Web Push is keyed on the
endpoint, since the endpoint is the destination andthe keys only encrypt; re-registering the same subscription with fresh keys does
not buy a new slot.
A snapshot that loads over the cap is kept, not truncated, with a warning:
dropping live devices' handles because a limit was lowered would be worse than
being temporarily over it. New registrations are refused until the sweep drains
it.
3. Rate limits, in two layers
Two layers because the DIDComm transport never passes through axum
middleware, and DIDComm is the preferred transport. A
towerlayer alonewould leave the main path unlimited.
tower_governoronPOST /trust-tasks, answering 429before the body is read. This needs
into_make_service_with_connect_info::<SocketAddr>(), which is whymain.rschanges how it serves.
PeerIpKeyExtractoris used; behind a trusted reverseproxy
SmartIpKeyExtractorwould readX-Forwarded-For, which is only soundwhen that proxy is the sole ingress — noted in a comment rather than switched,
because this repo does not know its own deployment.
registerdraws on one global budget (it isanonymous, so there is no caller to key on), and
provision/wakeon a budgetkeyed by the authenticated caller DID, so one misbehaving VTA or trigger is
throttled without affecting anyone else. This layer covers both transports.
The global register budget is an explicit trade, and worth stating plainly: a
flood from anywhere consumes the same bucket, so a sustained attack can crowd out
legitimate registrations. It bounds the work and the registry growth, which is
what PG-2 is about; the per-IP layer is what separates a well-behaved client from
one noisy source. Closing this properly needs a controller identity to key on,
which is PG-9.
tower_governoris taken withdefault-features = false, features = ["axum"]:its default feature set enables
tonicand drags a gRPC stack into a crate thatspeaks neither.
The keyed limiter is itself a map keyed by caller-chosen input — a growth
vector of exactly the kind this PR exists to close.
Limits::shrinkdropsfully-replenished buckets (indistinguishable from absent ones, so there is no
policy effect) and runs on the same 60 s timer as the sweep.
4. Debounced persistence
persist_lockedran on every insert, provision and remove. Mutations now set adirty flag and a background flusher writes at most once per
GATEWAY_SNAPSHOT_FLUSH_MS(default 1000), keeping the temp-file +fsync+rename sequence.
Dropflushes, so a clean shutdown — and a test that drops andreopens — loses nothing, and the flusher takes a final write on cancellation.
Tests
storeandlimitsunit tests plus four integration tests:register_flood_is_refused_after_the_burst— theregister-flood.shPoC: 200registrations, only the burst accepted, and
gateway_register_totalstops atthe burst. The counter is the assertion that matters: it proves the refusals
happened before anything was stored.
sweep_drops_only_stale_unprovisioned_handles— nothing swept at TTL−1, bothunprovisioned handles swept at TTL, the provisioned one untouched;
sweep_loop_runs_on_its_intervaldrives the real task under#[tokio::test(start_paused = true)].eleventh_registration_is_refused_at_capacitywithmax_handles = 10, andrepeated_registration_of_one_token_is_capped.inserts_are_debounced_into_few_snapshot_writes— 1,000 inserts cause zerowrites on their own, one
flushwrites exactly once, a secondflushwritesnothing, and the single write holds all 1,000 handles. There is a write counter
on the store so this asserts the debounce rather than trusting a timer.
legacy_snapshot_without_created_at_loads— a hand-written pre-created_atsnapshot loads, its unprovisioned record sweeps, its provisioned one survives.
wake_budget_is_per_caller_did— a noisy trigger is capped at its burst while adifferent trigger DID is unaffected.
Existing tests use
Limits::permissive(), so a rate limit tripping mid-testcannot turn into a confusing unrelated failure.
The per-IP HTTP layer is applied in
main.rsrather than insideapi::router,deliberately:
PeerIpKeyExtractorrequiresConnectInfo, whichoneshotdoesnot provide, so putting it in
router()would break every integration test forreasons unrelated to what they test. The behaviour that needs a regression test —
a flood being refused and the counter not climbing — is the core limiter's, and
that is tested through the router.
Operator-visible changes
All new, all optional, all with working defaults:
GATEWAY_UNPROVISIONED_TTL_SECSGATEWAY_MAX_HANDLESGATEWAY_MAX_HANDLES_PER_TOKENGATEWAY_SNAPSHOT_FLUSH_MSGATEWAY_REGISTER_PER_SEC/_BURSTGATEWAY_PER_DID_PER_SEC/_BURSTGATEWAY_HTTP_PER_SEC/_BURSTTwo behaviour changes to be aware of before rolling out:
refused. That should not happen in normal use (a device registers once per
install), but a client that re-registers on every service-worker spin-up
without discarding the old handle would hit it. Raise
GATEWAY_MAX_HANDLES_PER_TOKENif so.and only later conveys its handle to its VTA must do so inside the TTL.
The snapshot format is unchanged apart from the added
created_at, which olderbuilds ignore, so this rolls back cleanly.
Verification
cargo fmt --all --check,cargo clippy --all-targets -- -D warnings,cargo test --all-targets(47 unit + 16 integration, all passing) andcargo deny check(advisories/bans/licenses/sources ok — the two new cratesintroduce no new licence).
cargo denyemits one pre-existing warning,advisory-not-detectedforRUSTSEC-2025-0134— thedeny.tomlignore no longer matches anything in thetree. It is a warning, exits 0, and is identical on
origin/main(verifiedagainst an untouched worktree at
ae9a09b).Not in this PR
controller VTAs are legitimate in production. It is also what would let the
anonymous register budget become per-controller instead of global — the one real
weakness in the limits above.
expiresAtonprovision documents; without it there is no window to bound a replay against.