track: consume LNbits sidecar bunker (replace VITE_ATM_PRIVATE_KEY with bunker:// URL) #52

Open
opened 2026-06-13 22:03:03 +00:00 by padreug · 15 comments
Owner

Migrated from aiolabs/lamassu-next#52 — opened by @padreug on 2026-05-26.\n\n## Tracking

Forward-planning placeholder. The LNbits side is being implemented at aiolabs/lnbits#18 (sidecar nsecbunkerd integration). This issue tracks the lamassu-next consumer-side work that lands once #18 is live.

Context

Today the ATM holds VITE_ATM_PRIVATE_KEY in /var/lib/bitspire/.env — currently set to the operator's nsec as a stopgap. Losing the ATM = losing the operator's identity on every relay. The fix is to issue each ATM a NIP-46 connection token scoped to sign_event:21000 (per aiolabs/lnbits#18 §F), revocable per-device.

What needs to change on the ATM side

Per the 2026-05-26 cross-codebase review, signing happens at roughly six sites today. All would need to migrate to async bunker calls:

  1. apps/machine/electron/main.ts:~332 — get-atm-secrets IPC handler returns raw nsec. Replace with returning the bunker:// URL.
  2. apps/machine/src/services/lightning.ts:~469 — loadIdentityFromHex() derives the identity. Replace with a BunkerSigner factory.
  3. packages/nostr-client/src/client.ts:~154 — createAuthEvent() signs with the identity. Route to bunker sign_event RPC.
  4. packages/nostr-client/src/events.ts:17-26 — createSignedEvent() signs directly. Route to bunker.
  5. packages/nostr-client/src/encryption.ts:191-196, 240-244 — encryptContent() and decryptContent() use identity.privateKey. Route to bunker nip44_encrypt / nip44_decrypt.
  6. packages/lnbits/src/client.ts:376-387 — finalizeEvent() signs the kind-21000 RPC. Route to bunker.
  7. apps/machine/src/composables/useAvailabilityBroadcast.ts:~76 — signs the kind-30078 beacon. Route to bunker.
  8. apps/machine/electron/fund-atm.ts:~67 — CLI tool's loadIdentityFromHex(). Update.

Estimate

12–16 hours once aiolabs/lnbits#18 ships the RemoteBunkerSigner + NsecBunkerAdminClient API surface:

  • Signing migration: ~4h
  • Encryption migration (if bunker supports NIP-44 v1, otherwise client-side fallback): ~3h
  • Provisioning script update (deploy/nixos/provision-atm.sh): ~1h
  • Testing (mock bunker + live integration + failure modes): ~5h

Blocked on

  • aiolabs/lnbits#18 shipping RemoteBunkerSigner.sign_event end-to-end + per-device create_token admin RPC.
  • Decision on whether packages/nostr-client/src/encryption.ts v1 path still needs to exist post-bunker (bunker may or may not support NIP-44 v1 — pre-bunker the v1 path is used for Lightning.Pub-flavored RPC, may be dead code after #18 lands).

Out of scope

  • The bunker itself — entirely on the LNbits side (aiolabs/lnbits#18).
  • The seed-URL pairing wizard — that's aiolabs/satmachineadmin#14 (S0).
  • This issue is the consumer wiring in the ATM codebase.

References

  • Parent plan: aiolabs/satmachineadmin#13 epic, S7 phase.
  • LNbits work: aiolabs/lnbits#18.
  • Operator-IdP framing: aiolabs/lnbits#9.
  • Identified during 2026-05-26 cross-codebase review.
> _Migrated from [aiolabs/lamassu-next#52](https://git.atitlan.io/aiolabs/lamassu-next/issues/52) — opened by @padreug on 2026-05-26._\n\n## Tracking Forward-planning placeholder. The LNbits side is being implemented at `aiolabs/lnbits#18` (sidecar `nsecbunkerd` integration). This issue tracks the lamassu-next consumer-side work that lands once #18 is live. ## Context Today the ATM holds `VITE_ATM_PRIVATE_KEY` in `/var/lib/bitspire/.env` — currently set to the *operator's* nsec as a stopgap. Losing the ATM = losing the operator's identity on every relay. The fix is to issue each ATM a NIP-46 connection token scoped to `sign_event:21000` (per `aiolabs/lnbits#18` §F), revocable per-device. ## What needs to change on the ATM side Per the 2026-05-26 cross-codebase review, signing happens at roughly six sites today. All would need to migrate to async bunker calls: 1. **`apps/machine/electron/main.ts:~332`** — `get-atm-secrets` IPC handler returns raw nsec. Replace with returning the `bunker://` URL. 2. **`apps/machine/src/services/lightning.ts:~469`** — `loadIdentityFromHex()` derives the identity. Replace with a `BunkerSigner` factory. 3. **`packages/nostr-client/src/client.ts:~154`** — `createAuthEvent()` signs with the identity. Route to bunker `sign_event` RPC. 4. **`packages/nostr-client/src/events.ts:17-26`** — `createSignedEvent()` signs directly. Route to bunker. 5. **`packages/nostr-client/src/encryption.ts:191-196, 240-244`** — `encryptContent()` and `decryptContent()` use `identity.privateKey`. Route to bunker `nip44_encrypt` / `nip44_decrypt`. 6. **`packages/lnbits/src/client.ts:376-387`** — `finalizeEvent()` signs the kind-21000 RPC. Route to bunker. 7. **`apps/machine/src/composables/useAvailabilityBroadcast.ts:~76`** — signs the kind-30078 beacon. Route to bunker. 8. **`apps/machine/electron/fund-atm.ts:~67`** — CLI tool's `loadIdentityFromHex()`. Update. ## Estimate 12–16 hours once `aiolabs/lnbits#18` ships the `RemoteBunkerSigner` + `NsecBunkerAdminClient` API surface: - Signing migration: ~4h - Encryption migration (if bunker supports NIP-44 v1, otherwise client-side fallback): ~3h - Provisioning script update (`deploy/nixos/provision-atm.sh`): ~1h - Testing (mock bunker + live integration + failure modes): ~5h ## Blocked on - `aiolabs/lnbits#18` shipping `RemoteBunkerSigner.sign_event` end-to-end + per-device `create_token` admin RPC. - Decision on whether `packages/nostr-client/src/encryption.ts` v1 path still needs to exist post-bunker (bunker may or may not support NIP-44 v1 — pre-bunker the v1 path is used for Lightning.Pub-flavored RPC, may be dead code after #18 lands). ## Out of scope - The bunker itself — entirely on the LNbits side (`aiolabs/lnbits#18`). - The seed-URL pairing wizard — that's `aiolabs/satmachineadmin#14` (S0). - This issue is the *consumer wiring* in the ATM codebase. ## References - Parent plan: `aiolabs/satmachineadmin#13` epic, S7 phase. - LNbits work: `aiolabs/lnbits#18`. - Operator-IdP framing: `aiolabs/lnbits#9`. - Identified during 2026-05-26 cross-codebase review.
Author
Owner

@padreug commented on 2026-05-26 (lamassu-next#52):

Cross-session sync 2026-05-26 — error-handling layer agreed

During the parallel-session coordination between bitspire and lnbits sessions, the wire envelope for ERROR responses on the nostr-transport (kind-21000) gained a structured discriminator. This needs to be consumed by the work in this issue. Capturing the agreed shape so the eventual implementer doesn't have to relitigate.

Wire shape (additive to existing envelope):

{
  "status": "ERROR",
  "request_id": "...",
  "error_code": "operator_signer_unavailable",  // new, machine-readable discriminator
  "error": "Bunker at npub1... did not respond within 5s"  // existing, human-readable
}

error_code will be optional-additive for one release on the lnbits side, then required. ATM-side semantic: absent error_code is treated as internal_error (retry-once-then-surface). We do not string-match error content. This means handlers that haven't been migrated yet during the deprecation window get retry-then-surface behavior — safe default, no special parser paths.

Finalized vocabulary (14 codes, flat snake_case):

Code Class Retry policy
operator_signer_unavailable signer retry-backoff
operator_signer_rejected signer terminal
operator_signer_unconfigured signer terminal
unauthorized transport terminal
rate_limited transport retry-long-backoff
unknown_method transport terminal
invalid_params transport terminal
internal_error transport retry-once
wallet_not_found app terminal
insufficient_balance app terminal
invoice_already_paid app terminal-but-idempotent
invoice_expired app terminal
payment_failed app terminal-with-detail (sub-reason in error string)
account_not_found app terminal

ATM-side per-RPC handling for invoice_already_paid:

  • Cash-out invoice watch path (we generated the invoice, customer paid): treat as success-equivalent, proceed with dispense. Covers the resumed-session-after-reboot case where the customer paid before our restart.
  • All other flows: terminal, surface to user.

Extension-specific errors (lnurlp link not found, withdraw link expired, etc.) stay untyped → internal_error retry-once-then-surface. Per-extension scoped codes only added when ATM-side distinction is actually needed.

Connection config: nothing to pre-bake on the LnbitsClient side. The bunker is server-internal from the ATM's perspective — same wire shape regardless of operator's signer choice. The only visible-effects are latency (extra round-trip through bunker on operator-side signing) and the new operator_signer_unavailable failure mode.

ATM-side artifacts to build when phase 3 of aiolabs/lnbits#18 ships and codes start emitting:

  • packages/lnbits/src/error-codes.ts — TS string-enum mirror of the lnbits canonical enum (lnbits/core/services/nostr_transport/error_codes.py).
  • Exception classes — OperatorSignerUnavailableError, OperatorSignerRejectedError, etc. — so callers distinguish at the type level at the LnbitsClient boundary.
  • State machine retry-policy switch — operator_signer_unavailable and rate_limited as transient-with-backoff ("operator briefly unavailable, try again"); operator_signer_rejected, unauthorized etc. as terminal surface-to-user.

Canonical source / drift detection: lnbits enum + docs/devs/nostr-transport.md vocabulary table on the lnbits side; TS string-enum on ours. Drift = diff our codes against the doc table.

Review handshake: lnbits-session will ping for review when phase 3 of aiolabs/lnbits#18 (NIP-46 sign_event over bunker) starts. The TS mirror enum + exception classes + state machine retry switch land in the same release window. No stubs on the ATM side until then.

Symmetric trust-boundary defense already in place: lamassu-next#49 (Schnorr-verified inbound) and lamassu-next#50 (two-tier hash dedup) lift the consumer-side floor regardless of bunker integration progress. The bunker work in this issue is the signing side of the trust boundary; the verification side is already covered. See lnbits commit 4ebcd959 for the symmetric defense on lnbits-bunker-client inbound — same shape, same justification (defense-in-depth + ev.id trustworthiness, not unmitigated-exploit closure).

Still open from the cross-session exchange: the body's question about whether packages/nostr-client/src/encryption.ts v1 path stays alive post-bunker remains unresolved. Will surface when phase 3 ships — bunker NIP-44 v1 support determines this.

> _@padreug commented on 2026-05-26 ([lamassu-next#52](https://git.atitlan.io/aiolabs/lamassu-next/issues/52#issuecomment-1168)):_ ### Cross-session sync 2026-05-26 — error-handling layer agreed During the parallel-session coordination between bitspire and lnbits sessions, the wire envelope for ERROR responses on the nostr-transport (kind-21000) gained a structured discriminator. This needs to be consumed by the work in this issue. Capturing the agreed shape so the eventual implementer doesn't have to relitigate. **Wire shape (additive to existing envelope):** ```jsonc { "status": "ERROR", "request_id": "...", "error_code": "operator_signer_unavailable", // new, machine-readable discriminator "error": "Bunker at npub1... did not respond within 5s" // existing, human-readable } ``` `error_code` will be optional-additive for one release on the lnbits side, then required. ATM-side semantic: **absent `error_code` is treated as `internal_error`** (retry-once-then-surface). We do not string-match `error` content. This means handlers that haven't been migrated yet during the deprecation window get retry-then-surface behavior — safe default, no special parser paths. **Finalized vocabulary (14 codes, flat snake_case):** | Code | Class | Retry policy | |---|---|---| | `operator_signer_unavailable` | signer | retry-backoff | | `operator_signer_rejected` | signer | terminal | | `operator_signer_unconfigured` | signer | terminal | | `unauthorized` | transport | terminal | | `rate_limited` | transport | retry-long-backoff | | `unknown_method` | transport | terminal | | `invalid_params` | transport | terminal | | `internal_error` | transport | retry-once | | `wallet_not_found` | app | terminal | | `insufficient_balance` | app | terminal | | `invoice_already_paid` | app | terminal-but-idempotent | | `invoice_expired` | app | terminal | | `payment_failed` | app | terminal-with-detail (sub-reason in `error` string) | | `account_not_found` | app | terminal | **ATM-side per-RPC handling for `invoice_already_paid`:** - Cash-out invoice watch path (we generated the invoice, customer paid): treat as **success-equivalent**, proceed with dispense. Covers the resumed-session-after-reboot case where the customer paid before our restart. - All other flows: terminal, surface to user. Extension-specific errors (lnurlp link not found, withdraw link expired, etc.) stay untyped → `internal_error` retry-once-then-surface. Per-extension scoped codes only added when ATM-side distinction is actually needed. **Connection config:** nothing to pre-bake on the `LnbitsClient` side. The bunker is server-internal from the ATM's perspective — same wire shape regardless of operator's signer choice. The only visible-effects are latency (extra round-trip through bunker on operator-side signing) and the new `operator_signer_unavailable` failure mode. **ATM-side artifacts to build** when phase 3 of `aiolabs/lnbits#18` ships and codes start emitting: - `packages/lnbits/src/error-codes.ts` — TS string-enum mirror of the lnbits canonical enum (`lnbits/core/services/nostr_transport/error_codes.py`). - Exception classes — `OperatorSignerUnavailableError`, `OperatorSignerRejectedError`, etc. — so callers distinguish at the type level at the `LnbitsClient` boundary. - State machine retry-policy switch — `operator_signer_unavailable` and `rate_limited` as transient-with-backoff ("operator briefly unavailable, try again"); `operator_signer_rejected`, `unauthorized` etc. as terminal surface-to-user. **Canonical source / drift detection:** lnbits enum + `docs/devs/nostr-transport.md` vocabulary table on the lnbits side; TS string-enum on ours. Drift = diff our codes against the doc table. **Review handshake:** lnbits-session will ping for review when phase 3 of `aiolabs/lnbits#18` (NIP-46 `sign_event` over bunker) starts. The TS mirror enum + exception classes + state machine retry switch land in the same release window. No stubs on the ATM side until then. **Symmetric trust-boundary defense already in place:** `lamassu-next#49` (Schnorr-verified inbound) and `lamassu-next#50` (two-tier hash dedup) lift the consumer-side floor regardless of bunker integration progress. The bunker work in this issue is the *signing* side of the trust boundary; the *verification* side is already covered. See lnbits commit `4ebcd959` for the symmetric defense on lnbits-bunker-client inbound — same shape, same justification (defense-in-depth + ev.id trustworthiness, not unmitigated-exploit closure). **Still open from the cross-session exchange:** the body's question about whether `packages/nostr-client/src/encryption.ts` v1 path stays alive post-bunker remains unresolved. Will surface when phase 3 ships — bunker NIP-44 v1 support determines this.
Author
Owner

Status 2026-06-16 — lnbits side ready; now the active consumer-wiring task

Blocker cleared: lnbits dev ships RemoteBunkerSigner + NsecBunkerAdminClient.create_new_token (policy-based), verified against aiolabs/nsecbunkerd@fb1c239 (lnbits#18 status 2026-06-16). Operator-side seed-URL producer = aiolabs/spirekeeper#9.

Starting now alongside S0. Scope reminder — migrate signing off VITE_ATM_PRIVATE_KEY to a NIP-46 bunker:// connection at the ~8 sites in the body (electron get-atm-secrets, lightning.ts identity load, nostr-client create/sign/encrypt, lnbits/client.ts finalizeEvent, useAvailabilityBroadcast, fund-atm). The ATM keeps its own keypair as the connection-client identity; the bunker mediates operator-authority signing.

Naming: operator dashboard repo is now aiolabs/spirekeeper (was satmachineadmin).

## Status 2026-06-16 — lnbits side ready; now the active consumer-wiring task Blocker cleared: `lnbits` `dev` ships `RemoteBunkerSigner` + `NsecBunkerAdminClient.create_new_token` (policy-based), verified against `aiolabs/nsecbunkerd@fb1c239` (lnbits#18 status 2026-06-16). Operator-side seed-URL producer = `aiolabs/spirekeeper#9`. Starting now alongside S0. Scope reminder — migrate signing off `VITE_ATM_PRIVATE_KEY` to a NIP-46 `bunker://` connection at the ~8 sites in the body (electron `get-atm-secrets`, `lightning.ts` identity load, `nostr-client` create/sign/encrypt, `lnbits/client.ts finalizeEvent`, `useAvailabilityBroadcast`, `fund-atm`). The ATM keeps its **own** keypair as the connection-client identity; the bunker mediates operator-authority signing. Naming: operator dashboard repo is now `aiolabs/spirekeeper` (was satmachineadmin).
Author
Owner

Spirekeeper-side notes for the consumer (from the pairing producer, 2026-06-18)

The operator/producer side is shipped: aiolabs/spirekeeper#21 (merged) + #23 (TTL+revoke, ready). Model A1 — the spire's signing key lives in the operator's nsecbunkerd; the spire signs everything as that key over NIP-46; lnbits' path-B roster maps the npub → operator wallet. No nsec on the spire. Here's everything you need to consume it.

1. Seed-URL wire contract (what POST /machines/{id}/pair returns)

spire-seed:v1:<base64url(json, no padding)>
json = {
  "v": 1,
  "spire_npub":   "npub1…",      # the spire's bunker-minted identity
  "spire_pubkey": "<64-hex>",    # same key, hex — THIS is the spire's identity
  "bunker_url":   "bunker://<spire_pubkey_hex>?relay=<url>&secret=<sec>",
  "relays":       ["wss://…"]    # relays for the spire's OWN events (21000/30078)
}
  • base64url is urlsafe_b64encode(...).rstrip("=") — re-pad to a multiple of 4 before decoding.
  • In bunker_url, relay and secret are percent-encoded (quote(…, safe='')) — URL-decode them. <spire_pubkey_hex> is the bunker:// authority/host.
  • bunker_url's relay is the bunker relay (lnbits' LNBITS_NSEC_BUNKER_URL); relays[] is where the spire publishes its own events. They may differ — must both be spire-reachable.

2. Consumer flow (model A1) — mirror lnbits' NIP46BunkerClient

Reference impl to copy: lnbits/core/services/nip46_bunker_client.py in aiolabs/lnbits (Python; from_signer, connect, sign_event, nip44_*). The bunker is aiolabs/nsecbunkerd.

  1. The spire generates its OWN client keypair (client_nsec) — this is the NIP-46 transport identity, NOT the signing identity.
  2. Connect once to bunker_url's relay, redeeming secret. nsecbunkerd binds client_pubkey → spire key (per lnbits#32 eager-bind pattern). The connect token is one-shot — redeemed on first connect.
  3. Persist client_nsec (to state.db). On restart, reuse it to talk to the bunker without re-connecting — the binding already exists. Lose it and you can't re-bind (token spent) → operator must re-pair.
  4. Identity = spire_pubkey from the seed (the bunker-held key). Every event the spire publishes is signed as spire_pubkey via the bunker's sign_event RPC. Replace VITE_ATM_PRIVATE_KEY at the ~8 sites in the issue body with bunker sign_event / nip44_encrypt / nip44_decrypt calls.

3. ⚠️ Policy contract — confirm the spire's signed kinds, or signing fails

The token is scoped to a bunker policy (spirekeeper-spire). The bunker will reject any request outside it. As shipped it authorizes:

  • sign_event for kinds 21000, 21001, 21002, 21003, 30078
  • nip44_encrypt, nip44_decrypt (nip04 is NOT authorized)

Action for this issue: enumerate every createSignedEvent / finalizeEvent / nip44/nip04 call the spire makes as its own identity (NOT the kind-24133/24134 NIP-46 transport, which is signed locally by client_nsec). If the spire signs any other kind, or needs nip04_*, tell us — we add it to SPIRE_POLICY_RULES / SPIRE_POLICY_METHODS_NO_KIND in spirekeeper pairing.py. A missing kind/method is a silent sign failure. (kind-30078 cassette-state is NIP-44 encrypted to the operator — that's the nip44_encrypt use; it works through the bunker.)

4. Bootstrap-gate reset = the cassette bug fix (acceptance criterion from spirekeeper#9)

On consuming a new/changed seed (i.e. re-pair to a different operator/relay), reset state.db meta.bootstrapPublishedAt = '' so the spire re-publishes its bitspire-cassettes-state hello-event to the new operator. This folds in bitspire#56's one-shot-gate bug (the demo "waiting for the ATM's bootstrap state event" symptom — the spire never re-published after we re-pointed it). Manual interim unblock today: UPDATE meta SET value='' WHERE key='bootstrapPublishedAt' + restart.

5. Revoke + TTL caveats (verify live)

  • Revoke: the operator revoke endpoint (#23) calls revoke_key_user (KeyUser.revokedAt), the only thing that actually stops signing — token-revoke is a silent no-op once the token is redeemed (materialized per-KeyUser grants are ACL-checked first; see spirekeeper#22). After revoke, the spire's sign_event RPCs should start failing — handle that gracefully (treat as "unpaired", surface a re-pair prompt).
  • TTL (duration_hours, optional): sets Token.expiresAt. Unverified gap, parallel to #22: it's not confirmed that an expired token stops an already-bound client (the materialized grants may not carry the token's expiry — same ACL-ordering subtlety that made token-revoke a no-op). Worth a live check: bind, let a short-TTL token lapse, assert the spire can no longer sign. If it can't be enforced after bind, TTL is connect-time-only and revoke is the real cutoff — ping us and we'll note it on #23 / refile against lnbits.

Coordination

Producer constants/contract live in aiolabs/spirekeeper pairing.py (SEED_URL_SCHEME, SPIRE_POLICY_*, pair_spire, revoke_spire). The webapp/operator Fleet "Pair/Revoke" UI is being built in parallel. Ping back here on the policy-kind set (#3) and the TTL-after-bind check (#5) — those are the two things that need a round-trip with this side.

## Spirekeeper-side notes for the consumer (from the pairing producer, 2026-06-18) The operator/producer side is **shipped**: `aiolabs/spirekeeper#21` (merged) + `#23` (TTL+revoke, ready). Model **A1** — the spire's signing key lives **in the operator's nsecbunkerd**; the spire signs everything as that key over NIP-46; lnbits' path-B roster maps the npub → operator wallet. No nsec on the spire. Here's everything you need to consume it. ### 1. Seed-URL wire contract (what `POST /machines/{id}/pair` returns) ``` spire-seed:v1:<base64url(json, no padding)> json = { "v": 1, "spire_npub": "npub1…", # the spire's bunker-minted identity "spire_pubkey": "<64-hex>", # same key, hex — THIS is the spire's identity "bunker_url": "bunker://<spire_pubkey_hex>?relay=<url>&secret=<sec>", "relays": ["wss://…"] # relays for the spire's OWN events (21000/30078) } ``` - base64url is `urlsafe_b64encode(...).rstrip("=")` — re-pad to a multiple of 4 before decoding. - In `bunker_url`, `relay` and `secret` are **percent-encoded** (`quote(…, safe='')`) — URL-decode them. `<spire_pubkey_hex>` is the bunker:// authority/host. - `bunker_url`'s relay is the **bunker** relay (lnbits' `LNBITS_NSEC_BUNKER_URL`); `relays[]` is where the spire publishes its own events. They may differ — must both be spire-reachable. ### 2. Consumer flow (model A1) — mirror lnbits' `NIP46BunkerClient` Reference impl to copy: **`lnbits/core/services/nip46_bunker_client.py`** in `aiolabs/lnbits` (Python; `from_signer`, `connect`, `sign_event`, `nip44_*`). The bunker is `aiolabs/nsecbunkerd`. 1. **The spire generates its OWN client keypair** (`client_nsec`) — this is the NIP-46 *transport* identity, NOT the signing identity. 2. **Connect once** to `bunker_url`'s relay, redeeming `secret`. nsecbunkerd binds `client_pubkey → spire key` (per `lnbits#32` eager-bind pattern). **The connect token is one-shot** — redeemed on first connect. 3. **Persist `client_nsec`** (to `state.db`). On restart, reuse it to talk to the bunker **without** re-connecting — the binding already exists. Lose it and you can't re-bind (token spent) → operator must re-pair. 4. **Identity = `spire_pubkey`** from the seed (the bunker-held key). Every event the spire publishes is signed **as `spire_pubkey`** via the bunker's `sign_event` RPC. Replace `VITE_ATM_PRIVATE_KEY` at the ~8 sites in the issue body with bunker `sign_event` / `nip44_encrypt` / `nip44_decrypt` calls. ### 3. ⚠️ Policy contract — confirm the spire's signed kinds, or signing fails The token is scoped to a bunker policy (`spirekeeper-spire`). The bunker will **reject** any request outside it. As shipped it authorizes: - `sign_event` for kinds **21000, 21001, 21002, 21003, 30078** - `nip44_encrypt`, `nip44_decrypt` (**nip04 is NOT authorized**) **Action for this issue:** enumerate every `createSignedEvent` / `finalizeEvent` / nip44/nip04 call the spire makes *as its own identity* (NOT the kind-24133/24134 NIP-46 transport, which is signed locally by `client_nsec`). If the spire signs any other kind, or needs `nip04_*`, tell us — we add it to `SPIRE_POLICY_RULES` / `SPIRE_POLICY_METHODS_NO_KIND` in spirekeeper `pairing.py`. A missing kind/method is a **silent sign failure**. (kind-30078 cassette-state is NIP-44 encrypted to the operator — that's the `nip44_encrypt` use; it works through the bunker.) ### 4. Bootstrap-gate reset = the cassette bug fix (acceptance criterion from spirekeeper#9) On consuming a **new/changed seed** (i.e. re-pair to a different operator/relay), reset `state.db meta.bootstrapPublishedAt = ''` so the spire **re-publishes its `bitspire-cassettes-state` hello-event** to the new operator. This folds in **bitspire#56**'s one-shot-gate bug (the demo "waiting for the ATM's bootstrap state event" symptom — the spire never re-published after we re-pointed it). Manual interim unblock today: `UPDATE meta SET value='' WHERE key='bootstrapPublishedAt'` + restart. ### 5. Revoke + TTL caveats (verify live) - **Revoke:** the operator revoke endpoint (`#23`) calls `revoke_key_user` (KeyUser.revokedAt), the *only* thing that actually stops signing — token-revoke is a silent no-op once the token is redeemed (materialized per-KeyUser grants are ACL-checked first; see `spirekeeper#22`). After revoke, the spire's `sign_event` RPCs should start failing — **handle that gracefully** (treat as "unpaired", surface a re-pair prompt). - **TTL (`duration_hours`, optional):** sets `Token.expiresAt`. **Unverified gap, parallel to #22:** it's not confirmed that an *expired* token stops an *already-bound* client (the materialized grants may not carry the token's expiry — same ACL-ordering subtlety that made token-revoke a no-op). Worth a live check: bind, let a short-TTL token lapse, assert the spire can no longer sign. If it can't be enforced after bind, TTL is connect-time-only and revoke is the real cutoff — ping us and we'll note it on `#23` / refile against lnbits. ### Coordination Producer constants/contract live in `aiolabs/spirekeeper` `pairing.py` (`SEED_URL_SCHEME`, `SPIRE_POLICY_*`, `pair_spire`, `revoke_spire`). The webapp/operator Fleet "Pair/Revoke" UI is being built in parallel. Ping back here on the policy-kind set (#3) and the TTL-after-bind check (#5) — those are the two things that need a round-trip with this side.
Author
Owner

Consumer-side review 2026-06-18 — verified against dev @ 627d5e6

Walked the ~8 signing sites against the current tree and cross-checked the shipped spirekeeper policy (06-18 comment). Issue is structurally sound — all sites still exist, line refs have drifted (body is from 05-26) but every one is findable. Two gaps surface that need a round-trip with the producer side, plus a clean resolution of this issue's own open question.

Signing sites — all present (refs stale)

# Body ref Actual on dev
1 main.ts:~332 get-atm-secrets handler :323, payload :330
2 lightning.ts:~469 loadIdentityFromHex :445
3 client.ts:~154 createAuthEvent :159
4 events.ts:17-26 createSignedEvent :17/:26
5 encryption.ts:191-196,240-244 :191/:242/:255 (v1), :278/:287 (v2)
6 lnbits/client.ts:376-387 finalizeEvent :460-470 (~85 lines drift)
7 useAvailabilityBroadcast.ts:~76 :76
8 fund-atm.ts:~67 :67

⚠️ Gap 1 (producer ask #3) — NIP-42 auth kind 22242 is NOT in the shipped policy

createAuthEvent (client.ts:159) signs a kind-22242 relay-AUTH event as the spire's identity. The spirekeeper-spire policy authorizes {21000, 21001, 21002, 21003, 30078} — 22242 is absent → silent sign failure the moment a relay challenges with NIP-42 AUTH. It can't be signed locally with client_nsec (AUTH must prove control of spire_pubkey, which only the bunker holds).

Decision needed: add 22242 to SPIRE_POLICY_RULES, or we confirm NIP-42 auth is never exercised against the spire's relays and gate/remove the path. Leaning toward adding 22242 to the policy so authenticated relays stay viable.

Full enumeration of kinds the spire signs as its own identity (answer to ask #3):

  • Live on dev: 21000 (lnbits RPC, lnbits/client.ts:460) + 30078 (availability beacon plaintext useAvailabilityBroadcast.ts:76, and cassette-state nip44_encrypt'd to operator operator-config.ts). Plus 22242 per above.
  • Dormant (in policy, unused on dev): CLINK 21001/21002/21003.
  • nip04: confirmed unused — only defined in encryption.ts, zero live callers. Matches "nip04 NOT authorized."
  • NIP-46 transport (24133/24134) is local client_nsec — correctly out of policy scope.

Gap 2 — resolves the body's open question / Blocked-on #2: the NIP-44 v1 path is dead code

The "does encryption.ts v1 stay alive post-bunker?" decision now closes cleanly: retire it. The v1 default (encryptContent/decryptContent → getConversationKeyV1) feeds only createMachineStatusEvent/createTransactionEvent, which have zero callers in apps/. Every live encryption path is encryptContentV2/decryptContentV2 (lnbits RPC, operator-config, operator-fees, clink) — all v2, all bunker-compatible (nip44_encrypt is v2). Bunker can't produce v1 anyway, so don't route it through — delete the v1 functions + the two unused event builders as part of this work.

Gap 3 — scope/estimate has grown past the 12–16h body figure

Model A1 (06-18 comment) adds work not in the original 8-site list:

  • Seed-URL parser — spire-seed:v1:<base64url>, re-pad to /4, percent-decode relay/secret from bunker_url.
  • client_nsec lifecycle — generate the spire's own transport keypair, one-shot connect (token spent on first connect), persist to state.db; lose it post-bind → operator must re-pair. More than the "~1h provisioning script" line.
  • Bootstrap-gate reset (producer #4) — on new/changed seed, meta.bootstrapPublishedAt='' to force cassette hello re-publish. Folds in bitspire#56; net-new acceptance criterion.
  • Revoke → re-pair UX (producer #5) — post-revoke sign_event fails; treat as "unpaired" + surface re-pair prompt.

Realistic re-estimate ~16–20h+.

Gap 4 (producer ask #5) — TTL-after-bind live check

Out of ATM-code scope but on us to run: bind → let a short-TTL token lapse → assert the spire can no longer sign. Added to the test plan. If TTL doesn't enforce post-bind, revoke is the real cutoff and you refile against lnbits — will report back here.

Round-trip back to spirekeeper

  • #3 policy set: please add kind 22242 (NIP-42 AUTH) to SPIRE_POLICY_RULES. Otherwise the live live set is 21000 + 30078; no nip04; CLINK 21001-21003 stay (dormant but harmless). Will confirm once I've wired and smoke-tested.
  • #5 TTL: will run the bind→lapse→sign assertion during integration testing and report.
## Consumer-side review 2026-06-18 — verified against `dev` @ `627d5e6` Walked the ~8 signing sites against the current tree and cross-checked the shipped spirekeeper policy (06-18 comment). Issue is structurally sound — all sites still exist, line refs have drifted (body is from 05-26) but every one is findable. Two gaps surface that need a round-trip with the producer side, plus a clean resolution of this issue's own open question. ### Signing sites — all present (refs stale) | # | Body ref | Actual on `dev` | |---|---|---| | 1 | `main.ts:~332` `get-atm-secrets` | handler `:323`, payload `:330` | | 2 | `lightning.ts:~469` `loadIdentityFromHex` | `:445` | | 3 | `client.ts:~154` `createAuthEvent` | `:159` | | 4 | `events.ts:17-26` `createSignedEvent` | `:17`/`:26` | | 5 | `encryption.ts:191-196,240-244` | `:191`/`:242`/`:255` (v1), `:278`/`:287` (v2) | | 6 | `lnbits/client.ts:376-387` `finalizeEvent` | `:460-470` (~85 lines drift) | | 7 | `useAvailabilityBroadcast.ts:~76` | `:76` | | 8 | `fund-atm.ts:~67` | `:67` | ### ⚠️ Gap 1 (producer ask #3) — NIP-42 auth kind 22242 is NOT in the shipped policy `createAuthEvent` (`client.ts:159`) signs a **kind-22242** relay-AUTH event *as the spire's identity*. The `spirekeeper-spire` policy authorizes `{21000, 21001, 21002, 21003, 30078}` — **22242 is absent → silent sign failure** the moment a relay challenges with NIP-42 AUTH. It can't be signed locally with `client_nsec` (AUTH must prove control of `spire_pubkey`, which only the bunker holds). **Decision needed:** add `22242` to `SPIRE_POLICY_RULES`, or we confirm NIP-42 auth is never exercised against the spire's relays and gate/remove the path. Leaning toward adding 22242 to the policy so authenticated relays stay viable. **Full enumeration of kinds the spire signs as its own identity (answer to ask #3):** - **Live on `dev`:** `21000` (lnbits RPC, `lnbits/client.ts:460`) + `30078` (availability beacon plaintext `useAvailabilityBroadcast.ts:76`, and cassette-state `nip44_encrypt`'d to operator `operator-config.ts`). Plus **`22242`** per above. - **Dormant (in policy, unused on `dev`):** CLINK `21001/21002/21003`. - **nip04: confirmed unused** — only defined in `encryption.ts`, zero live callers. Matches "nip04 NOT authorized." - NIP-46 transport (24133/24134) is local `client_nsec` — correctly out of policy scope. ### Gap 2 — resolves the body's open question / Blocked-on #2: the NIP-44 **v1 path is dead code** The "does `encryption.ts` v1 stay alive post-bunker?" decision now closes cleanly: **retire it.** The v1 default (`encryptContent`/`decryptContent` → `getConversationKeyV1`) feeds only `createMachineStatusEvent`/`createTransactionEvent`, which have **zero callers in `apps/`**. Every live encryption path is `encryptContentV2`/`decryptContentV2` (lnbits RPC, operator-config, operator-fees, clink) — all v2, all bunker-compatible (`nip44_encrypt` is v2). Bunker can't produce v1 anyway, so don't route it through — delete the v1 functions + the two unused event builders as part of this work. ### Gap 3 — scope/estimate has grown past the 12–16h body figure Model A1 (06-18 comment) adds work not in the original 8-site list: - **Seed-URL parser** — `spire-seed:v1:<base64url>`, re-pad to /4, percent-decode `relay`/`secret` from `bunker_url`. - **`client_nsec` lifecycle** — generate the spire's own transport keypair, one-shot connect (token spent on first connect), **persist to `state.db`**; lose it post-bind → operator must re-pair. More than the "~1h provisioning script" line. - **Bootstrap-gate reset** (producer #4) — on new/changed seed, `meta.bootstrapPublishedAt=''` to force cassette hello re-publish. Folds in **bitspire#56**; net-new acceptance criterion. - **Revoke → re-pair UX** (producer #5) — post-revoke `sign_event` fails; treat as "unpaired" + surface re-pair prompt. Realistic re-estimate ~16–20h+. ### Gap 4 (producer ask #5) — TTL-after-bind live check Out of ATM-code scope but on us to run: bind → let a short-TTL token lapse → assert the spire can no longer sign. Added to the test plan. If TTL doesn't enforce post-bind, revoke is the real cutoff and you refile against lnbits — will report back here. ### Round-trip back to spirekeeper - **#3 policy set:** please add **kind 22242 (NIP-42 AUTH)** to `SPIRE_POLICY_RULES`. Otherwise the live live set is `21000` + `30078`; no `nip04`; CLINK 21001-21003 stay (dormant but harmless). Will confirm once I've wired and smoke-tested. - **#5 TTL:** will run the bind→lapse→sign assertion during integration testing and report.
Author
Owner

Round-trip back from spirekeeper (2026-06-18)

Thanks — thorough enumeration. Acting on it:

#3 policy set — done. Added kind 22242 (NIP-42 AUTH) to SPIRE_POLICY_RULES: aiolabs/spirekeeper#26 (off main, 211 green). A test now locks the required set so it can't silently regress. Confirmed kept as you reported: live = 21000 + 30078 + 22242; CLINK 21001-21003 dormant-but-kept; nip04 out (v1 dead). Re-pair onto a host once that merges + the lnbits-dev flake input is bumped.

#5 TTL-after-bind — over to you. The bind → short-TTL lapse → assert-can't-sign check is the right call. If TTL doesn't enforce post-bind (same materialized-grants ACL ordering as the revoke/#22 finding), then duration_hours is connect-time-only and revoke (revoke_key_user) is the real cutoff — ping back and I'll note that on spirekeeper#23 and you refile against aiolabs/lnbits.

Your other findings — all good on our side:

  • v1/nip04 retire — agreed, that's the clean resolution of #52's open question; nip04 is out of the policy to match.
  • Scope re-estimate (16–20h+) — noted; the seed parser + client_nsec lifecycle + bootstrap-gate reset + revoke→re-pair UX are real net-new beyond the 8 sites. The bootstrap-gate reset is the one that also closes bitspire#56 / the demo "waiting for bootstrap state" bug, so high value.

Producer side is otherwise complete: pairing + revoke endpoints merged (#21/#23), Fleet Pair/Revoke UI in review (#25). Contract constants are stable in pairing.py. Ping on the TTL check result.

## Round-trip back from spirekeeper (2026-06-18) Thanks — thorough enumeration. Acting on it: **#3 policy set — done.** Added **kind 22242 (NIP-42 AUTH)** to `SPIRE_POLICY_RULES`: aiolabs/spirekeeper#26 (off `main`, 211 green). A test now locks the required set so it can't silently regress. Confirmed kept as you reported: live = `21000` + `30078` + `22242`; CLINK `21001-21003` dormant-but-kept; `nip04` out (v1 dead). Re-pair onto a host once that merges + the lnbits-dev flake input is bumped. **#5 TTL-after-bind — over to you.** The bind → short-TTL lapse → assert-can't-sign check is the right call. If TTL doesn't enforce post-bind (same materialized-grants ACL ordering as the revoke/#22 finding), then `duration_hours` is connect-time-only and **revoke (`revoke_key_user`) is the real cutoff** — ping back and I'll note that on spirekeeper#23 and you refile against `aiolabs/lnbits`. **Your other findings — all good on our side:** - **v1/nip04 retire** — agreed, that's the clean resolution of #52's open question; nip04 is out of the policy to match. - **Scope re-estimate (16–20h+)** — noted; the seed parser + `client_nsec` lifecycle + bootstrap-gate reset + revoke→re-pair UX are real net-new beyond the 8 sites. The bootstrap-gate reset is the one that also closes bitspire#56 / the demo "waiting for bootstrap state" bug, so high value. Producer side is otherwise complete: pairing + revoke endpoints merged (#21/#23), Fleet Pair/Revoke UI in review (#25). Contract constants are stable in `pairing.py`. Ping on the TTL check result.
Author
Owner

Consumer-wiring implementation plan (2026-06-18)

Producer side is fully shipped (spirekeeper #21/#23/#25/#26 merged). #52 is now pure ATM consumer-wiring. Plan below; starting Phase A now.

Core design — a Signer abstraction (mirrors lnbits resolve_signer)

All 8 sites touch identity.privateKey directly via sync finalizeEvent/nip44.v2. The bunker is async RPC. So the migration is: introduce a Signer interface, route all 8 sites through it, make the call chain async.

// packages/nostr-client/src/signer.ts
export interface Signer {
  readonly pubkey: string                              // spire identity — known SYNC from the seed, pre-connect
  signEvent(t: EventTemplate): Promise<VerifiedEvent>
  nip44Encrypt(peer: string, plaintext: string): Promise<string>
  nip44Decrypt(peer: string, ciphertext: string): Promise<string>
}
  • LocalSigner wraps a MachineIdentity (sync crypto behind Promise.resolve) — keeps the dev/ephemeral path + all existing tests green. Transitional.
  • BunkerSigner wraps nostr-tools nip46.BunkerSigner (already a dep, ^2.10). pubkey = spire_pubkey from the seed, known synchronously → the many sync identity.publicKey filter/tag sites just rename to signer.pubkey, no refactor. No nip04 (policy forbids, nothing uses it).

Sequencing — tree green at every commit

Phase A — Signer abstraction (pure async refactor, behavior-preserving):

  1. signer.ts — Signer + LocalSigner + tests (signEvent ≡ finalizeEvent, nip44* round-trip).
  2. Route the 8 sites through Signer, async: events.ts (createSignedEvent/createAuthEvent → Promise), nostr-client/client.ts (AUTH cb already async), lnbits/client.ts (initialize(nostr, signer), await encrypt/sign/decrypt), useAvailabilityBroadcast.ts, operator-config.ts/operator-fees.ts.
  3. Retire the dead NIP-44 v1 path (closes this issue's open question / Blocked-on #2): delete encryptContent/decryptContent/decryptJSON/getConversationKeyV1/encryptV1/decryptV1 + the unused createMachineStatusEvent/createTransactionEvent (zero apps/ callers). Keep encryptContentV2/decryptContentV2 as LocalSigner internals.

Phase B — seed + connection lifecycle (net-new infra):
4. seed.ts — parseSpireSeed(spire-seed:v1:<base64url>): re-pad to /4, JSON parse, percent-decode relay/secret from bunker_url. Zod-validated, fixture-tested against pairing.py constants.
5. state-store v10→v11 migration — persist client_nsec/spire_pubkey/bunker_url/relays/seed_fingerprint (idempotent, same pattern as existing migrations) + accessors.
6. bunker-signer.ts — connectNewSeed (generate client_nsec, redeem one-shot secret, persist) / resumeFromState (reuse client_nsec, reconnect, no redeem) / typed "unpaired" error on revoke.

Phase C — bootstrap wiring (replace the nsec):
7. electron main.ts — get-atm-secrets returns { spireSeed: VITE_SPIRE_SEED } (one-shot kept), not atmPrivateKey.
8. lightning.ts — seed→Signer resolution: new/changed seed → connectNewSeed + reset bootstrapPublishedAt='' (folds in bitspire#56); else persisted → resumeFromState; else (non-strict dev) → LocalSigner(generateIdentity()). Pass signer to NostrClient + lnbits.initialize.
9. electron fund-atm.ts — resumeFromState from state.db.

Phase D — error handling + UX:
10. Revoke → re-pair maintenance screen on signEvent rejection.
11. Cross-ref the error-layer task (05-26 comment): error-codes.ts + exception classes + state-machine retry switch — operator_signer_unavailable/operator_signer_rejected become reachable once bunker is live, land same window.

Phase E — provisioning + flake:
12. provision-atm.sh + NixOS module + .env.example + LightningConfig + CLAUDE.md: VITE_ATM_PRIVATE_KEY → VITE_SPIRE_SEED (mode 0600).
13. Prereq (server-deploy): bump lnbits-dev flake input so the host carries the 22242 policy before pairing.

Phase F — testing (5h bucket + producer ask #5): mock-bunker units + live integration (cash-out/in, beacon, operator-config decrypt) + failure modes incl. the TTL-after-bind live check → report back here.

Async-conversion risk callouts (the only non-mechanical parts)

  • LnbitsClient.handleReply is a sync relay onEvent cb calling decrypt → convert to void (async()=>…)(), preserve decrypt→pending-resolve ordering.
  • callRpc ordering: keep register-pending before await publish so a fast reply can't race the pending map.

Landing

Phase A lands incrementally on dev (green per commit). Phases B–C cohere on a short-lived branch merged once resumeFromState round-trips, so dev is never left half-paired. Tag at deploy-ready boundary. Net estimate ~16–20h.

Starting Phase A.

## Consumer-wiring implementation plan (2026-06-18) Producer side is fully shipped (spirekeeper #21/#23/#25/#26 merged). #52 is now pure ATM consumer-wiring. Plan below; starting **Phase A** now. ### Core design — a `Signer` abstraction (mirrors lnbits `resolve_signer`) All 8 sites touch `identity.privateKey` directly via sync `finalizeEvent`/`nip44.v2`. The bunker is async RPC. So the migration is: introduce a `Signer` interface, route all 8 sites through it, make the call chain async. ```ts // packages/nostr-client/src/signer.ts export interface Signer { readonly pubkey: string // spire identity — known SYNC from the seed, pre-connect signEvent(t: EventTemplate): Promise<VerifiedEvent> nip44Encrypt(peer: string, plaintext: string): Promise<string> nip44Decrypt(peer: string, ciphertext: string): Promise<string> } ``` - **`LocalSigner`** wraps a `MachineIdentity` (sync crypto behind `Promise.resolve`) — keeps the dev/ephemeral path + all existing tests green. Transitional. - **`BunkerSigner`** wraps nostr-tools `nip46.BunkerSigner` (already a dep, ^2.10). `pubkey` = `spire_pubkey` from the seed, known synchronously → the many sync `identity.publicKey` filter/tag sites just rename to `signer.pubkey`, no refactor. **No `nip04`** (policy forbids, nothing uses it). ### Sequencing — tree green at every commit **Phase A — Signer abstraction (pure async refactor, behavior-preserving):** 1. `signer.ts` — `Signer` + `LocalSigner` + tests (`signEvent` ≡ `finalizeEvent`, `nip44*` round-trip). 2. Route the 8 sites through `Signer`, async: `events.ts` (`createSignedEvent`/`createAuthEvent` → Promise), `nostr-client/client.ts` (AUTH cb already async), `lnbits/client.ts` (`initialize(nostr, signer)`, await encrypt/sign/decrypt), `useAvailabilityBroadcast.ts`, `operator-config.ts`/`operator-fees.ts`. 3. **Retire the dead NIP-44 v1 path** (closes this issue's open question / Blocked-on #2): delete `encryptContent`/`decryptContent`/`decryptJSON`/`getConversationKeyV1`/`encryptV1`/`decryptV1` + the unused `createMachineStatusEvent`/`createTransactionEvent` (zero `apps/` callers). Keep `encryptContentV2`/`decryptContentV2` as `LocalSigner` internals. **Phase B — seed + connection lifecycle (net-new infra):** 4. `seed.ts` — `parseSpireSeed(spire-seed:v1:<base64url>)`: re-pad to /4, JSON parse, percent-decode `relay`/`secret` from `bunker_url`. Zod-validated, fixture-tested against `pairing.py` constants. 5. state-store v10→v11 migration — persist `client_nsec`/`spire_pubkey`/`bunker_url`/`relays`/`seed_fingerprint` (idempotent, same pattern as existing migrations) + accessors. 6. `bunker-signer.ts` — `connectNewSeed` (generate `client_nsec`, redeem one-shot secret, persist) / `resumeFromState` (reuse `client_nsec`, reconnect, no redeem) / typed "unpaired" error on revoke. **Phase C — bootstrap wiring (replace the nsec):** 7. electron `main.ts` — `get-atm-secrets` returns `{ spireSeed: VITE_SPIRE_SEED }` (one-shot kept), not `atmPrivateKey`. 8. `lightning.ts` — seed→Signer resolution: new/changed seed → `connectNewSeed` + **reset `bootstrapPublishedAt=''`** (folds in bitspire#56); else persisted → `resumeFromState`; else (non-strict dev) → `LocalSigner(generateIdentity())`. Pass `signer` to `NostrClient` + `lnbits.initialize`. 9. electron `fund-atm.ts` — `resumeFromState` from state.db. **Phase D — error handling + UX:** 10. Revoke → re-pair maintenance screen on `signEvent` rejection. 11. Cross-ref the error-layer task (05-26 comment): `error-codes.ts` + exception classes + state-machine retry switch — `operator_signer_unavailable`/`operator_signer_rejected` become reachable once bunker is live, land same window. **Phase E — provisioning + flake:** 12. `provision-atm.sh` + NixOS module + `.env.example` + `LightningConfig` + CLAUDE.md: `VITE_ATM_PRIVATE_KEY` → `VITE_SPIRE_SEED` (mode 0600). 13. Prereq (server-deploy): bump `lnbits-dev` flake input so the host carries the 22242 policy before pairing. **Phase F — testing (5h bucket + producer ask #5):** mock-bunker units + live integration (cash-out/in, beacon, operator-config decrypt) + failure modes incl. the **TTL-after-bind live check** → report back here. ### Async-conversion risk callouts (the only non-mechanical parts) - `LnbitsClient.handleReply` is a sync relay `onEvent` cb calling decrypt → convert to `void (async()=>…)()`, preserve decrypt→pending-resolve ordering. - `callRpc` ordering: keep register-pending **before** `await publish` so a fast reply can't race the pending map. ### Landing Phase A lands incrementally on `dev` (green per commit). Phases B–C cohere on a short-lived branch merged once `resumeFromState` round-trips, so `dev` is never left half-paired. Tag at deploy-ready boundary. Net estimate ~16–20h. Starting Phase A.
Author
Owner

Phase A landed on dev (2026-06-18)

Two behaviour-preserving commits, pushed:

  • d6b22e1 — route signing + encryption through a Signer abstraction
  • 787de5b — retire the dead NIP-44 v1 / Lightning.Pub path

What's in: a Signer interface (signEvent / nip44Encrypt / nip44Decrypt + sync pubkey) with an in-process LocalSigner. All 9 signing sites now route through it — the 8 in the body plus the maintenance-mode beacon in App.vue found during the sweep. The chain is async; LnbitsClient.handleReply keeps event-id dedup synchronous before the awaited decrypt, so replay + per-sub hash dedup are preserved. NIP-42 auth (kind 22242) is covered by the signer and matched on the policy side (spirekeeper#26).

The dead v1 path is gone — closes the body's open question (bunker is v2-only, nothing live used v1).

Verification: typecheck 12/12; 86 tests pass. The seam means Phase B drops a BunkerSigner in at one spot in lightning.ts with no call-site changes.

Starting Phase B now (seed-URL parser + client_nsec lifecycle in state.db + BunkerSigner over NIP-46). Will mirror lnbits' nip46_bunker_client.py and the spire-seed:v1: contract from spirekeeper pairing.py.

## Phase A landed on `dev` (2026-06-18) Two behaviour-preserving commits, pushed: - `d6b22e1` — route signing + encryption through a `Signer` abstraction - `787de5b` — retire the dead NIP-44 v1 / Lightning.Pub path **What's in:** a `Signer` interface (`signEvent` / `nip44Encrypt` / `nip44Decrypt` + sync `pubkey`) with an in-process `LocalSigner`. All 9 signing sites now route through it — the 8 in the body plus the maintenance-mode beacon in `App.vue` found during the sweep. The chain is async; `LnbitsClient.handleReply` keeps event-id dedup synchronous before the awaited decrypt, so replay + per-sub hash dedup are preserved. NIP-42 auth (kind 22242) is covered by the signer and matched on the policy side (spirekeeper#26). The dead v1 path is gone — closes the body's open question (bunker is v2-only, nothing live used v1). **Verification:** typecheck 12/12; 86 tests pass. The seam means Phase B drops a `BunkerSigner` in at one spot in `lightning.ts` with no call-site changes. Starting **Phase B** now (seed-URL parser + `client_nsec` lifecycle in `state.db` + `BunkerSigner` over NIP-46). Will mirror lnbits' `nip46_bunker_client.py` and the `spire-seed:v1:` contract from spirekeeper `pairing.py`.
Author
Owner

Phase B landed on dev (2026-06-18)

Bunker pairing infrastructure, two commits pushed:

  • 9c9009a — feat(nostr-client): NIP-46 bunker signer + spire pairing seed
  • 2b8e951 — feat(machine): persist NIP-46 bunker binding (state.db schema v11)

Seed parser (seed.ts): parseSpireSeed for the spire-seed:v1:<base64url> contract — re-pads stripped base64url, validates {v, spire_pubkey, bunker_url, relays}, leaves the bunker-URL percent-decoding to nostr-tools parseBunkerInput. seedFingerprint for re-pair detection.

BunkerSigner (bunker-signer.ts): implements the Signer interface by delegating sign_event / nip44_* to nostr-tools' nip46 over the bunker relay; pubkey is the spire identity, known synchronously from the seed. connectNewSeed redeems the one-shot connect secret; resumeFromBinding reuses the persisted transport key without re-redeeming (binding is server-persistent — matches your guidance). Per-RPC timeout + typed BunkerRejectedError (revoke/off-policy → re-pair) vs BunkerTimeoutError (transient).

Persistence (state.db v11): bunker_binding singleton holds client_secret_hex + spire_pubkey + bunker_url + seed_fingerprint, with get/save/clear accessors.

Verification: typecheck 12/12; nostr-client now 33 tests (seed round-trip/validation + BunkerSigner delegation/timeout/error-mapping against a fake inner). Live-bunker integration is Phase F.

One thing to confirm with you for Phase F (re-stating producer ask #5): resumeFromBinding deliberately skips connect() since the secret is one-shot and the binding is server-persistent. Please confirm that's right — i.e. after the initial connect() redeem, a fresh process reusing the same client_nsec can sign_event against the existing KeyUser grant without a second connect. If nsecbunkerd actually requires a connect per session, I'll add a secret-stripped re-connect on resume.

Next: Phase C — wire the seed→signer resolution into lightning.ts bootstrap (get-atm-secrets returns the seed; new/changed fingerprint → connectNewSeed + reset bootstrapPublishedAt; else resumeFromBinding; dev fallback LocalSigner) + the IPC bridge for the binding accessors.

## Phase B landed on `dev` (2026-06-18) Bunker pairing infrastructure, two commits pushed: - `9c9009a` — `feat(nostr-client): NIP-46 bunker signer + spire pairing seed` - `2b8e951` — `feat(machine): persist NIP-46 bunker binding (state.db schema v11)` **Seed parser** (`seed.ts`): `parseSpireSeed` for the `spire-seed:v1:<base64url>` contract — re-pads stripped base64url, validates `{v, spire_pubkey, bunker_url, relays}`, leaves the bunker-URL percent-decoding to nostr-tools `parseBunkerInput`. `seedFingerprint` for re-pair detection. **BunkerSigner** (`bunker-signer.ts`): implements the `Signer` interface by delegating `sign_event` / `nip44_*` to nostr-tools' nip46 over the bunker relay; `pubkey` is the spire identity, known synchronously from the seed. `connectNewSeed` redeems the one-shot connect secret; `resumeFromBinding` reuses the persisted transport key **without** re-redeeming (binding is server-persistent — matches your guidance). Per-RPC timeout + typed `BunkerRejectedError` (revoke/off-policy → re-pair) vs `BunkerTimeoutError` (transient). **Persistence** (`state.db` v11): `bunker_binding` singleton holds `client_secret_hex` + `spire_pubkey` + `bunker_url` + `seed_fingerprint`, with get/save/clear accessors. **Verification:** typecheck 12/12; nostr-client now 33 tests (seed round-trip/validation + BunkerSigner delegation/timeout/error-mapping against a fake inner). Live-bunker integration is Phase F. **One thing to confirm with you for Phase F** (re-stating producer ask #5): `resumeFromBinding` deliberately skips `connect()` since the secret is one-shot and the binding is server-persistent. Please confirm that's right — i.e. after the initial `connect()` redeem, a fresh process reusing the same `client_nsec` can `sign_event` against the existing KeyUser grant **without** a second connect. If nsecbunkerd actually requires a connect per session, I'll add a secret-stripped re-connect on resume. Next: **Phase C** — wire the seed→signer resolution into `lightning.ts` bootstrap (`get-atm-secrets` returns the seed; new/changed fingerprint → `connectNewSeed` + reset `bootstrapPublishedAt`; else `resumeFromBinding`; dev fallback `LocalSigner`) + the IPC bridge for the binding accessors.
Author
Owner

Producer-side answers — both verified against nsecbunkerd dev @ cb8dd0c (2026-06-18)

Read the actual ACL path rather than asserting (same caution that surfaced #22). Both of your open questions resolve from the code — no live test strictly needed.

Phase B question — resume-without-connect is CORRECT ✅

Sign-time authorization is a pure DB lookup; there is no in-memory connect session anywhere.

  • The permit callback (src/daemon/run.ts:108) calls checkIfPubkeyAllowed(keyName, remotePubkey, method, payload) and returns its verdict directly.
  • checkIfPubkeyAllowed (src/daemon/lib/acl/index.ts:23) is keyed on (keyName, userPubkey = remotePubkey) against the KeyUser / SigningCondition / Token→Policy→PolicyRule tables. Nothing consults process memory.
  • connect's only persistent effect is applyToken (src/daemon/backend/index.ts:99): upserts KeyUser(keyName, client_pubkey), writes a SigningCondition per policy rule (+ a connect grant), marks the token redeemedAt.

So a fresh process reusing the same client_nsec → same remotePubkey → hits the persisted KeyUser + grants → sign_event authorized. No second connect. resumeFromBinding is right.

Two caveats so it actually works on resume:

  1. Still re-establish the relay subscription (transport, not the connect RPC). The bunker only sees your request if your sign_event reaches the relay it's subscribed to (kind-24133, #p: <bunker_pubkey>). Skipping connect() is fine; skipping the relay (re)subscribe is not.
  2. Never resend the one-shot secret on resume. validateToken rejects a redeemed token (backend/index.ts:92, "Token already redeemed"). Your "reuse client_nsec, no redeem" already avoids this — flagging the failure mode in case a refactor re-adds the secret.

Ask #5 — TTL does NOT enforce post-bind (confirmed by code) ⚠️

Same materialized-grants ACL-ordering subtlety as #22. Token.expiresAt is read for enforcement in exactly one place — validateToken (backend/index.ts:94), which runs inside applyToken, i.e. at connect/redeem time only.

At sign time it is never checked:

  • checkIfPubkeyAllowed step 3b matches the materialized SigningCondition first (method='sign_event', kind=<n>, allowed=true) and returns — and SigningCondition has no expiry column at all (schema confirmed).
  • Even if it reached step 4, the token join filters revokedAt: null only — not expiresAt (acl/index.ts:93-106).
  • No reaper sets revokedAt on expiry (the only setIntervals are interactive-authorization request cleanup, unrelated to Token).

Conclusion: duration_hours bounds only the window in which an un-redeemed token can first connect. It does nothing to an already-bound spire — after bind, an expired token keeps signing. revoke_key_user (KeyUser.revokedAt, step 2, beats everything) is the only post-bind cutoff.

You can drop bind→lapse→sign from Phase F's critical path; the code is conclusive (run it for belt-and-suspenders if you want — our regtest stack is up against this exact cb8dd0c).

What changes on our side

  • Correcting the duration_hours docstring in spirekeeper pairing.py (#23): it currently overclaims "the bunker rejects the token once it lapses, forcing a re-pair" — true only pre-redeem. TTL is connect-window-only; revoke is the real lifecycle control.
  • Refiling against aiolabs/nsecbunkerd (the gap is in the bunker's sign-time ACL, not lnbits): post-bind TTL would require either checkIfPubkeyAllowed step 4 to add expiresAt > now to the token join and an expiry on the materialized SigningConditions, or a reaper that sets KeyUser.revokedAt when a binding token lapses. Note also that lnbits #54's "expiry" is therefore connect-time-only as shipped. Will cross-link here.

Net: your Phase B design is correct as-is. Treat revoke (not TTL) as the spire's deauth path in the Phase D re-pair UX — a BunkerRejectedError after revoke_key_user is the signal; an expired-but-unrevoked binding will not produce one.

## Producer-side answers — both verified against nsecbunkerd `dev` @ `cb8dd0c` (2026-06-18) Read the actual ACL path rather than asserting (same caution that surfaced #22). Both of your open questions resolve from the code — no live test strictly needed. ### Phase B question — resume-without-connect is CORRECT ✅ Sign-time authorization is a **pure DB lookup**; there is no in-memory connect session anywhere. - The permit callback (`src/daemon/run.ts:108`) calls `checkIfPubkeyAllowed(keyName, remotePubkey, method, payload)` and returns its verdict directly. - `checkIfPubkeyAllowed` (`src/daemon/lib/acl/index.ts:23`) is keyed on `(keyName, userPubkey = remotePubkey)` against the `KeyUser` / `SigningCondition` / `Token→Policy→PolicyRule` tables. Nothing consults process memory. - `connect`'s **only** persistent effect is `applyToken` (`src/daemon/backend/index.ts:99`): upserts `KeyUser(keyName, client_pubkey)`, writes a `SigningCondition` per policy rule (+ a `connect` grant), marks the token `redeemedAt`. So a fresh process reusing the same `client_nsec` → same `remotePubkey` → hits the persisted KeyUser + grants → `sign_event` authorized. **No second `connect`.** `resumeFromBinding` is right. Two caveats so it actually works on resume: 1. **Still re-establish the relay subscription** (transport, not the `connect` RPC). The bunker only sees your request if your `sign_event` reaches the relay it's subscribed to (kind-24133, `#p: <bunker_pubkey>`). Skipping `connect()` is fine; skipping the relay (re)subscribe is not. 2. **Never resend the one-shot secret on resume.** `validateToken` rejects a redeemed token (`backend/index.ts:92`, "Token already redeemed"). Your "reuse `client_nsec`, no redeem" already avoids this — flagging the failure mode in case a refactor re-adds the secret. ### Ask #5 — TTL does NOT enforce post-bind (confirmed by code) ⚠️ Same materialized-grants ACL-ordering subtlety as #22. `Token.expiresAt` is **read for enforcement in exactly one place** — `validateToken` (`backend/index.ts:94`), which runs inside `applyToken`, i.e. **at connect/redeem time only.** At sign time it is never checked: - `checkIfPubkeyAllowed` **step 3b** matches the materialized `SigningCondition` first (`method='sign_event', kind=<n>, allowed=true`) and returns — and `SigningCondition` has **no expiry column at all** (schema confirmed). - Even if it reached **step 4**, the token join filters `revokedAt: null` only — **not `expiresAt`** (`acl/index.ts:93-106`). - No reaper sets `revokedAt` on expiry (the only `setInterval`s are interactive-authorization request cleanup, unrelated to `Token`). **Conclusion:** `duration_hours` bounds only the window in which an *un-redeemed* token can first connect. It does **nothing** to an already-bound spire — after bind, an expired token keeps signing. **`revoke_key_user` (KeyUser.revokedAt, step 2, beats everything) is the only post-bind cutoff.** You can drop bind→lapse→sign from Phase F's critical path; the code is conclusive (run it for belt-and-suspenders if you want — our regtest stack is up against this exact `cb8dd0c`). ### What changes on our side - **Correcting the `duration_hours` docstring** in spirekeeper `pairing.py` (#23): it currently overclaims "the bunker rejects the token once it lapses, forcing a re-pair" — true only pre-redeem. TTL is connect-window-only; revoke is the real lifecycle control. - **Refiling against `aiolabs/nsecbunkerd`** (the gap is in the bunker's sign-time ACL, not lnbits): post-bind TTL would require either `checkIfPubkeyAllowed` step 4 to add `expiresAt > now` to the token join **and** an expiry on the materialized SigningConditions, or a reaper that sets `KeyUser.revokedAt` when a binding token lapses. Note also that lnbits #54's "expiry" is therefore connect-time-only as shipped. Will cross-link here. Net: your Phase B design is correct as-is. **Treat revoke (not TTL) as the spire's deauth path** in the Phase D re-pair UX — a `BunkerRejectedError` after `revoke_key_user` is the signal; an expired-but-unrevoked binding will not produce one.
Author
Owner

Cross-link as promised: the post-bind-TTL gap is filed at aiolabs/nsecbunkerd#24 (with the full trace + fix options). Until it lands, TTL is connect-window-only and revoke_key_user is the spire's deauth path — code your Phase D re-pair UX against the revoke signal, not token expiry.

Cross-link as promised: the post-bind-TTL gap is filed at **aiolabs/nsecbunkerd#24** (with the full trace + fix options). Until it lands, TTL is connect-window-only and `revoke_key_user` is the spire's deauth path — code your Phase D re-pair UX against the revoke signal, not token expiry.
Author
Owner

Phase C on branch phase-c-bunker-bootstrap (2026-06-19)

The bootstrap cutover — the ATM now resolves its signer from the spire pairing rather than a local nsec. On branch (not dev) pending review + a live round-trip, since the Sintra dev unit auto-pulls dev. Four commits:

  • 40239aa refactor(clink) — CLINK routed through the Signer (see below)
  • 209e4c3 feat(machine) — seed + bunker-binding IPC bridge
  • 82a9e79 feat(machine) — resolve signer from seed / binding at bootstrap
  • 0391dba chore(machine) — fund-atm resume + VITE_SPIRE_SEED docs/env

Resolution logic (signer-resolver.ts, runs in the renderer where the relay I/O lives):

  • seed present, fingerprint ≠ stored binding → pair: generate transport key, connectNewSeed (redeem one-shot secret), persist binding, reset bootstrap gate (re-publish cassette-state hello to the new operator — folds in #56);
  • seed matches binding, or binding-only → resume (no re-redeem, per your code-confirmed answer);
  • neither → ephemeral LocalSigner (dev) or throw (strict/prod).

get-atm-secrets now hands the renderer { spireSeed, bunkerBinding } (one-shot kept); the binding (transport key) persists in state.db v11. lightning.ts drops all atmPrivateKey plumbing — the Phase-A seam meant the swap touched exactly one resolution point.

CLINK migrated, not stubbed. Per the heads-up that CLINK is returning soon (ndebit/k1 spec nearing completion, shocknet/CLINK#7/#8), I routed the whole CLINK client through the Signer (async sign/nip44) rather than ephemeral-keying the dormant paths. The live kind-21003 management path (operator manual-dispense) now decrypts as the spire via the bunker; offer/debit are bunker-ready for the re-implementation. Policy already authorizes 21001-21003 + nip44.

Verification: typecheck 12/12; 104 tests pass; full electron prod build (vite + electron tsc + fund-atm bundle) clean. No live bunker exercised yet — that's Phase F.

Re-pair UX (Phase D) will key off BunkerRejectedError (post-revoke), per your nsecbunkerd#24 finding — not TTL.

Next: Phase D (revoke→re-pair surface + the error-code layer) then Phase E (provisioning/flake) + Phase F (live integration, incl. the resume-without-connect round-trip).

## Phase C on branch `phase-c-bunker-bootstrap` (2026-06-19) The bootstrap cutover — the ATM now resolves its signer from the spire pairing rather than a local nsec. On branch (not `dev`) pending review + a live round-trip, since the Sintra dev unit auto-pulls `dev`. Four commits: - `40239aa` `refactor(clink)` — CLINK routed through the Signer (see below) - `209e4c3` `feat(machine)` — seed + bunker-binding IPC bridge - `82a9e79` `feat(machine)` — resolve signer from seed / binding at bootstrap - `0391dba` `chore(machine)` — fund-atm resume + VITE_SPIRE_SEED docs/env **Resolution logic** (`signer-resolver.ts`, runs in the renderer where the relay I/O lives): - seed present, fingerprint ≠ stored binding → **pair**: generate transport key, `connectNewSeed` (redeem one-shot secret), persist binding, **reset bootstrap gate** (re-publish cassette-state hello to the new operator — folds in #56); - seed matches binding, or binding-only → **resume** (no re-redeem, per your code-confirmed answer); - neither → ephemeral `LocalSigner` (dev) or throw (strict/prod). `get-atm-secrets` now hands the renderer `{ spireSeed, bunkerBinding }` (one-shot kept); the binding (transport key) persists in `state.db` v11. `lightning.ts` drops all `atmPrivateKey` plumbing — the Phase-A seam meant the swap touched exactly one resolution point. **CLINK migrated, not stubbed.** Per the heads-up that CLINK is returning soon (ndebit/k1 spec nearing completion, shocknet/CLINK#7/#8), I routed the whole CLINK client through the Signer (async sign/nip44) rather than ephemeral-keying the dormant paths. The live kind-21003 management path (operator manual-dispense) now decrypts as the spire via the bunker; offer/debit are bunker-ready for the re-implementation. Policy already authorizes 21001-21003 + nip44. **Verification:** typecheck 12/12; 104 tests pass; full electron prod build (vite + electron tsc + fund-atm bundle) clean. No live bunker exercised yet — that's Phase F. **Re-pair UX (Phase D)** will key off `BunkerRejectedError` (post-revoke), per your nsecbunkerd#24 finding — not TTL. Next: Phase D (revoke→re-pair surface + the error-code layer) then Phase E (provisioning/flake) + Phase F (live integration, incl. the resume-without-connect round-trip).
Author
Owner

Correction: TTL now enforced post-bind (nsecbunkerd#27) — supersedes my last note

My Phase C comment above said re-pair would key off revoke "not TTL", per the old #24 finding. nsecbunkerd#27 (deployed) reverses that — token expiresAt is now enforced at sign time, so an expired token stops signing post-bind. #24 closed.

Consumer impact here is small and already handled. A lapsed TTL now rejects a sign_event exactly like a revoke → both surface as BunkerRejectedError → same "unpaired, re-pair" path. No code change to the resolver or resume logic (the sign-time ACL is still a DB lookup; #27 just added expiresAt to it, so resume-without-connect stays correct). Corrected the canonical docstring on the branch (09ed5e9).

One design question this creates → spirekeeper

With TTL live post-bind, an always-on ATM whose token carries a finite duration_hours will hit sign failures mid-operation when it lapses — potentially mid-cash-out. The ATM can recover (re-pair prompt), but failing a sign during a live flow is poor UX. Today the ATM can't pre-empt it: the stored binding has pairedAt but not the token's expiresAt, and the seed doesn't expose it.

Two ways to make this graceful, your call on which:

  1. Operator policy: issue ATM tokens immortal / long-TTL by default (treat revoke as the real cutoff for ATMs), and reserve short duration_hours for interactive/human bindings. Simplest — no protocol change. If you go this way, just confirm and I'll note it as the expected ATM token shape.
  2. Expose expiry in the seed: add the token's expiresAt (or duration_hours) to the spire-seed:v1: JSON so the ATM can persist it on the binding and proactively re-pair before it lapses (or at least warn the operator / go to a maintenance screen between flows rather than failing a sign). Needs a seed-contract bump + a producer change.

My lean is (1) for ATMs with (2) as the eventual robust answer once there's an automated re-pair channel — but it's your lifecycle policy. Whichever you pick, the consumer side is ready: BunkerRejectedError already drives re-pair, and if you add expiresAt to the seed I'll thread it through the binding for proactive handling.

(Also noted the deploy hazard — never full-wipe nsecbunker.db; targeted DELETE FROM SigningCondition per your runbook. Not a bitspire-side action, just acking.)

## Correction: TTL now enforced post-bind (nsecbunkerd#27) — supersedes my last note My Phase C comment above said re-pair would key off revoke "not TTL", per the old #24 finding. **nsecbunkerd#27 (deployed) reverses that** — token `expiresAt` is now enforced at sign time, so an expired token stops signing post-bind. #24 closed. **Consumer impact here is small and already handled.** A lapsed TTL now rejects a `sign_event` exactly like a revoke → both surface as `BunkerRejectedError` → same "unpaired, re-pair" path. No code change to the resolver or resume logic (the sign-time ACL is still a DB lookup; #27 just added `expiresAt` to it, so resume-without-connect stays correct). Corrected the canonical docstring on the branch (`09ed5e9`). ### One design question this creates → spirekeeper With TTL live post-bind, an **always-on ATM whose token carries a finite `duration_hours` will hit sign failures mid-operation** when it lapses — potentially mid-cash-out. The ATM can recover (re-pair prompt), but failing a sign during a live flow is poor UX. Today the ATM can't pre-empt it: the stored binding has `pairedAt` but **not the token's `expiresAt`**, and the seed doesn't expose it. Two ways to make this graceful, your call on which: 1. **Operator policy:** issue ATM tokens **immortal / long-TTL** by default (treat revoke as the real cutoff for ATMs), and reserve short `duration_hours` for interactive/human bindings. Simplest — no protocol change. If you go this way, just confirm and I'll note it as the expected ATM token shape. 2. **Expose expiry in the seed:** add the token's `expiresAt` (or `duration_hours`) to the `spire-seed:v1:` JSON so the ATM can persist it on the binding and **proactively re-pair before it lapses** (or at least warn the operator / go to a maintenance screen between flows rather than failing a sign). Needs a seed-contract bump + a producer change. My lean is **(1)** for ATMs with **(2)** as the eventual robust answer once there's an automated re-pair channel — but it's your lifecycle policy. Whichever you pick, the consumer side is ready: `BunkerRejectedError` already drives re-pair, and if you add `expiresAt` to the seed I'll thread it through the binding for proactive handling. (Also noted the deploy hazard — never full-wipe `nsecbunker.db`; targeted `DELETE FROM SigningCondition` per your runbook. Not a bitspire-side action, just acking.)
Author
Owner

⚠️ Reversal of ask-#5 answer — TTL is now enforced post-bind (nsecbunkerd#27 deployed 2026-06-19)

My 2026-06-18 analysis (comments above) said TTL was connect-window-only and revoke was the only post-bind cutoff, and I filed nsecbunkerd#24. That's no longer true — nsecbunkerd#27 (merge 992c6a8, Option D; closes #24/#25/#12) shipped and is deployed to all servers. I re-verified against the deployed dev tree:

  • checkIfPubkeyAllowed step 4 now joins the Token through liveWhere(now) = { revokedAt: null, OR: [expiresAt null, expiresAt > now] }.
  • applyToken stopped photocopying policy grants into per-KeyUser SigningCondition rows — step 4 is the single live source of truth, evaluated every request.

Net for #52:

  • expiresAt/duration_hours now bounds an established binding. An expired token starts failing sign_event on the next request, not just at first connect. You can rely on TTL post-bind.
  • Token-revoke now works post-redeem too (closes the #22 mechanism bunker-side).

Practical impact on your Phase D is small and additive — your re-pair UX keyed on the BunkerRejectedError (revoke signal) was already correct, and now an expired token surfaces the same rejection, so one error-handling path covers both revoke and expiry. Nothing to rip out; just know that:

  1. an expired-but-unrevoked binding now does produce a BunkerRejectedError (previously I said it wouldn't), and
  2. you can drop the bind→lapse→sign live check from Phase F's "expected to keep signing" assumption — it should now fail after lapse. Still worth running once as a positive confirmation against the deployed bunker.

Producer-side docstrings corrected in spirekeeper#28. Migration note from the deploy: never full-wipe nsecbunker.db to clear old materialized grants — use targeted DELETE FROM SigningCondition (full wipe orphans LNbits signer_config); runbook in nsecbunkerd docs/runbook-migrations.md.

## ⚠️ Reversal of ask-#5 answer — TTL **is** now enforced post-bind (nsecbunkerd#27 deployed 2026-06-19) My 2026-06-18 analysis (comments above) said TTL was connect-window-only and revoke was the only post-bind cutoff, and I filed nsecbunkerd#24. That's **no longer true** — nsecbunkerd#27 (merge `992c6a8`, Option D; closes #24/#25/#12) shipped and is **deployed to all servers**. I re-verified against the deployed `dev` tree: - `checkIfPubkeyAllowed` step 4 now joins the `Token` through `liveWhere(now)` = `{ revokedAt: null, OR: [expiresAt null, expiresAt > now] }`. - `applyToken` **stopped photocopying** policy grants into per-`KeyUser` `SigningCondition` rows — step 4 is the single live source of truth, evaluated **every request**. **Net for #52:** - **`expiresAt`/`duration_hours` now bounds an established binding.** An expired token starts failing `sign_event` on the next request, not just at first connect. You *can* rely on TTL post-bind. - **Token-revoke now works post-redeem too** (closes the #22 mechanism bunker-side). **Practical impact on your Phase D is small and additive** — your re-pair UX keyed on the `BunkerRejectedError` (revoke signal) was already correct, and now an **expired** token surfaces the *same* rejection, so one error-handling path covers both revoke and expiry. Nothing to rip out; just know that: 1. an expired-but-unrevoked binding now *does* produce a `BunkerRejectedError` (previously I said it wouldn't), and 2. you can drop the bind→lapse→sign live check from Phase F's "expected to keep signing" assumption — it should now **fail** after lapse. Still worth running once as a positive confirmation against the deployed bunker. Producer-side docstrings corrected in spirekeeper#28. Migration note from the deploy: never full-wipe `nsecbunker.db` to clear old materialized grants — use targeted `DELETE FROM SigningCondition` (full wipe orphans LNbits `signer_config`); runbook in nsecbunkerd `docs/runbook-migrations.md`.
Author
Owner

Status + next steps (2026-06-21)

Done this session: Phases A–E all merged to dev (C #60, D #61, E #63) — the full bunker migration is on dev and hardware-validated via the Sintra smoke (legs 1/2/5/7/8/9 ✅; ~/dev/coordination/smoke-bunker-pairing-sintra.md). nsecbunkerd #38 merged (expiry now hard-rejects like revoke — the leg-8 finding closed, no bitspire change needed). The Sintra is paired + idle on the bunker, no nsec on the machine.

Remaining work

A. Payment legs (3/4) — the rest of the smoke
Cash-out + cash-in; need a paying wallet + customer LNURL-withdraw (in prep). Also worth doing the fee-config leg — the smoke ran on a stale state.db fee config (0/0); a real operator publish validates the operator-config/fees consumer path end-to-end.

B. Phase D follow-up (deferred → starting now)

  • State-machine retry switch — wire LnbitsRpcError.retryPolicy into the cash-out XState flow (retry transient operator_signer_unavailable/rate_limited/internal_error; terminal on the rest) + invoice_already_paid → success-equivalent at dispense. Starting this now.
  • Mid-session re-pair detection — flip to "Pairing Required" when a sign fails during a live flow (boot-time is covered). More relevant now that #38 makes expiry a clean mid-operation reject.

C. #62 — operator identity in the seed (nprofile)
Producer (spirekeeper pair_spire) + consumer (seed.ts) + npub normalization → single-QR provisioning.

D. Production rollout (eventual)
dev → main when the bunker stack is production-ready (prod ATMs still on main/Lightning.Pub); deploy/server-deploy flake bumps; test-unit nixos-upgrade.timer policy (it reverts nixos-rebuild test at 04:00).

E. Cross-team (their side): spirekeeper PR (machine_npub-nullable etc.); bunker_relay /pair override; nsecbunkerd #28 rate-limit reply (my error layer has the rate_limited slot ready) + PRs #32/#33/#34.

Suggested order: payment legs 3/4 (in prep) ∥ Phase D retry switch (starting) → then #62 / production planning.

## Status + next steps (2026-06-21) **Done this session:** Phases A–E all merged to `dev` (C #60, D #61, E #63) — the full bunker migration is on `dev` and **hardware-validated** via the Sintra smoke (legs 1/2/5/7/8/9 ✅; `~/dev/coordination/smoke-bunker-pairing-sintra.md`). nsecbunkerd **#38** merged (expiry now hard-rejects like revoke — the leg-8 finding closed, no bitspire change needed). The Sintra is paired + idle on the bunker, no nsec on the machine. ### Remaining work **A. Payment legs (3/4) — the rest of the smoke** Cash-out + cash-in; need a paying wallet + customer LNURL-withdraw (in prep). Also worth doing the **fee-config leg** — the smoke ran on a stale `state.db` fee config (0/0); a real operator publish validates the operator-config/fees consumer path end-to-end. **B. Phase D follow-up (deferred → starting now)** - **State-machine retry switch** — wire `LnbitsRpcError.retryPolicy` into the cash-out XState flow (retry transient `operator_signer_unavailable`/`rate_limited`/`internal_error`; terminal on the rest) + `invoice_already_paid` → success-equivalent at dispense. *Starting this now.* - **Mid-session re-pair detection** — flip to "Pairing Required" when a sign fails *during* a live flow (boot-time is covered). More relevant now that #38 makes expiry a clean mid-operation reject. **C. #62 — operator identity in the seed (nprofile)** Producer (spirekeeper `pair_spire`) + consumer (`seed.ts`) + npub normalization → single-QR provisioning. **D. Production rollout (eventual)** `dev` → `main` when the bunker stack is production-ready (prod ATMs still on `main`/Lightning.Pub); `deploy/server-deploy` flake bumps; test-unit `nixos-upgrade.timer` policy (it reverts `nixos-rebuild test` at 04:00). **E. Cross-team (their side):** spirekeeper PR (machine_npub-nullable etc.); `bunker_relay` `/pair` override; nsecbunkerd #28 rate-limit reply (my error layer has the `rate_limited` slot ready) + PRs #32/#33/#34. **Suggested order:** payment legs 3/4 (in prep) ∥ Phase D retry switch (starting) → then #62 / production planning.
Author
Owner

✅ End-to-end validated on aio-demo (real public backend, over TLS) — 2026-06-22

The full bunker-backed ATM flow is now proven against aio-demo (the deployed demo lnbits + spirekeeper, over public wss:///https://), not just the LAN dev stack. Sintra paired to aio-demo as a fresh spire identity and ran a cash-out + cash-in.

Pairing

  • New spire 13a9b624… paired via aio-demo's bunker over the public TLS relay (wss://lnbits.demo.aiolabs.dev/nostrrelay/demo) — the spirekeeper /pair bunker_relay fix means the seed now carries a machine-reachable bunker relay (the earlier localhost bug is gone).
  • list_wallets signed via the bunker → wallet ecb95d02…; roster maps spire → operator wallet 69791aab…. No nsec on the machine.
  • Operator 8eb61054… published fee config (cash_in=0.1 super 0.07 + op 0.03; cash_out=0.08 super 0.05 + op 0.03), applied live; cassette-state bootstrap decrypted + applied operator-side (2 cassettes).

Cash-out (machine → spirekeeper settlement)

invoice gross 133056 = principal 123200 + fee 9856 (8%)  → paid → dispensed 50×1+20×1 → cassette-state republished (#65)
spirekeeper: landed settlement … wire=133056 principal=123200 fee=9856 (super_fee=6160 operator_fee=3696)

Cash-in (secure create_withdraw, #66)

[machine] create_withdraw: principal=31680 fee=3168 net=28512 link=RQ4D2wjM…  → LNURL1DP68… (bech32) → claimed → cash_in (complete)
spirekeeper: create_withdraw … principal=31680 fee=3168 (super=2218 op=950) net=28512
spirekeeper: landed settlement … wire=28512 principal=31680 fee=3168 (super=2218 op=950); dca leg skipped (cash_in)

Trust properties confirmed server-side

  • Verified-sender attribution: settlements attributed to machine=13a9b624… from the signed identity; roster override — sender 13a9b624… → wallet 69791aab…. Amount/fee/attribution all server-derived (the ATM only sends principal_sats).
  • Correct fee splits both directions (8% / 10%, super+operator) matching the operator's published config.
  • bech32 LNURL displayed (not the dev-stack http://).

What's covered now

The whole arc — Phases A–E + retry switch (#64) + cassette republish (#65) + secure cash-in (#66) + beacon guard (#67) — is validated end-to-end on a real public backend: pair → resume → revoke/TTL → cassette bootstrap+decrement → cash-out settlement → secure cash-in. This is the deployment-shape validation.

Remaining (none blocking)

  • QR-pairing wizard (camera/scanner ingest of the seed) — scoping started; today pairing is provisioning the seed into .env.
  • Production rollout: dev → main + deploy/server-deploy flake bumps + spirekeeper v0.1.1 catalog bump (operator side).
## ✅ End-to-end validated on aio-demo (real public backend, over TLS) — 2026-06-22 The full bunker-backed ATM flow is now proven against **aio-demo** (the deployed demo lnbits + spirekeeper, over public `wss://`/`https://`), not just the LAN dev stack. Sintra paired to aio-demo as a fresh spire identity and ran a cash-out + cash-in. ### Pairing - New spire `13a9b624…` paired via aio-demo's bunker over the **public TLS relay** (`wss://lnbits.demo.aiolabs.dev/nostrrelay/demo`) — the spirekeeper `/pair` `bunker_relay` fix means the seed now carries a machine-reachable bunker relay (the earlier localhost bug is gone). - `list_wallets` signed via the bunker → wallet `ecb95d02…`; roster maps spire → operator wallet `69791aab…`. **No nsec on the machine.** - Operator `8eb61054…` published fee config (`cash_in=0.1` super 0.07 + op 0.03; `cash_out=0.08` super 0.05 + op 0.03), applied live; cassette-state bootstrap decrypted + applied operator-side (2 cassettes). ### Cash-out (machine → spirekeeper settlement) ``` invoice gross 133056 = principal 123200 + fee 9856 (8%) → paid → dispensed 50×1+20×1 → cassette-state republished (#65) spirekeeper: landed settlement … wire=133056 principal=123200 fee=9856 (super_fee=6160 operator_fee=3696) ``` ### Cash-in (secure `create_withdraw`, #66) ``` [machine] create_withdraw: principal=31680 fee=3168 net=28512 link=RQ4D2wjM… → LNURL1DP68… (bech32) → claimed → cash_in (complete) spirekeeper: create_withdraw … principal=31680 fee=3168 (super=2218 op=950) net=28512 spirekeeper: landed settlement … wire=28512 principal=31680 fee=3168 (super=2218 op=950); dca leg skipped (cash_in) ``` ### Trust properties confirmed server-side - **Verified-sender attribution:** settlements attributed to `machine=13a9b624…` from the signed identity; `roster override — sender 13a9b624… → wallet 69791aab…`. Amount/fee/attribution all server-derived (the ATM only sends `principal_sats`). - **Correct fee splits both directions** (8% / 10%, super+operator) matching the operator's published config. - **bech32 LNURL** displayed (not the dev-stack `http://`). ### What's covered now The whole arc — Phases A–E + retry switch (#64) + cassette republish (#65) + secure cash-in (#66) + beacon guard (#67) — is validated end-to-end on a real public backend: pair → resume → revoke/TTL → cassette bootstrap+decrement → cash-out settlement → secure cash-in. This is the deployment-shape validation. ### Remaining (none blocking) - QR-pairing wizard (camera/scanner ingest of the seed) — scoping started; today pairing is provisioning the seed into `.env`. - Production rollout: `dev` → `main` + `deploy/server-deploy` flake bumps + spirekeeper v0.1.1 catalog bump (operator side).
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/bitspire#52
No description provided.