S0 — Seed-URL pairing + ATM keypair separation #9

Open
opened 2026-06-14 07:08:43 +00:00 by padreug · 2 comments
Owner

Migrated from aiolabs/satmachineadmin#14 (2026-06-13). Issue numbers were reassigned in this repo; cross-refs updated.

Part of #8. Closes gaps G3 (ATM holds operator nsec) and G9 (no ACL on auto-account-from-npub).

2026-05-26 — payload swap (NIP-26 → NIP-46 bunker URL) + blocked

Original framing had the seed URL carry a NIP-26 delegation token signed by the operator's nsec. That primitive is dead (see epic #8 status block and lnbits#9). The seed URL now carries a NIP-46 bunker:// connection URL issued by the sidecar nsecbunkerd (lnbits#18 §F).

Sequencing decision (per epic #8): wait for lnbits#18 to land before shipping S0. No transitional shim — wiring a seed URL format we'd rewrite in two weeks is throwaway work. The ATM keeps the Option 1 stopgap (operator nsec on disk) until the bunker is real and S0 ships alongside S2 + S7 in Sprint 2.

Today's stopgap (Option 1, stays in place until Sprint 2)

deploy/nixos/provision-atm.sh:99 in aiolabs/lamassu-next writes the operator's own nsec into /var/lib/bitspire/.env as VITE_ATM_PRIVATE_KEY. Physical compromise of the ATM ⇒ total operator compromise on every relay.

Proposed (post-lnbits#18)

  1. ATM has its own keypair. Provisioning script generates a fresh nsec and never overwrites it with the operator's. (It already generates one when ATM_PRIVATE_KEY is unset — we just stop the manual override.)
  2. Operator-side authority comes from a NIP-46 connection token (per lnbits#18 §F):
    token = await bunker_admin.create_token(
        target_pubkey=operator_pubkey,
        client_name=f"satmachine-{machine.id}",
        perms=["sign_event:21000"],
        expires_at=int(time.time()) + 86400 * 30,
    )
    
    Returns a bunker://<target_pubkey>?relay=<url>&secret=<token> URL.
  3. One-shot seed URL containing {atm_npub, bunker_url, relay_list} is rendered as QR on the operator's satmachineadmin dashboard and consumed by the ATM at first boot (or pasted as a string via web UI).
  4. Revocation = operator clicks "Revoke ATM access" in the dashboard → satmachineadmin calls bunker_admin.revoke_user(target_pubkey) → bunker rejects subsequent requests. Re-pair issues a fresh token.

Changes

aiolabs/satmachineadmin

  • New API: POST /api/v1/dca/machines/:id/pair → returns one-shot seed URL with a freshly-issued bunker connection URL (uses the lnbits admin client introduced by lnbits#18).
  • New UI on the Fleet tab: "Pair ATM" wizard with QR + copy-to-clipboard fallback.
  • dca_machines gets bunker_connection_id + bunker_token_expires_at columns so we can show the expiry + a "Re-pair" button.

aiolabs/lamassu-next

  • deploy/nixos/provision-atm.sh: stop accepting ATM_PRIVATE_KEY=<operator nsec>; instead consume a seed URL via --seed-url flag or stdin.
  • ATM-side loader (lamassu-next app) reads bunker URL from sealed config and uses it for NIP-46 signing.

Acceptance

  • Operator pairs a fresh ATM via the wizard end-to-end on the regtest stack.
  • /var/lib/bitspire/.env contains only the ATM's own nsec + a bunker connection URL — never the operator's nsec.
  • After "Revoke ATM access" → the ATM's next kind-21000 is rejected by the handler.
  • Revocation visible in operator dashboard.

Reference

  • aiolabs/lnbits#18 §F — per-device scoped tokens (this issue's authority primitive).
  • NIP-46 spec: ~/dev/nostr-protocol/nips/46.md.
  • Design doc: docs/security-pathway-v1.md §5.1, §6.S0 — needs a follow-up edit reflecting the NIP-26→NIP-46 swap.
  • Predecessor sketch: aiolabs/satmachineadmin#12.
> _Migrated from aiolabs/satmachineadmin#14 (2026-06-13). Issue numbers were reassigned in this repo; cross-refs updated._ Part of #8. Closes gaps G3 (ATM holds operator nsec) and G9 (no ACL on auto-account-from-npub). ## 2026-05-26 — payload swap (NIP-26 → NIP-46 bunker URL) + blocked Original framing had the seed URL carry a NIP-26 delegation token signed by the operator's nsec. That primitive is dead (see epic #8 status block and lnbits#9). The seed URL now carries a **NIP-46 `bunker://` connection URL** issued by the sidecar `nsecbunkerd` (lnbits#18 §F). **Sequencing decision (per epic #8): wait for lnbits#18 to land before shipping S0.** No transitional shim — wiring a seed URL format we'd rewrite in two weeks is throwaway work. The ATM keeps the Option 1 stopgap (operator nsec on disk) until the bunker is real and S0 ships alongside S2 + S7 in Sprint 2. ## Today's stopgap (Option 1, stays in place until Sprint 2) `deploy/nixos/provision-atm.sh:99` in `aiolabs/lamassu-next` writes the operator's own `nsec` into `/var/lib/bitspire/.env` as `VITE_ATM_PRIVATE_KEY`. Physical compromise of the ATM ⇒ total operator compromise on every relay. ## Proposed (post-lnbits#18) 1. **ATM has its own keypair.** Provisioning script generates a fresh `nsec` and never overwrites it with the operator's. (It already generates one when `ATM_PRIVATE_KEY` is unset — we just stop the manual override.) 2. **Operator-side authority** comes from a NIP-46 connection token (per lnbits#18 §F): ```python token = await bunker_admin.create_token( target_pubkey=operator_pubkey, client_name=f"satmachine-{machine.id}", perms=["sign_event:21000"], expires_at=int(time.time()) + 86400 * 30, ) ``` Returns a `bunker://<target_pubkey>?relay=<url>&secret=<token>` URL. 3. **One-shot seed URL** containing `{atm_npub, bunker_url, relay_list}` is rendered as QR on the operator's satmachineadmin dashboard and consumed by the ATM at first boot (or pasted as a string via web UI). 4. **Revocation** = operator clicks "Revoke ATM access" in the dashboard → satmachineadmin calls `bunker_admin.revoke_user(target_pubkey)` → bunker rejects subsequent requests. Re-pair issues a fresh token. ## Changes **`aiolabs/satmachineadmin`** - New API: `POST /api/v1/dca/machines/:id/pair` → returns one-shot seed URL with a freshly-issued bunker connection URL (uses the lnbits admin client introduced by lnbits#18). - New UI on the Fleet tab: "Pair ATM" wizard with QR + copy-to-clipboard fallback. - `dca_machines` gets `bunker_connection_id` + `bunker_token_expires_at` columns so we can show the expiry + a "Re-pair" button. **`aiolabs/lamassu-next`** - `deploy/nixos/provision-atm.sh`: stop accepting `ATM_PRIVATE_KEY=<operator nsec>`; instead consume a seed URL via `--seed-url` flag or stdin. - ATM-side loader (lamassu-next app) reads bunker URL from sealed config and uses it for NIP-46 signing. ## Acceptance - [ ] Operator pairs a fresh ATM via the wizard end-to-end on the regtest stack. - [ ] `/var/lib/bitspire/.env` contains only the ATM's own nsec + a bunker connection URL — never the operator's nsec. - [ ] After "Revoke ATM access" → the ATM's next kind-21000 is rejected by the handler. - [ ] Revocation visible in operator dashboard. ## Reference - `aiolabs/lnbits#18` §F — per-device scoped tokens (this issue's authority primitive). - NIP-46 spec: `~/dev/nostr-protocol/nips/46.md`. - Design doc: `docs/security-pathway-v1.md` §5.1, §6.S0 — needs a follow-up edit reflecting the NIP-26→NIP-46 swap. - Predecessor sketch: aiolabs/satmachineadmin#12.
Author
Owner

Status 2026-06-16 — unblocked; API correction; naming; gate-reset acceptance

Unblocked. The lnbits-side primitive this issue waited on shipped on lnbits dev (lnbits#18 status 2026-06-16): NsecBunkerAdminClient.create_new_token + policies, verified against the real bunker.

API correction. The pseudocode here (create_token(target_pubkey, perms=["sign_event:21000"], expires_at=...)) does NOT match the shipped admin client. Actual flow is policy-based:

policy_id = await bunker.create_new_policy(name=f"atm-{machine.id}", rules=[...])  # allow sign_event kind 21000
await bunker.create_new_token(key_name=<operator_bunker_key>, client_name=f"atm-{machine.id}", policy_id=policy_id)
bunker_url = (await bunker.get_key_tokens(<operator_bunker_key>))[-1]  # bunker:// connection string

The seed URL embeds that bunker_url.

Naming. Producer is now aiolabs/spirekeeper (v2-bitspire split from satmachineadmin 2026-06-13). Read every "satmachineadmin" here as "spirekeeper". ATM-side counterpart = bitspire#52; cassette state = bitspire#56.

New acceptance criterion — folds in the cassette-bootstrap gap. The ATM publishes its bitspire-cassettes-state hello-event exactly once, gated by state.db meta.bootstrapPublishedAt (bitspire#56). Re-pointing an ATM at a new operator/relay today never re-publishes it (reproduced on demo 2026-06-16 — cassettes never populate after re-pairing). The pairing consumer MUST, on consuming a new/changed seed:

  • reset bootstrapPublishedAt (→ re-publish cassette-state to the new operator), and
  • re-publish other operator-scoped state under the new relationship.
    This makes the standalone gate bug a non-issue rather than a separate patch.
## Status 2026-06-16 — unblocked; API correction; naming; gate-reset acceptance **Unblocked.** The lnbits-side primitive this issue waited on shipped on `lnbits` `dev` (lnbits#18 status 2026-06-16): `NsecBunkerAdminClient.create_new_token` + policies, verified against the real bunker. **API correction.** The pseudocode here (`create_token(target_pubkey, perms=["sign_event:21000"], expires_at=...)`) does NOT match the shipped admin client. Actual flow is **policy-based**: ```python policy_id = await bunker.create_new_policy(name=f"atm-{machine.id}", rules=[...]) # allow sign_event kind 21000 await bunker.create_new_token(key_name=<operator_bunker_key>, client_name=f"atm-{machine.id}", policy_id=policy_id) bunker_url = (await bunker.get_key_tokens(<operator_bunker_key>))[-1] # bunker:// connection string ``` The seed URL embeds that `bunker_url`. **Naming.** Producer is now **`aiolabs/spirekeeper`** (v2-bitspire split from satmachineadmin 2026-06-13). Read every "satmachineadmin" here as "spirekeeper". ATM-side counterpart = **bitspire#52**; cassette state = **bitspire#56**. **New acceptance criterion — folds in the cassette-bootstrap gap.** The ATM publishes its `bitspire-cassettes-state` hello-event exactly once, gated by `state.db meta.bootstrapPublishedAt` (bitspire#56). Re-pointing an ATM at a new operator/relay today never re-publishes it (reproduced on demo 2026-06-16 — cassettes never populate after re-pairing). The pairing consumer MUST, on consuming a new/changed seed: - [ ] reset `bootstrapPublishedAt` (→ re-publish cassette-state to the new operator), and - [ ] re-publish other operator-scoped state under the new relationship. This makes the standalone gate bug a non-issue rather than a separate patch.
Author
Owner

Correction 2026-06-16 — ATM self-signs (path B), not sign-as-operator; model = per-ATM bunker key (A1)

The original pseudocode has the ATM signing kind-21000 as the operator (target_pubkey=operator_pubkey, bunker://<operator_pubkey>). That contradicts the shipped path-B roster (nostr_transport/roster.py, live on demo), which routes by the kind-21000 sender pubkey = the ATM's own npub. Signing as the operator would make the sender the operator and the roster lookup never fire.

Resolved model (confirmed 2026-06-16): the ATM self-signs with its own key; the roster maps that npub -> operator wallet. The bunker's role for the ATM is to hold the ATM's own key (no nsec on the ATM disk), NOT to let the ATM sign as the operator.

Pairing flow (model A1 — chosen 2026-06-16):

  1. bunker.create_new_key(name=f"atm-{machine.id}", ...) -> the ATM's own npub, held in the bunker.
  2. find-or-create a scoped policy (sign_event kind 21000 + 30078; nip44 for cassette-state encryption to the operator).
  3. create_new_token(key_name=f"atm-{machine.id}", client_name=..., policy_id) -> recover bunker:// via get_key_tokens.
  4. dca_machines.machine_npub = the bunker-generated ATM npub (so roster routes it). Seed URL embeds {atm_npub, bunker_url, relay_list}.

Lifecycle consequence: machine_npub is assigned at pair time (bunker-generated), not entered by the operator at create time.

## Correction 2026-06-16 — ATM self-signs (path B), not sign-as-operator; model = per-ATM bunker key (A1) The original pseudocode has the ATM signing kind-21000 *as the operator* (`target_pubkey=operator_pubkey`, `bunker://<operator_pubkey>`). That contradicts the shipped **path-B roster** (`nostr_transport/roster.py`, live on demo), which routes by the kind-21000 **sender** pubkey = the ATM's own npub. Signing as the operator would make the sender the operator and the roster lookup never fire. **Resolved model (confirmed 2026-06-16): the ATM self-signs with its own key; the roster maps that npub -> operator wallet.** The bunker's role for the ATM is to **hold the ATM's own key** (no nsec on the ATM disk), NOT to let the ATM sign as the operator. Pairing flow (model A1 — chosen 2026-06-16): 1. `bunker.create_new_key(name=f"atm-{machine.id}", ...)` -> the ATM's own npub, held in the bunker. 2. find-or-create a scoped policy (sign_event kind 21000 + 30078; nip44 for cassette-state encryption to the operator). 3. `create_new_token(key_name=f"atm-{machine.id}", client_name=..., policy_id)` -> recover `bunker://` via `get_key_tokens`. 4. `dca_machines.machine_npub` = the bunker-generated ATM npub (so roster routes it). Seed URL embeds `{atm_npub, bunker_url, relay_list}`. Lifecycle consequence: `machine_npub` is **assigned at pair time** (bunker-generated), not entered by the operator at create time.
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/spirekeeper#9
No description provided.