track: consume LNbits sidecar bunker (replace VITE_ATM_PRIVATE_KEY with bunker:// URL) #52
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Forward-planning placeholder. The LNbits side is being implemented at
aiolabs/lnbits#18(sidecarnsecbunkerdintegration). This issue tracks the lamassu-next consumer-side work that lands once #18 is live.Context
Today the ATM holds
VITE_ATM_PRIVATE_KEYin/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 tosign_event:21000(peraiolabs/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:
apps/machine/electron/main.ts:~332—get-atm-secretsIPC handler returns raw nsec. Replace with returning thebunker://URL.apps/machine/src/services/lightning.ts:~469—loadIdentityFromHex()derives the identity. Replace with aBunkerSignerfactory.packages/nostr-client/src/client.ts:~154—createAuthEvent()signs with the identity. Route to bunkersign_eventRPC.packages/nostr-client/src/events.ts:17-26—createSignedEvent()signs directly. Route to bunker.packages/nostr-client/src/encryption.ts:191-196, 240-244—encryptContent()anddecryptContent()useidentity.privateKey. Route to bunkernip44_encrypt/nip44_decrypt.packages/lnbits/src/client.ts:376-387—finalizeEvent()signs the kind-21000 RPC. Route to bunker.apps/machine/src/composables/useAvailabilityBroadcast.ts:~76— signs the kind-30078 beacon. Route to bunker.apps/machine/electron/fund-atm.ts:~67— CLI tool'sloadIdentityFromHex(). Update.Estimate
12–16 hours once
aiolabs/lnbits#18ships theRemoteBunkerSigner+NsecBunkerAdminClientAPI surface:deploy/nixos/provision-atm.sh): ~1hBlocked on
aiolabs/lnbits#18shippingRemoteBunkerSigner.sign_eventend-to-end + per-devicecreate_tokenadmin RPC.packages/nostr-client/src/encryption.tsv1 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
aiolabs/lnbits#18).aiolabs/satmachineadmin#14(S0).References
aiolabs/satmachineadmin#13epic, S7 phase.aiolabs/lnbits#18.aiolabs/lnbits#9.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):
error_codewill be optional-additive for one release on the lnbits side, then required. ATM-side semantic: absenterror_codeis treated asinternal_error(retry-once-then-surface). We do not string-matcherrorcontent. 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):
operator_signer_unavailableoperator_signer_rejectedoperator_signer_unconfiguredunauthorizedrate_limitedunknown_methodinvalid_paramsinternal_errorwallet_not_foundinsufficient_balanceinvoice_already_paidinvoice_expiredpayment_failederrorstring)account_not_foundATM-side per-RPC handling for
invoice_already_paid:Extension-specific errors (lnurlp link not found, withdraw link expired, etc.) stay untyped →
internal_errorretry-once-then-surface. Per-extension scoped codes only added when ATM-side distinction is actually needed.Connection config: nothing to pre-bake on the
LnbitsClientside. 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 newoperator_signer_unavailablefailure mode.ATM-side artifacts to build when phase 3 of
aiolabs/lnbits#18ships 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).OperatorSignerUnavailableError,OperatorSignerRejectedError, etc. — so callers distinguish at the type level at theLnbitsClientboundary.operator_signer_unavailableandrate_limitedas transient-with-backoff ("operator briefly unavailable, try again");operator_signer_rejected,unauthorizedetc. as terminal surface-to-user.Canonical source / drift detection: lnbits enum +
docs/devs/nostr-transport.mdvocabulary 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-46sign_eventover 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) andlamassu-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 commit4ebcd959for 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.tsv1 path stays alive post-bunker remains unresolved. Will surface when phase 3 ships — bunker NIP-44 v1 support determines this.Status 2026-06-16 — lnbits side ready; now the active consumer-wiring task
Blocker cleared:
lnbitsdevshipsRemoteBunkerSigner+NsecBunkerAdminClient.create_new_token(policy-based), verified againstaiolabs/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_KEYto a NIP-46bunker://connection at the ~8 sites in the body (electronget-atm-secrets,lightning.tsidentity load,nostr-clientcreate/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).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}/pairreturns)urlsafe_b64encode(...).rstrip("=")— re-pad to a multiple of 4 before decoding.bunker_url,relayandsecretare 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'
NIP46BunkerClientReference impl to copy:
lnbits/core/services/nip46_bunker_client.pyinaiolabs/lnbits(Python;from_signer,connect,sign_event,nip44_*). The bunker isaiolabs/nsecbunkerd.client_nsec) — this is the NIP-46 transport identity, NOT the signing identity.bunker_url's relay, redeemingsecret. nsecbunkerd bindsclient_pubkey → spire key(perlnbits#32eager-bind pattern). The connect token is one-shot — redeemed on first connect.client_nsec(tostate.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.spire_pubkeyfrom the seed (the bunker-held key). Every event the spire publishes is signed asspire_pubkeyvia the bunker'ssign_eventRPC. ReplaceVITE_ATM_PRIVATE_KEYat the ~8 sites in the issue body with bunkersign_event/nip44_encrypt/nip44_decryptcalls.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_eventfor kinds 21000, 21001, 21002, 21003, 30078nip44_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 byclient_nsec). If the spire signs any other kind, or needsnip04_*, tell us — we add it toSPIRE_POLICY_RULES/SPIRE_POLICY_METHODS_NO_KINDin spirekeeperpairing.py. A missing kind/method is a silent sign failure. (kind-30078 cassette-state is NIP-44 encrypted to the operator — that's thenip44_encryptuse; 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 itsbitspire-cassettes-statehello-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)
#23) callsrevoke_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; seespirekeeper#22). After revoke, the spire'ssign_eventRPCs should start failing — handle that gracefully (treat as "unpaired", surface a re-pair prompt).duration_hours, optional): setsToken.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/spirekeeperpairing.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.Consumer-side review 2026-06-18 — verified against
dev@627d5e6Walked 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)
devmain.ts:~332get-atm-secrets:323, payload:330lightning.ts:~469loadIdentityFromHex:445client.ts:~154createAuthEvent:159events.ts:17-26createSignedEvent:17/:26encryption.ts:191-196,240-244:191/:242/:255(v1),:278/:287(v2)lnbits/client.ts:376-387finalizeEvent:460-470(~85 lines drift)useAvailabilityBroadcast.ts:~76:76fund-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. Thespirekeeper-spirepolicy 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 withclient_nsec(AUTH must prove control ofspire_pubkey, which only the bunker holds).Decision needed: add
22242toSPIRE_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):
dev:21000(lnbits RPC,lnbits/client.ts:460) +30078(availability beacon plaintextuseAvailabilityBroadcast.ts:76, and cassette-statenip44_encrypt'd to operatoroperator-config.ts). Plus22242per above.dev): CLINK21001/21002/21003.encryption.ts, zero live callers. Matches "nip04 NOT authorized."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.tsv1 stay alive post-bunker?" decision now closes cleanly: retire it. The v1 default (encryptContent/decryptContent→getConversationKeyV1) feeds onlycreateMachineStatusEvent/createTransactionEvent, which have zero callers inapps/. Every live encryption path isencryptContentV2/decryptContentV2(lnbits RPC, operator-config, operator-fees, clink) — all v2, all bunker-compatible (nip44_encryptis 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:
spire-seed:v1:<base64url>, re-pad to /4, percent-decoderelay/secretfrombunker_url.client_nseclifecycle — generate the spire's own transport keypair, one-shot connect (token spent on first connect), persist tostate.db; lose it post-bind → operator must re-pair. More than the "~1h provisioning script" line.meta.bootstrapPublishedAt=''to force cassette hello re-publish. Folds in bitspire#56; net-new acceptance criterion.sign_eventfails; 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
SPIRE_POLICY_RULES. Otherwise the live live set is21000+30078; nonip04; CLINK 21001-21003 stay (dormant but harmless). Will confirm once I've wired and smoke-tested.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 (offmain, 211 green). A test now locks the required set so it can't silently regress. Confirmed kept as you reported: live =21000+30078+22242; CLINK21001-21003dormant-but-kept;nip04out (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_hoursis 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 againstaiolabs/lnbits.Your other findings — all good on our side:
client_nseclifecycle + 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.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
Signerabstraction (mirrors lnbitsresolve_signer)All 8 sites touch
identity.privateKeydirectly via syncfinalizeEvent/nip44.v2. The bunker is async RPC. So the migration is: introduce aSignerinterface, route all 8 sites through it, make the call chain async.LocalSignerwraps aMachineIdentity(sync crypto behindPromise.resolve) — keeps the dev/ephemeral path + all existing tests green. Transitional.BunkerSignerwraps nostr-toolsnip46.BunkerSigner(already a dep, ^2.10).pubkey=spire_pubkeyfrom the seed, known synchronously → the many syncidentity.publicKeyfilter/tag sites just rename tosigner.pubkey, no refactor. Nonip04(policy forbids, nothing uses it).Sequencing — tree green at every commit
Phase A — Signer abstraction (pure async refactor, behavior-preserving):
signer.ts—Signer+LocalSigner+ tests (signEvent≡finalizeEvent,nip44*round-trip).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.encryptContent/decryptContent/decryptJSON/getConversationKeyV1/encryptV1/decryptV1+ the unusedcreateMachineStatusEvent/createTransactionEvent(zeroapps/callers). KeepencryptContentV2/decryptContentV2asLocalSignerinternals.Phase B — seed + connection lifecycle (net-new infra):
4.
seed.ts—parseSpireSeed(spire-seed:v1:<base64url>): re-pad to /4, JSON parse, percent-decoderelay/secretfrombunker_url. Zod-validated, fixture-tested againstpairing.pyconstants.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(generateclient_nsec, redeem one-shot secret, persist) /resumeFromState(reuseclient_nsec, reconnect, no redeem) / typed "unpaired" error on revoke.Phase C — bootstrap wiring (replace the nsec):
7. electron
main.ts—get-atm-secretsreturns{ spireSeed: VITE_SPIRE_SEED }(one-shot kept), notatmPrivateKey.8.
lightning.ts— seed→Signer resolution: new/changed seed →connectNewSeed+ resetbootstrapPublishedAt=''(folds in bitspire#56); else persisted →resumeFromState; else (non-strict dev) →LocalSigner(generateIdentity()). PasssignertoNostrClient+lnbits.initialize.9. electron
fund-atm.ts—resumeFromStatefrom state.db.Phase D — error handling + UX:
10. Revoke → re-pair maintenance screen on
signEventrejection.11. Cross-ref the error-layer task (05-26 comment):
error-codes.ts+ exception classes + state-machine retry switch —operator_signer_unavailable/operator_signer_rejectedbecome 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-devflake 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.handleReplyis a sync relayonEventcb calling decrypt → convert tovoid (async()=>…)(), preserve decrypt→pending-resolve ordering.callRpcordering: keep register-pending beforeawait publishso 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 onceresumeFromStateround-trips, sodevis never left half-paired. Tag at deploy-ready boundary. Net estimate ~16–20h.Starting Phase A.
Phase A landed on
dev(2026-06-18)Two behaviour-preserving commits, pushed:
d6b22e1— route signing + encryption through aSignerabstraction787de5b— retire the dead NIP-44 v1 / Lightning.Pub pathWhat's in: a
Signerinterface (signEvent/nip44Encrypt/nip44Decrypt+ syncpubkey) with an in-processLocalSigner. All 9 signing sites now route through it — the 8 in the body plus the maintenance-mode beacon inApp.vuefound during the sweep. The chain is async;LnbitsClient.handleReplykeeps 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
BunkerSignerin at one spot inlightning.tswith no call-site changes.Starting Phase B now (seed-URL parser +
client_nseclifecycle instate.db+BunkerSignerover NIP-46). Will mirror lnbits'nip46_bunker_client.pyand thespire-seed:v1:contract from spirekeeperpairing.py.Phase B landed on
dev(2026-06-18)Bunker pairing infrastructure, two commits pushed:
9c9009a—feat(nostr-client): NIP-46 bunker signer + spire pairing seed2b8e951—feat(machine): persist NIP-46 bunker binding (state.db schema v11)Seed parser (
seed.ts):parseSpireSeedfor thespire-seed:v1:<base64url>contract — re-pads stripped base64url, validates{v, spire_pubkey, bunker_url, relays}, leaves the bunker-URL percent-decoding to nostr-toolsparseBunkerInput.seedFingerprintfor re-pair detection.BunkerSigner (
bunker-signer.ts): implements theSignerinterface by delegatingsign_event/nip44_*to nostr-tools' nip46 over the bunker relay;pubkeyis the spire identity, known synchronously from the seed.connectNewSeedredeems the one-shot connect secret;resumeFromBindingreuses the persisted transport key without re-redeeming (binding is server-persistent — matches your guidance). Per-RPC timeout + typedBunkerRejectedError(revoke/off-policy → re-pair) vsBunkerTimeoutError(transient).Persistence (
state.dbv11):bunker_bindingsingleton holdsclient_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):
resumeFromBindingdeliberately skipsconnect()since the secret is one-shot and the binding is server-persistent. Please confirm that's right — i.e. after the initialconnect()redeem, a fresh process reusing the sameclient_nseccansign_eventagainst 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.tsbootstrap (get-atm-secretsreturns the seed; new/changed fingerprint →connectNewSeed+ resetbootstrapPublishedAt; elseresumeFromBinding; dev fallbackLocalSigner) + the IPC bridge for the binding accessors.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.
src/daemon/run.ts:108) callscheckIfPubkeyAllowed(keyName, remotePubkey, method, payload)and returns its verdict directly.checkIfPubkeyAllowed(src/daemon/lib/acl/index.ts:23) is keyed on(keyName, userPubkey = remotePubkey)against theKeyUser/SigningCondition/Token→Policy→PolicyRuletables. Nothing consults process memory.connect's only persistent effect isapplyToken(src/daemon/backend/index.ts:99): upsertsKeyUser(keyName, client_pubkey), writes aSigningConditionper policy rule (+ aconnectgrant), marks the tokenredeemedAt.So a fresh process reusing the same
client_nsec→ sameremotePubkey→ hits the persisted KeyUser + grants →sign_eventauthorized. No secondconnect.resumeFromBindingis right.Two caveats so it actually works on resume:
connectRPC). The bunker only sees your request if yoursign_eventreaches the relay it's subscribed to (kind-24133,#p: <bunker_pubkey>). Skippingconnect()is fine; skipping the relay (re)subscribe is not.validateTokenrejects a redeemed token (backend/index.ts:92, "Token already redeemed"). Your "reuseclient_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.expiresAtis read for enforcement in exactly one place —validateToken(backend/index.ts:94), which runs insideapplyToken, i.e. at connect/redeem time only.At sign time it is never checked:
checkIfPubkeyAllowedstep 3b matches the materializedSigningConditionfirst (method='sign_event', kind=<n>, allowed=true) and returns — andSigningConditionhas no expiry column at all (schema confirmed).revokedAt: nullonly — notexpiresAt(acl/index.ts:93-106).revokedAton expiry (the onlysetIntervals are interactive-authorization request cleanup, unrelated toToken).Conclusion:
duration_hoursbounds 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
duration_hoursdocstring in spirekeeperpairing.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.aiolabs/nsecbunkerd(the gap is in the bunker's sign-time ACL, not lnbits): post-bind TTL would require eithercheckIfPubkeyAllowedstep 4 to addexpiresAt > nowto the token join and an expiry on the materialized SigningConditions, or a reaper that setsKeyUser.revokedAtwhen 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
BunkerRejectedErrorafterrevoke_key_useris the signal; an expired-but-unrevoked binding will not produce one.expiresAt(TTL) is not enforced post-bind — sign-time ACL ignores it #24Cross-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_useris the spire's deauth path — code your Phase D re-pair UX against the revoke signal, not token expiry.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-pullsdev. Four commits:40239aarefactor(clink)— CLINK routed through the Signer (see below)209e4c3feat(machine)— seed + bunker-binding IPC bridge82a9e79feat(machine)— resolve signer from seed / binding at bootstrap0391dbachore(machine)— fund-atm resume + VITE_SPIRE_SEED docs/envResolution logic (
signer-resolver.ts, runs in the renderer where the relay I/O lives):connectNewSeed(redeem one-shot secret), persist binding, reset bootstrap gate (re-publish cassette-state hello to the new operator — folds in #56);LocalSigner(dev) or throw (strict/prod).get-atm-secretsnow hands the renderer{ spireSeed, bunkerBinding }(one-shot kept); the binding (transport key) persists instate.dbv11.lightning.tsdrops allatmPrivateKeyplumbing — 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).
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
expiresAtis 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_eventexactly like a revoke → both surface asBunkerRejectedError→ 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 addedexpiresAtto 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_hourswill 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 haspairedAtbut not the token'sexpiresAt, and the seed doesn't expose it.Two ways to make this graceful, your call on which:
duration_hoursfor 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.expiresAt(orduration_hours) to thespire-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:
BunkerRejectedErroralready drives re-pair, and if you addexpiresAtto the seed I'll thread it through the binding for proactive handling.(Also noted the deploy hazard — never full-wipe
nsecbunker.db; targetedDELETE FROM SigningConditionper your runbook. Not a bitspire-side action, just acking.)⚠️ 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 deployeddevtree:checkIfPubkeyAllowedstep 4 now joins theTokenthroughliveWhere(now)={ revokedAt: null, OR: [expiresAt null, expiresAt > now] }.applyTokenstopped photocopying policy grants into per-KeyUserSigningConditionrows — step 4 is the single live source of truth, evaluated every request.Net for #52:
expiresAt/duration_hoursnow bounds an established binding. An expired token starts failingsign_eventon the next request, not just at first connect. You can rely on TTL post-bind.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:BunkerRejectedError(previously I said it wouldn't), andProducer-side docstrings corrected in spirekeeper#28. Migration note from the deploy: never full-wipe
nsecbunker.dbto clear old materialized grants — use targetedDELETE FROM SigningCondition(full wipe orphans LNbitssigner_config); runbook in nsecbunkerddocs/runbook-migrations.md.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 ondevand 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.dbfee 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)
LnbitsRpcError.retryPolicyinto the cash-out XState flow (retry transientoperator_signer_unavailable/rate_limited/internal_error; terminal on the rest) +invoice_already_paid→ success-equivalent at dispense. Starting this now.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→mainwhen the bunker stack is production-ready (prod ATMs still onmain/Lightning.Pub);deploy/server-deployflake bumps; test-unitnixos-upgrade.timerpolicy (it revertsnixos-rebuild testat 04:00).E. Cross-team (their side): spirekeeper PR (machine_npub-nullable etc.);
bunker_relay/pairoverride; nsecbunkerd #28 rate-limit reply (my error layer has therate_limitedslot ready) + PRs #32/#33/#34.Suggested order: payment legs 3/4 (in prep) ∥ Phase D retry switch (starting) → then #62 / production planning.
✅ 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
13a9b624…paired via aio-demo's bunker over the public TLS relay (wss://lnbits.demo.aiolabs.dev/nostrrelay/demo) — the spirekeeper/pairbunker_relayfix means the seed now carries a machine-reachable bunker relay (the earlier localhost bug is gone).list_walletssigned via the bunker → walletecb95d02…; roster maps spire → operator wallet69791aab…. No nsec on the machine.8eb61054…published fee config (cash_in=0.1super 0.07 + op 0.03;cash_out=0.08super 0.05 + op 0.03), applied live; cassette-state bootstrap decrypted + applied operator-side (2 cassettes).Cash-out (machine → spirekeeper settlement)
Cash-in (secure
create_withdraw, #66)Trust properties confirmed server-side
machine=13a9b624…from the signed identity;roster override — sender 13a9b624… → wallet 69791aab…. Amount/fee/attribution all server-derived (the ATM only sendsprincipal_sats).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)
.env.dev→main+deploy/server-deployflake bumps + spirekeeper v0.1.1 catalog bump (operator side).