diff --git a/pairing.py b/pairing.py index ed5684d..be7a6a4 100644 --- a/pairing.py +++ b/pairing.py @@ -166,14 +166,13 @@ async def pair_spire( return the seed URL the spire redeems at first boot. `duration_hours` (optional, aiolabs/lnbits#54 item 2) stamps `expiresAt` - on the spire's connect token. NOTE: this bounds ONLY the window in which - an *un-redeemed* token can first connect — nsecbunkerd reads `expiresAt` - solely in `validateToken` at redeem time. Once the spire has connected - and its per-KeyUser grants are materialized, an expired token keeps - signing (the sign-time ACL never checks `expiresAt`; same ACL-ordering - subtlety as the revoke finding, #22). The real post-bind cutoff is - `revoke_spire` (`revoke_key_user`), not TTL. Post-bind TTL enforcement is - tracked at aiolabs/nsecbunkerd#24. None = non-expiring connect window. + on the spire's connect token, bounding the established binding's lifetime. + Since aiolabs/nsecbunkerd#27 (deployed 2026-06-19) the sign-time ACL + evaluates token lifecycle on EVERY request (`checkIfPubkeyAllowed` step 4 + joins through a `liveWhere` filter; `applyToken` no longer photocopies + grants), so an expired token stops signing post-bind, not just at connect. + The spire must re-pair to keep signing once the token lapses. None = + non-expiring (the only invalidation path is then `revoke_spire`). `admin_client` must already be connected (the caller owns the `async with NsecBunkerAdminClient.from_settings()` context) — keeps @@ -255,16 +254,16 @@ async def revoke_spire( machine: Machine, *, admin_client: NsecBunkerAdminClient ) -> int: """Revoke a spire's bunker access (the "Revoke spire access" UX, - aiolabs/spirekeeper#9/#12; security model per #22). + aiolabs/spirekeeper#9/#12). - Calls `revoke_key_user` — NOT `revoke_token` / `revoke_key_token`. - lnbits eager-binds (redeems) the connect token at provision time - (aiolabs/lnbits#32), so nsecbunkerd has already materialised the - token's policy into standing per-`KeyUser` `SigningCondition` grants; - its sign-time ACL checks those *before* the `Token.revokedAt` filter, - so revoking the token is a silent no-op (the spire keeps signing). - Only `KeyUser.revokedAt` — set by `revoke_user` / `revoke_key_user` — - actually cuts off signing (verified live 2026-06-18, #22). + Calls `revoke_key_user` (sets `KeyUser.revokedAt`) — the subject-level + sticky ban that's checked at step 2 of `checkIfPubkeyAllowed`, beating + every grant. This cuts the WHOLE binding regardless of how many tokens + were issued to the spire, which is the right semantics for "revoke this + spire." (Since aiolabs/nsecbunkerd#27 token-revoke also works post-bind — + the sign-time ACL now evaluates `Token.revokedAt`/`expiresAt` live every + request, closing the #22 no-op — but per-token revoke only cuts one + token's grant, so `revoke_key_user` remains the correct full-deauth call.) Returns the number of KeyUsers revoked: >= 1 means the spire's signing access is now cut; 0 means nothing was bound (token minted but the diff --git a/tests/test_pairing.py b/tests/test_pairing.py index 54f00ca..080f5f9 100644 --- a/tests/test_pairing.py +++ b/tests/test_pairing.py @@ -287,8 +287,10 @@ def test_pair_default_duration_is_none(): def test_revoke_spire_calls_revoke_key_user(): - # revoke MUST go through revoke_key_user (KeyUser.revokedAt), not token - # revoke — token revoke is a no-op once redeemed (spirekeeper#22). + # revoke goes through revoke_key_user (KeyUser.revokedAt) — the subject- + # level ban that cuts the whole binding, not just one token's grant. + # (Token-revoke also works post-bind since nsecbunkerd#27, but only + # severs a single token; revoke_key_user is the full-deauth call.) bunker = FakeBunker(revoke_count=2) count = asyncio.run(revoke_spire(_machine(), admin_client=bunker)) assert count == 2