ACL: an expired/exhausted bound token should hard-reject (false), not fall through to prompt-admin (undefined) — clients time out instead of re-pairing #36

Closed
opened 2026-06-21 09:56:00 +00:00 by padreug · 1 comment
Owner

Summary

Surfaced during the bitspire bunker-pairing smoke on the Sintra (2026-06-21; legs 1/2 , leg 7 revoke , leg 8 TTL). When a token's lifecycle lapses post-bind — expiry (#24), per-token revoke, or usage exhaustion (#28) — checkIfPubkeyAllowed falls through to step 5 → undefined ("no auto-decision, ask an admin"), not false ("reject").

For an unattended NIP-46 client (a spire/ATM, no admin watching to approve), undefined routes to the requestAuthorization path → nobody approves → the request hangs → the client times out. That's the wrong client-facing outcome for what is a permanent condition.

The two ACL exits diverge — they shouldn't, for a lapsed binding

Condition ACL path Returns Client (bitspire Phase D) sees
revoke_key_user (subject ban) step 2 KeyUser.revokedAt false BunkerRejectedError"Pairing Required"
token expired (TTL) step-4 live join: no match → step 5 undefined hang → (~10s) BunkerTimeoutError"Signer Unreachable"
token usage-exhausted (#28) same as expiry undefined same
per-token revokedAt (not the KeyUser) same as expiry undefined same

So revoke_key_user (subject-level) rejects cleanly, but every grant-level lapse (the token's own expiry/revoke/usage) surfaces as a transient-looking timeout.

Why the fix belongs bunker-side (bitspire's argument, smoke thread 2026-06-21)

The consumer cannot distinguish "token expired" from "bunker briefly unreachable" — a hung request looks identical either way. So the disambiguation has to happen bunker-side: the authority that knows the lifecycle state must encode it in the response. This is the mirror of the resume-without-connect call.

A BunkerTimeoutError ("Signer Unreachable", transient) makes a customer/operator wait forever; a BunkerRejectedError ("Pairing Required", permanent) prompts a re-pair — the only sane outcome for a spent/lapsed binding.

Proposed change

At step 5, before returning undefined, distinguish:

  • "a grant existed for this (method, kind) but every matching token is non-live" → return false (the binding lapsed — reject so the client re-pairs), vs
  • "no token ever granted this (method, kind)" → keep undefined (the client may be asking for a new permission an admin could grant).

Concretely: if step 4 found no LIVE token, query for a non-live token (past expiresAt / revokedAt set / usage-exhausted) bound to this KeyUser whose policy has a matching PolicyRule for the request. If one exists → false. Else → undefined.

This covers expiry, per-token revoke, and usage exhaustion (#28) uniformly — all three are "the grant was here and is now spent/lapsed."

Keep undefined for the genuine ask-admin case

The prompt-admin path (undefinedrequestAuthorization) is correct for "a known/paired client requests a permission its policy never granted" (e.g. a new kind an admin might allow). That must stay. This change only converts lapsed-grant misses to false; it does not touch never-granted misses.

Tests

The #29/#33 integration harness is the right home: add cases asserting checkIfPubkeyAllowed returns false (not undefined) when the only matching token is expired / token-revoked / usage-exhausted, while a method with no matching rule at all still returns undefined.

Smoke evidence

  • Legs 1/2 (first-pair + resume-without-connect) confirmed on hardware and in the bunker logs (connectsign on boot 1; sign with no connect on boot 2) — #27 working.
  • Leg 7 (revoke) → clean falseBunkerRejectedError (correct).
  • Leg 8 (TTL) → this finding; bitspire is capturing the Sintra's actual leg-8 behavior as live evidence.

Cross-refs

#27 (the live-lifecycle ACL this refines), #24 (TTL), #28 (usage caps — same exit, PR #34), #29/#33 (test harness); aiolabs/bitspire#52/#61 (the BunkerRejectedError vs BunkerTimeoutError consumer mapping); coordination thread smoke-bunker-pairing-sintra.md (2026-06-21).

## Summary Surfaced during the bitspire bunker-pairing smoke on the Sintra (2026-06-21; legs 1/2 ✅, leg 7 revoke ✅, leg 8 TTL). When a token's lifecycle lapses **post-bind** — expiry (#24), per-token revoke, or usage exhaustion (#28) — `checkIfPubkeyAllowed` falls through to **step 5 → `undefined`** ("no auto-decision, ask an admin"), not **`false`** ("reject"). For an unattended NIP-46 client (a spire/ATM, no admin watching to approve), `undefined` routes to the `requestAuthorization` path → nobody approves → the request **hangs** → the client times out. That's the wrong client-facing outcome for what is a **permanent** condition. ## The two ACL exits diverge — they shouldn't, for a lapsed binding | Condition | ACL path | Returns | Client (bitspire Phase D) sees | |---|---|---|---| | `revoke_key_user` (subject ban) | step 2 `KeyUser.revokedAt` | **`false`** | `BunkerRejectedError` → **"Pairing Required"** ✅ | | token **expired** (TTL) | step-4 live join: no match → step 5 | **`undefined`** | hang → (~10s) `BunkerTimeoutError` → **"Signer Unreachable"** ❌ | | token **usage-exhausted** (#28) | same as expiry | **`undefined`** | same ❌ | | per-**token** `revokedAt` (not the KeyUser) | same as expiry | **`undefined`** | same ❌ | So `revoke_key_user` (subject-level) rejects cleanly, but every **grant-level** lapse (the token's own expiry/revoke/usage) surfaces as a transient-looking timeout. ## Why the fix belongs bunker-side (bitspire's argument, smoke thread 2026-06-21) > The consumer **cannot** distinguish "token expired" from "bunker briefly unreachable" — a hung request looks identical either way. So the disambiguation has to happen **bunker-side**: the authority that knows the lifecycle state must encode it in the response. This is the mirror of the resume-without-connect call. A `BunkerTimeoutError` ("Signer Unreachable", transient) makes a customer/operator wait forever; a `BunkerRejectedError` ("Pairing Required", permanent) prompts a re-pair — the only sane outcome for a spent/lapsed binding. ## Proposed change At step 5, before returning `undefined`, distinguish: - **"a grant existed for this (method, kind) but every matching token is non-live"** → return **`false`** (the binding lapsed — reject so the client re-pairs), vs - **"no token ever granted this (method, kind)"** → keep **`undefined`** (the client may be asking for a *new* permission an admin could grant). Concretely: if step 4 found no LIVE token, query for a **non-live** token (past `expiresAt` / `revokedAt` set / usage-exhausted) bound to this `KeyUser` whose policy has a matching `PolicyRule` for the request. If one exists → `false`. Else → `undefined`. This covers expiry, per-token revoke, and usage exhaustion (#28) **uniformly** — all three are "the grant was here and is now spent/lapsed." ## Keep `undefined` for the genuine ask-admin case The prompt-admin path (`undefined` → `requestAuthorization`) is correct for **"a known/paired client requests a permission its policy never granted"** (e.g. a new kind an admin might allow). That must stay. This change only converts **lapsed-grant** misses to `false`; it does not touch **never-granted** misses. ## Tests The #29/#33 integration harness is the right home: add cases asserting `checkIfPubkeyAllowed` returns **`false`** (not `undefined`) when the only matching token is expired / token-revoked / usage-exhausted, while a method with **no** matching rule at all still returns `undefined`. ## Smoke evidence - Legs 1/2 (first-pair + resume-without-connect) confirmed on hardware **and** in the bunker logs (`connect`→`sign` on boot 1; `sign` with no `connect` on boot 2) — #27 working. - Leg 7 (revoke) → clean `false` → `BunkerRejectedError` (correct). - Leg 8 (TTL) → this finding; bitspire is capturing the Sintra's actual leg-8 behavior as live evidence. ## Cross-refs #27 (the live-lifecycle ACL this refines), #24 (TTL), #28 (usage caps — same exit, PR #34), #29/#33 (test harness); `aiolabs/bitspire#52`/#61 (the `BunkerRejectedError` vs `BunkerTimeoutError` consumer mapping); coordination thread `smoke-bunker-pairing-sintra.md` (2026-06-21).
Author
Owner

Fixed via PR #38 (merged to dev @ 3468972). Verified on hardware during the Sintra smoke: an expired/revoked token binding now hard-rejects (BunkerRejectedError → "Pairing Required") instead of hanging to a BunkerTimeoutError. Closing.

Fixed via PR #38 (merged to `dev` @ `3468972`). Verified on hardware during the Sintra smoke: an expired/revoked token binding now hard-rejects (`BunkerRejectedError` → "Pairing Required") instead of hanging to a `BunkerTimeoutError`. Closing.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/nsecbunkerd#36
No description provided.