feat(pairing): optional token TTL + revoke endpoint (#9/#12, #22)
Some checks failed
ci.yml / feat(pairing): optional token TTL + revoke endpoint (#9/#12, #22) (pull_request) Failing after 0s

Builds on the seed-URL pairing in #21 (stacked).

(b) TTL — PairMachineData.duration_hours (validated > 0) threads through
    pair_spire -> create_new_token (lnbits#55). None = non-expiring.

(c) Revoke — POST /machines/{id}/revoke -> revoke_spire ->
    admin_client.revoke_key_user(spire-<id>). Per spirekeeper#22, revoke
    MUST go through KeyUser.revokedAt (revoke_key_user), NOT token revoke:
    lnbits eager-binds (redeems) the connect token at provision, so
    nsecbunkerd has materialised the policy into per-KeyUser grants its
    ACL checks BEFORE the Token.revokedAt filter -> token revoke is a
    silent no-op. Returns RevokeResult{revoked_count}: >=1 = cut, 0 =
    never bound. set_machine_unpaired clears paired_at (keeps npub +
    bunker_spire_key_name for audit / re-pair).

7 new tests (duration threading + default-None; revoke routes to
revoke_key_user and never token-revoke + error mapping; endpoint wiring
revoke happy/zero/502). 210 green; new code black/ruff-clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-18 18:51:54 +02:00
commit a5efdf22a1
6 changed files with 223 additions and 10 deletions

View file

@ -97,6 +97,14 @@ class PairResult(BaseModel):
seed_url: str
class RevokeResult(BaseModel):
"""Output of revoke. `revoked_count` >= 1 = the spire's signing access
is cut (KeyUser.revokedAt set); 0 = nothing was bound (token minted but
the spire never connected)."""
revoked_count: int
def spire_key_name(machine_id: str) -> str:
"""The spire's key name in the bunker keystore. Stable across re-pairs
so re-issuing a token reuses the same underlying key (create_new_key
@ -147,10 +155,16 @@ async def pair_spire(
admin_client: NsecBunkerAdminClient,
bunker_relay: str | None = None,
keystore_passphrase: str | None = None,
duration_hours: int | None = None,
) -> PairResult:
"""Mint a bunker-held key + scoped connect token for `machine` and
return the seed URL the spire redeems at first boot.
`duration_hours` (optional, aiolabs/lnbits#54 item 2) sets a TTL on the
spire's connect token — the bunker stamps `expiresAt` and rejects the
token once it lapses, forcing a re-pair. None = non-expiring (the only
invalidation path is then revoke, `revoke_spire`).
`admin_client` must already be connected (the caller owns the
`async with NsecBunkerAdminClient.from_settings()` context) keeps
connection lifecycle out of the orchestration so this is unit-testable
@ -196,7 +210,9 @@ async def pair_spire(
rules=SPIRE_POLICY_RULES,
methods_no_kind=SPIRE_POLICY_METHODS_NO_KIND,
)
await admin_client.create_new_token(key_name, client_name, policy_id)
await admin_client.create_new_token(
key_name, client_name, policy_id, duration_hours=duration_hours
)
tokens = await admin_client.get_key_tokens(key_name)
except NsecBunkerNotConfiguredError as exc:
raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc
@ -223,3 +239,32 @@ async def pair_spire(
bunker_url=bunker_url,
seed_url=seed_url,
)
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).
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).
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
spire never connected). Raises PairingError on any bunker failure.
"""
try:
return await admin_client.revoke_key_user(spire_key_name(machine.id))
except NsecBunkerNotConfiguredError as exc:
raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc
except NsecBunkerError as exc:
raise PairingError(
f"bunker admin RPC failed during revoke: {exc}"
) from exc