docs(pairing): TTL + token-revoke now enforced post-bind (nsecbunkerd#27) #28
2 changed files with 21 additions and 20 deletions
docs(pairing): TTL + token-revoke now enforced post-bind (nsecbunkerd#27)
Some checks failed
ci.yml / docs(pairing): TTL + token-revoke now enforced post-bind (nsecbunkerd#27) (pull_request) Failing after 0s
Some checks failed
ci.yml / docs(pairing): TTL + token-revoke now enforced post-bind (nsecbunkerd#27) (pull_request) Failing after 0s
nsecbunkerd#27 (deployed 2026-06-19) reverses the #24 finding: the sign-time ACL now evaluates token lifecycle live on every request (checkIfPubkeyAllowed step 4 joins through a liveWhere filter; applyToken stopped photocopying grants into SigningConditions). So: - duration_hours / token expiresAt now bounds an ESTABLISHED binding — an expired token stops signing post-bind, not just at connect. The prior docstring (connect-window-only, pointing at the now-closed nsecbunkerd#24) is corrected. - Token-revoke is no longer a post-redeem no-op (closes the #22 mechanism bunker-side). revoke_spire keeps using revoke_key_user because that's the subject-level ban cutting the whole binding, not just one token's grant — rationale updated, behavior unchanged. Doc/comment only; 20 pairing tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
commit
b193f6262d
33
pairing.py
33
pairing.py
|
|
@ -166,14 +166,13 @@ async def pair_spire(
|
||||||
return the seed URL the spire redeems at first boot.
|
return the seed URL the spire redeems at first boot.
|
||||||
|
|
||||||
`duration_hours` (optional, aiolabs/lnbits#54 item 2) stamps `expiresAt`
|
`duration_hours` (optional, aiolabs/lnbits#54 item 2) stamps `expiresAt`
|
||||||
on the spire's connect token. NOTE: this bounds ONLY the window in which
|
on the spire's connect token, bounding the established binding's lifetime.
|
||||||
an *un-redeemed* token can first connect — nsecbunkerd reads `expiresAt`
|
Since aiolabs/nsecbunkerd#27 (deployed 2026-06-19) the sign-time ACL
|
||||||
solely in `validateToken` at redeem time. Once the spire has connected
|
evaluates token lifecycle on EVERY request (`checkIfPubkeyAllowed` step 4
|
||||||
and its per-KeyUser grants are materialized, an expired token keeps
|
joins through a `liveWhere` filter; `applyToken` no longer photocopies
|
||||||
signing (the sign-time ACL never checks `expiresAt`; same ACL-ordering
|
grants), so an expired token stops signing post-bind, not just at connect.
|
||||||
subtlety as the revoke finding, #22). The real post-bind cutoff is
|
The spire must re-pair to keep signing once the token lapses. None =
|
||||||
`revoke_spire` (`revoke_key_user`), not TTL. Post-bind TTL enforcement is
|
non-expiring (the only invalidation path is then `revoke_spire`).
|
||||||
tracked at aiolabs/nsecbunkerd#24. None = non-expiring connect window.
|
|
||||||
|
|
||||||
`admin_client` must already be connected (the caller owns the
|
`admin_client` must already be connected (the caller owns the
|
||||||
`async with NsecBunkerAdminClient.from_settings()` context) — keeps
|
`async with NsecBunkerAdminClient.from_settings()` context) — keeps
|
||||||
|
|
@ -255,16 +254,16 @@ async def revoke_spire(
|
||||||
machine: Machine, *, admin_client: NsecBunkerAdminClient
|
machine: Machine, *, admin_client: NsecBunkerAdminClient
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Revoke a spire's bunker access (the "Revoke spire access" UX,
|
"""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`.
|
Calls `revoke_key_user` (sets `KeyUser.revokedAt`) — the subject-level
|
||||||
lnbits eager-binds (redeems) the connect token at provision time
|
sticky ban that's checked at step 2 of `checkIfPubkeyAllowed`, beating
|
||||||
(aiolabs/lnbits#32), so nsecbunkerd has already materialised the
|
every grant. This cuts the WHOLE binding regardless of how many tokens
|
||||||
token's policy into standing per-`KeyUser` `SigningCondition` grants;
|
were issued to the spire, which is the right semantics for "revoke this
|
||||||
its sign-time ACL checks those *before* the `Token.revokedAt` filter,
|
spire." (Since aiolabs/nsecbunkerd#27 token-revoke also works post-bind —
|
||||||
so revoking the token is a silent no-op (the spire keeps signing).
|
the sign-time ACL now evaluates `Token.revokedAt`/`expiresAt` live every
|
||||||
Only `KeyUser.revokedAt` — set by `revoke_user` / `revoke_key_user` —
|
request, closing the #22 no-op — but per-token revoke only cuts one
|
||||||
actually cuts off signing (verified live 2026-06-18, #22).
|
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
|
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
|
access is now cut; 0 means nothing was bound (token minted but the
|
||||||
|
|
|
||||||
|
|
@ -287,8 +287,10 @@ def test_pair_default_duration_is_none():
|
||||||
|
|
||||||
|
|
||||||
def test_revoke_spire_calls_revoke_key_user():
|
def test_revoke_spire_calls_revoke_key_user():
|
||||||
# revoke MUST go through revoke_key_user (KeyUser.revokedAt), not token
|
# revoke goes through revoke_key_user (KeyUser.revokedAt) — the subject-
|
||||||
# revoke — token revoke is a no-op once redeemed (spirekeeper#22).
|
# 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)
|
bunker = FakeBunker(revoke_count=2)
|
||||||
count = asyncio.run(revoke_spire(_machine(), admin_client=bunker))
|
count = asyncio.run(revoke_spire(_machine(), admin_client=bunker))
|
||||||
assert count == 2
|
assert count == 2
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue