feat(pairing): seed-URL pairing — operator-side producer (S0 / #9) #21

Merged
padreug merged 4 commits from feat/seed-url-pairing into main 2026-06-18 17:03:34 +00:00
Owner

Operator-side producer for seed-URL pairing (S0 / #9), model A1: the spire's signing key lives inside the operator's nsecbunkerd, never on the spire's disk. The spire self-signs all its events as that bunker-held key; lnbits' path-B roster maps the npub → operator wallet.

Design note (a contradiction resolved)

While building this I found and corrected a contradiction in the plan: #9's original pseudocode had the ATM signing kind-21000 as the operator via the bunker, which conflicts with the shipped path-B roster (routes by the kind-21000 sender = the spire's own npub). The corrected, shipped-aligned model: the spire self-signs with its own (bunker-held) key; the bunker holds the key so no nsec sits on the device. See the #9 correction comment (2026-06-16). Also confirmed aiolabs/lnbits#18's scoped-token API is already shipped — this work is unblocked.

What's here (3 commits)

  1. schema (m010)dca_machines += bunker_spire_key_name, paired_at (idempotent, m009 pattern).
  2. pairing service (pairing.py)pair_spire() orchestrates the nsecbunkerd admin chain (create_new_key(spire-<id>) → scoped spirekeeper-spire policy → create_new_tokenget_key_tokens) and builds a bunker:// + base64url spire-seed:v1: URL {spire_npub, spire_pubkey, bunker_url, relays}. spirekeeper does steps 1–4; the spire does the NIP-46 connect/bind at first boot (bitspire#52). A local _ensure_spire_policy avoids lnbits' policy-name-blind _ensure_policy cache.
  3. API (POST /machines/{id}/pair) — ownership guard, bunker-error mapping (503 / 502), post-mint collision guard on the minted hex, persist via set_machine_pairing. Re-pair supported.

Tests

203 green (12 new: 9 pairing-service with a fake bunker; 3 endpoint wiring). npub↔hex through lnbits' real helpers. New code is black/ruff-clean — pre-existing lint debt in crud.py/views_api.py left untouched (avoided black version-drift whole-file churn).

Reviewer note — lifecycle change

Under A1 the bunker mints the spire identity, so /pair overwrites machine_npub with the bunker-generated key (re-running the no-collision guard). "Add machine" no longer needs an operator-supplied npub — it's assigned at pair time.

Naming

New code uses spire (the bitSpire machine; "ATM" is the legacy Lamassu term); bunker keys are spire-<machine_id>. Cross-repo atm terms (bitspire .env, lnbits roster) left for a later sweep; the Nostr wire is unaffected.

Follow-ups (not in this PR)

  • Frontend — Fleet "Pair Spire" wizard (QR + copy).
  • aiolabs/bitspire#52 — spire-side consumer: parse the seed, NIP-46 connect/bind, route the ~8 signing sites through a BunkerSigner, reset bootstrapPublishedAt on a new seed (folds in the cassette-bootstrap-gate fix, #56).
  • aiolabs/lnbits#54 — admin-client gaps gating "revoke spire access" + token expiry.

🤖 Generated with Claude Code

Operator-side producer for **seed-URL pairing** (S0 / #9), model **A1**: the spire's signing key lives inside the operator's nsecbunkerd, never on the spire's disk. The spire self-signs all its events as that bunker-held key; lnbits' path-B roster maps the npub → operator wallet. ## Design note (a contradiction resolved) While building this I found and corrected a contradiction in the plan: #9's original pseudocode had the ATM signing kind-21000 *as the operator* via the bunker, which conflicts with the **shipped path-B roster** (routes by the kind-21000 *sender* = the spire's own npub). The corrected, shipped-aligned model: **the spire self-signs with its own (bunker-held) key**; the bunker holds the key so no nsec sits on the device. See the #9 correction comment (2026-06-16). Also confirmed `aiolabs/lnbits#18`'s scoped-token API is already shipped — this work is unblocked. ## What's here (3 commits) 1. **schema (m010)** — `dca_machines` += `bunker_spire_key_name`, `paired_at` (idempotent, m009 pattern). 2. **pairing service (`pairing.py`)** — `pair_spire()` orchestrates the nsecbunkerd admin chain (`create_new_key(spire-<id>)` → scoped `spirekeeper-spire` policy → `create_new_token` → `get_key_tokens`) and builds a `bunker://` + base64url `spire-seed:v1:` URL `{spire_npub, spire_pubkey, bunker_url, relays}`. spirekeeper does steps 1–4; the spire does the NIP-46 connect/bind at first boot (bitspire#52). A local `_ensure_spire_policy` avoids lnbits' policy-name-blind `_ensure_policy` cache. 3. **API (`POST /machines/{id}/pair`)** — ownership guard, bunker-error mapping (503 / 502), post-mint collision guard on the minted hex, persist via `set_machine_pairing`. Re-pair supported. ## Tests **203 green** (12 new: 9 pairing-service with a fake bunker; 3 endpoint wiring). npub↔hex through lnbits' real helpers. New code is black/ruff-clean — pre-existing lint debt in `crud.py`/`views_api.py` left untouched (avoided black version-drift whole-file churn). ## Reviewer note — lifecycle change Under A1 the bunker **mints** the spire identity, so `/pair` **overwrites `machine_npub`** with the bunker-generated key (re-running the no-collision guard). "Add machine" no longer needs an operator-supplied npub — it's assigned at pair time. ## Naming New code uses **`spire`** (the bitSpire machine; "ATM" is the legacy Lamassu term); bunker keys are `spire-<machine_id>`. Cross-repo `atm` terms (bitspire `.env`, lnbits roster) left for a later sweep; the Nostr wire is unaffected. ## Follow-ups (not in this PR) - **Frontend** — Fleet "Pair Spire" wizard (QR + copy). - **aiolabs/bitspire#52** — spire-side consumer: parse the seed, NIP-46 connect/bind, route the ~8 signing sites through a BunkerSigner, reset `bootstrapPublishedAt` on a new seed (folds in the cassette-bootstrap-gate fix, #56). - **aiolabs/lnbits#54** — admin-client gaps gating "revoke spire access" + token expiry. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Schema checkpoint for seed-URL pairing (S0 / #9; spire-side bitspire#52),
model A1 — the spire's signing key lives in the operator's nsecbunkerd,
not on the spire's disk.

dca_machines gains:
  - bunker_spire_key_name — the spire's key name in the bunker
    (spire-<machine_id>); used to re-issue connect tokens on re-pair.
  - paired_at — last successful pair; NULL = never paired.

Both nullable, idempotent column-probe add (m009 pattern). Machine model
gains the matching optional fields. Validated on the regtest dev db
(columns present, migrations clean); 191 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator-side producer for seed-URL pairing (S0/#9, model A1). pair_spire()
orchestrates the nsecbunkerd admin chain via lnbits' NsecBunkerAdminClient:
  create_new_key(spire-<id>) -> _ensure_spire_policy -> create_new_token ->
  get_key_tokens -> package the <npub>#secret token into a bunker:// URL +
  base64url seed URL {spire_npub, spire_pubkey, bunker_url, relays}.

The spire later self-signs all its events as that bunker-held key; lnbits'
path-B roster maps the npub to the operator wallet — no nsec on the spire.
spirekeeper does steps 1-4 only; the NIP-46 connect/bind happens spire-side
(bitspire#52) with the spire's own client keypair.

Scoped policy 'spirekeeper-spire': sign_event 21000/21001-3/30078 + nip44
(kind-less via add_policy_rule). Local _ensure_spire_policy (no cache)
avoids lnbits' admin-pubkey-keyed _ensure_policy cache (policy-name-blind).

9 unit tests with a fake bunker (orchestration, policy reconcile, seed/
bunker:// wire shape, error paths); npub<->hex via lnbits' real helpers.
200 tests green.

Known gaps (lnbits NsecBunkerAdminClient): no token-expiry param, no revoke
RPC — re-pair works; 'revoke spire access' deferred to a bunker follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(pairing): POST /machines/{id}/pair endpoint (#9)
Some checks failed
ci.yml / feat(pairing): POST /machines/{id}/pair endpoint (#9) (pull_request) Failing after 0s
761f078053
Wires the pairing service into the operator API. api_pair_machine:
  - _machine_owned_by ownership guard (404 on miss)
  - opens NsecBunkerAdminClient.from_settings() and runs pair_spire
  - maps bunker failures: not-configured -> 503, PairingError/NsecBunkerError
    -> 502 (nothing persisted on failure)
  - runs _assert_no_pubkey_collision on the bunker-minted hex, then
    set_machine_pairing persists machine_npub (= minted spire identity, so
    path-B roster routes it), bunker_spire_key_name, paired_at.

Re-pair supported; revoke/expiry gated on aiolabs/lnbits#54.

Adds Create... PairMachineData {relays} body, set_machine_pairing CRUD,
and 3 endpoint wiring tests (persist+collision, empty-relays 400, failure
502). 203 tests green. Pre-existing black/ruff debt in crud/views_api left
untouched (version-drift churn avoided); new code is lint-clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refactor(pairing): use lnbits' public ensure_policy, drop fork duplicate (#9)
Some checks failed
ci.yml / refactor(pairing): use lnbits' public ensure_policy, drop fork duplicate (#9) (pull_request) Failing after 0s
9c5f07c72e
Adopts aiolabs/lnbits#55 (merged b5fba561): pair_spire now calls the
public ensure_policy(client, name='spirekeeper-spire', rules=...,
methods_no_kind=...) instead of spirekeeper's cache-free
_ensure_spire_policy copy. #55 re-keyed _POLICY_ID_CACHE on
(admin_pubkey, policy_name), so the shared helper no longer returns the
wrong (lnbits-default) id for a non-default policy name — the exact
reason the duplicate existed. Net -45 LOC, one less fork-divergent
reimplementation to keep in sync.

Requires lnbits >= the #55 merge (ensure_policy importable) — already
true on dev/demo.

Tests: FakeBunker gains admin_pubkey; an autouse fixture clears lnbits'
_POLICY_ID_CACHE between tests (the shared helper caches, unlike the old
local one). 203 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
padreug deleted branch feat/seed-url-pairing 2026-06-18 17:03:35 +00:00
Sign in to join this conversation.
No reviewers
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!21
No description provided.