Migrate merchant signing off plaintext nsec — integrate with the lnbits #9 signer abstraction #5

Closed
opened 2026-05-25 13:41:03 +00:00 by padreug · 0 comments
Owner

Problem

nostrmarket.merchants.private_key TEXT NOT NULL stores the merchant's
Nostr nsec as plaintext. Every kind 30030/30031/4 event the relay
listener publishes calls sign_message_hash(self.private_key, hash_)
(models.py:65), which instantiates coincurve.PrivateKey from the
plaintext column on every publish. A DB dump or any read access to
the merchants table is a wholesale compromise of every merchant's
identity — exactly the same shape of failure that aiolabs/lnbits#9
is closing on the core accounts table.

aiolabs/lnbits#9 phase 1 (now in flight via PR
aiolabs/lnbits#17) lands the NostrSigner abstraction in core. The
core _create_default_merchant auto-provision path is disabled in
phase 1
for exactly this reason — it cannot in good conscience copy
the user's encrypted-at-rest nsec out of signer_config into a
plaintext column in this extension. Re-enabling it depends on this
extension migrating onto the same abstraction. That's what this issue
tracks.

Goals

  1. Stop persisting plaintext nsec in nostrmarket.merchants. End
    state: the column either holds an envelope-encrypted blob, or it
    doesn't exist at all (the merchant's signing identity is resolved
    from the core account's signer_type + signer_config).
  2. Route every signing call through the core NostrSigner ABC
    instead of a local sign_message_hash(plaintext_hex, …) helper.
  3. Add NIP-46 bunker support so sovereignty-minded merchants can
    keep their nsec entirely off the server (the user's bunker signs
    each merchant event over a NIP-44-encrypted RPC channel).
  4. Don't kneecap the shop UX. Requiring the user's bunker to be
    online for every order event would be a regression — see the
    NIP-26 escape valve below.

Current state — inventory

Sites that read the plaintext nsec today

File Site What
models.py:55 Merchant.private_key: str Pydantic field
models.py:65 sign_message_hash(self.private_key, hash_) Hot path — called from every Merchant.sign_hash
helpers.py:5–6 sign_message_hash impl Instantiates coincurve.PrivateKey(bytes.fromhex(private_key))
crud.py:29–35 INSERT … private_key … Merchant create
crud.py:59–69 UPDATE … SET private_key = :private_key Merchant update
migrations.py:10 private_key TEXT NOT NULL Schema
services.py:188–214 provision_merchant(private_key=…, …) Core's old auto-provision path

Already-aligned (no change needed)

  • The pubkey column already duplicates merchants.public_key
    the merchant identity IS already a nostr pubkey. So the schema is
    ready to support a server-with-no-nsec signing posture; the row
    just needs a signer_type + signer_config instead of a raw
    private_key.

Proposed architecture

Land in three phases. Each phase is shippable and reduces the attack
surface; later phases are optional improvements on the strict-correctness
floor that phase A provides.

Phase A — Envelope-encrypt the existing column

Smallest blast radius. Default for backward compat.

  • Use core's lnbits.core.services.key_encryption.encrypt_blob /
    decrypt_blob_str (the same envelope shape that lnbits user nsecs
    ride in — ChaCha20-Poly1305, per-row data key wrapped under
    LNBITS_KEY_MASTER). One dep, already in the runtime.
  • Schema: rename private_key TEXT NOT NULL
    signer_blob TEXT NOT NULL (or add a new column and drop the old
    in a follow-up). The blob holds the encrypted nsec; the cleartext
    exists only inside sign_hash for the duration of one signature.
  • helpers.sign_message_hash(private_key, hash_) becomes
    sign_message_hash(signer_blob, hash_) — decrypts on demand, signs,
    drops the cleartext from scope.
  • provision_merchant accepts a plaintext nsec at the API boundary,
    encrypts immediately, NEVER persists cleartext. (The core's
    _create_default_merchant won't pass plaintext — see phase B/C —
    but the migration path needs this for in-place conversion of
    existing rows.)
  • Data migration: m00N_encrypt_existing_merchant_nsecs
    idempotent fork-migration that encrypts every row's
    private_key into the new column and NULLs out the legacy.
    Same shape as core's signer_migration.py:classify_unmigrated_accounts.

After phase A: the failure mode of a DB dump is reduced from
"plaintext nsec for every merchant" to "ciphertext that requires the
master key to recover" — same posture as lnbits user nsecs after #9
phase 1.

Phase B — LocalSigner parity via core's abstraction

Replace the extension-local helper with a call into core. Each
Merchant instance resolves a LocalSigner from its own
signer_blob (or, for the merchants whose account-side signer_type
is LocalSigner, defers to the core account's signer entirely).

Two sub-cases worth covering:

  • Merchant account is a LocalSigner in core. The merchant table
    doesn't need its own signer config at all — sign via
    resolve_signer(account).sign_event(event). Single source of truth
    for the nsec.
  • Merchant account is a ClientSideOnlySigner in core (NIP-98
    login user; nostr-transport auto-create user). Server has no nsec
    for them. Today's plaintext path can't work here either; phase A's
    envelope-encrypted column also can't help (the server never had a
    cleartext to encrypt). Falls through to phase C.

Phase C — NIP-46 bunker + NIP-26 delegation

The sovereignty story. Two variants depending on shop throughput.

Variant 1 — every event hits the bunker (NIP-46).

  • Merchant's signer_type = RemoteBunkerSigner. Server holds a local
    ephemeral keypair (transport identity for the bunker channel), the
    user's pubkey, and the bunker's pubkey + relay list. NO server-held
    nsec.
  • Every kind 30030/30031/4 publish: server constructs the unsigned
    event, encrypts a sign_event RPC request under the ephemeral key,
    fires it to the bunker's relay, awaits the signed event over the
    same channel, broadcasts the signed event to merchant relays.
  • Latency: ~1s overhead per event under good conditions (one relay
    round-trip). For low-volume merchants (couple of orders/day) this
    is fine. For high-throughput shops it could noticeably delay
    customer-facing UI updates.
  • Failure mode: bunker offline → can't publish. Merchant must be
    available, or the user must run a self-hosted bunker (nak bunker,
    Promenade, etc.).

Variant 2 — NIP-26 delegation (recommended default for shops).

  • One-time setup: user's bunker signs a NIP-26 delegation token
    granting the server (or a server-held subordinate key) the ability
    to publish events of kinds 30030, 30031, 4 — scoped with an
    expiry (e.g. 30 days, renewable).
  • The delegation key is held server-side under the standard envelope
    encryption from phase A. The cleartext nsec is never server-
    held, only this delegated subordinate.
  • Every merchant event includes the delegation tag per NIP-26
    semantics — clients verify the signing key was authorized by the
    user's master nostr identity.
  • Latency: zero overhead per event vs. today's path. Bunker only has
    to be online at delegation renewal time.
  • Revocation: user revokes the delegation on the bunker side at any
    point. Server's signing capability vanishes the moment the token
    expires.
  • Failure mode: delegation expires unrenewed → server falls back to
    variant 1 (or fails closed, configurable per merchant).

The natural product offering after phase C: a merchant settings page
showing "who signs your shop events" with three radio options —
"Server (encrypted at rest, fastest)", "My bunker via delegation (no
server key, fast)", "My bunker for every event (no server key, slower)".
Each row is a signer_type enum value; no fundamental architecture
swap to flip.

Migration plan

Concrete order I'd ship in:

  1. Phase A schema + data migrationmigrations_fork.py m00N
    adds signer_blob TEXT column; one-shot startup job encrypts every
    existing row's private_key and clears the legacy column. Phase A
    passes its own acceptance bar without depending on the others.
  2. Phase B refactorMerchant.sign_hash reads signer_blob
    instead of private_key; helpers.sign_message_hash either
    accepts a blob and decrypts internally, or is replaced entirely by
    resolve_signer(account).sign_event(event) when we want to
    collapse onto the core abstraction. Tests + smoke on a dev-tier
    host.
  3. Re-enable _create_default_merchant on the lnbits core side
    (the disabled function in lnbits/core/services/users.py whose
    stub was added in aiolabs/lnbits#17). Auto-provision now passes
    the user's nsec encrypted, NOT plaintext. Coordinate the deploy
    so the lnbits side bumps to a version that includes the re-enable.
  4. Phase C variant 2 (NIP-26 delegation) — bigger lift, can land
    later behind a signer_type='Delegated' flag.
  5. Phase C variant 1 (NIP-46 every-event) — last, since the
    throughput trade-off limits its applicability; ship for users who
    actively choose it.

Out of scope (intentional)

  • Migrating the encrypted-merchant-nsec storage to a different
    cryptosystem (HSM, KMS, hardware-backed wrapping). Same wrapping
    primitives as lnbits #9 — a single rotation story across the whole
    stack.
  • Multi-merchant-per-user support beyond what nostrmarket already
    does. Each merchant row gets its own signer_blob; the per-user
    uniqueness story is unchanged.
  • Frontend UX for the "who signs your shop" settings page — design
    separately once the backend supports the radio options.

Acceptance

  • No row in nostrmarket.merchants contains a plaintext nsec.
  • helpers.sign_message_hash is either removed or only sees
    cleartext nsecs for the duration of a single signature (no
    long-lived references / no in-memory caching across signs).
  • provision_merchant no longer accepts a plaintext nsec at the
    API boundary OR accepts it only to encrypt-and-discard within the
    same function.
  • Migration runs cleanly against a production DB snapshot with
    existing private_key values.
  • _create_default_merchant is re-enabled on the lnbits core
    side and lands rows that round-trip through resolve_signer → signer.sign_event end-to-end on a dev-tier host.
  • (Phase C, optional) signer_type='Delegated' flow works
    end-to-end against nak bunker and nsec.app.

Cross-references

  • aiolabs/lnbits#9 — parent: user nsec hardening + signer
    abstraction in core.
  • aiolabs/lnbits#17 — PR landing phase 1 of #9 (the abstraction this
    extension will integrate against). Includes the deliberately
    disabled _create_default_merchant stub that re-enables once this
    extension migrates.
  • aiolabs/lnbits#8 — extension migrations_fork pattern (the schema
    migration shape this issue's phase A uses).
  • NIP-46 — connecting clients without sharing private keys.
  • NIP-26 — delegated event signing.
## Problem `nostrmarket.merchants.private_key TEXT NOT NULL` stores the merchant's Nostr nsec as plaintext. Every kind 30030/30031/4 event the relay listener publishes calls `sign_message_hash(self.private_key, hash_)` (`models.py:65`), which instantiates `coincurve.PrivateKey` from the plaintext column on every publish. A DB dump or any read access to the `merchants` table is a wholesale compromise of every merchant's identity — exactly the same shape of failure that `aiolabs/lnbits#9` is closing on the core `accounts` table. `aiolabs/lnbits#9 phase 1` (now in flight via PR `aiolabs/lnbits#17`) lands the `NostrSigner` abstraction in core. The core `_create_default_merchant` auto-provision path is **disabled in phase 1** for exactly this reason — it cannot in good conscience copy the user's encrypted-at-rest nsec out of `signer_config` into a plaintext column in this extension. Re-enabling it depends on this extension migrating onto the same abstraction. That's what this issue tracks. ## Goals 1. **Stop persisting plaintext nsec in `nostrmarket.merchants`.** End state: the column either holds an envelope-encrypted blob, or it doesn't exist at all (the merchant's signing identity is resolved from the core account's `signer_type` + `signer_config`). 2. **Route every signing call through the core `NostrSigner` ABC** instead of a local `sign_message_hash(plaintext_hex, …)` helper. 3. **Add NIP-46 bunker support** so sovereignty-minded merchants can keep their nsec entirely off the server (the user's bunker signs each merchant event over a NIP-44-encrypted RPC channel). 4. **Don't kneecap the shop UX.** Requiring the user's bunker to be online for every order event would be a regression — see the NIP-26 escape valve below. ## Current state — inventory ### Sites that read the plaintext nsec today | File | Site | What | |---|---|---| | `models.py:55` | `Merchant.private_key: str` | Pydantic field | | `models.py:65` | `sign_message_hash(self.private_key, hash_)` | Hot path — called from every `Merchant.sign_hash` | | `helpers.py:5–6` | `sign_message_hash` impl | Instantiates `coincurve.PrivateKey(bytes.fromhex(private_key))` | | `crud.py:29–35` | `INSERT … private_key …` | Merchant create | | `crud.py:59–69` | `UPDATE … SET private_key = :private_key` | Merchant update | | `migrations.py:10` | `private_key TEXT NOT NULL` | Schema | | `services.py:188–214` | `provision_merchant(private_key=…, …)` | Core's old auto-provision path | ### Already-aligned (no change needed) - The `pubkey` column already duplicates `merchants.public_key` — the merchant identity IS already a nostr pubkey. So the schema is ready to support a server-with-no-nsec signing posture; the row just needs a `signer_type` + `signer_config` instead of a raw `private_key`. ## Proposed architecture Land in three phases. Each phase is shippable and reduces the attack surface; later phases are optional improvements on the strict-correctness floor that phase A provides. ### Phase A — Envelope-encrypt the existing column **Smallest blast radius. Default for backward compat.** - Use core's `lnbits.core.services.key_encryption.encrypt_blob` / `decrypt_blob_str` (the same envelope shape that lnbits user nsecs ride in — ChaCha20-Poly1305, per-row data key wrapped under `LNBITS_KEY_MASTER`). One dep, already in the runtime. - Schema: rename `private_key TEXT NOT NULL` → `signer_blob TEXT NOT NULL` (or add a new column and drop the old in a follow-up). The blob holds the encrypted nsec; the cleartext exists only inside `sign_hash` for the duration of one signature. - `helpers.sign_message_hash(private_key, hash_)` becomes `sign_message_hash(signer_blob, hash_)` — decrypts on demand, signs, drops the cleartext from scope. - `provision_merchant` accepts a plaintext nsec at the API boundary, encrypts immediately, NEVER persists cleartext. (The core's `_create_default_merchant` won't pass plaintext — see phase B/C — but the migration path needs this for in-place conversion of existing rows.) - Data migration: `m00N_encrypt_existing_merchant_nsecs` — idempotent fork-migration that encrypts every row's `private_key` into the new column and NULLs out the legacy. Same shape as core's `signer_migration.py:classify_unmigrated_accounts`. After phase A: the failure mode of a DB dump is reduced from "plaintext nsec for every merchant" to "ciphertext that requires the master key to recover" — same posture as lnbits user nsecs after #9 phase 1. ### Phase B — `LocalSigner` parity via core's abstraction Replace the extension-local helper with a call into core. Each `Merchant` instance resolves a `LocalSigner` from its own `signer_blob` (or, for the merchants whose account-side `signer_type` is `LocalSigner`, defers to the core account's signer entirely). Two sub-cases worth covering: - **Merchant account is a `LocalSigner` in core.** The merchant table doesn't need its own signer config at all — sign via `resolve_signer(account).sign_event(event)`. Single source of truth for the nsec. - **Merchant account is a `ClientSideOnlySigner` in core** (NIP-98 login user; nostr-transport auto-create user). Server has no nsec for them. Today's plaintext path can't work here either; phase A's envelope-encrypted column also can't help (the server never had a cleartext to encrypt). Falls through to phase C. ### Phase C — NIP-46 bunker + NIP-26 delegation The sovereignty story. Two variants depending on shop throughput. **Variant 1 — every event hits the bunker (NIP-46).** - Merchant's `signer_type = RemoteBunkerSigner`. Server holds a local ephemeral keypair (transport identity for the bunker channel), the user's pubkey, and the bunker's pubkey + relay list. NO server-held nsec. - Every kind 30030/30031/4 publish: server constructs the unsigned event, encrypts a `sign_event` RPC request under the ephemeral key, fires it to the bunker's relay, awaits the signed event over the same channel, broadcasts the signed event to merchant relays. - Latency: ~1s overhead per event under good conditions (one relay round-trip). For low-volume merchants (couple of orders/day) this is fine. For high-throughput shops it could noticeably delay customer-facing UI updates. - Failure mode: bunker offline → can't publish. Merchant must be available, or the user must run a self-hosted bunker (`nak bunker`, Promenade, etc.). **Variant 2 — NIP-26 delegation (recommended default for shops).** - One-time setup: user's bunker signs a NIP-26 delegation token granting the server (or a server-held subordinate key) the ability to publish events of kinds 30030, 30031, 4 — scoped with an expiry (e.g. 30 days, renewable). - The delegation key is held server-side under the standard envelope encryption from phase A. The cleartext nsec is **never** server- held, only this delegated subordinate. - Every merchant event includes the `delegation` tag per NIP-26 semantics — clients verify the signing key was authorized by the user's master nostr identity. - Latency: zero overhead per event vs. today's path. Bunker only has to be online at delegation renewal time. - Revocation: user revokes the delegation on the bunker side at any point. Server's signing capability vanishes the moment the token expires. - Failure mode: delegation expires unrenewed → server falls back to variant 1 (or fails closed, configurable per merchant). The natural product offering after phase C: a merchant settings page showing **"who signs your shop events"** with three radio options — "Server (encrypted at rest, fastest)", "My bunker via delegation (no server key, fast)", "My bunker for every event (no server key, slower)". Each row is a `signer_type` enum value; no fundamental architecture swap to flip. ## Migration plan Concrete order I'd ship in: 1. **Phase A schema + data migration** — `migrations_fork.py m00N` adds `signer_blob TEXT` column; one-shot startup job encrypts every existing row's `private_key` and clears the legacy column. Phase A passes its own acceptance bar without depending on the others. 2. **Phase B refactor** — `Merchant.sign_hash` reads `signer_blob` instead of `private_key`; `helpers.sign_message_hash` either accepts a blob and decrypts internally, or is replaced entirely by `resolve_signer(account).sign_event(event)` when we want to collapse onto the core abstraction. Tests + smoke on a dev-tier host. 3. **Re-enable `_create_default_merchant` on the lnbits core side** (the disabled function in `lnbits/core/services/users.py` whose stub was added in `aiolabs/lnbits#17`). Auto-provision now passes the user's nsec encrypted, NOT plaintext. Coordinate the deploy so the lnbits side bumps to a version that includes the re-enable. 4. **Phase C variant 2 (NIP-26 delegation)** — bigger lift, can land later behind a `signer_type='Delegated'` flag. 5. **Phase C variant 1 (NIP-46 every-event)** — last, since the throughput trade-off limits its applicability; ship for users who actively choose it. ## Out of scope (intentional) - Migrating the encrypted-merchant-nsec storage to a different cryptosystem (HSM, KMS, hardware-backed wrapping). Same wrapping primitives as lnbits #9 — a single rotation story across the whole stack. - Multi-merchant-per-user support beyond what nostrmarket already does. Each merchant row gets its own `signer_blob`; the per-user uniqueness story is unchanged. - Frontend UX for the "who signs your shop" settings page — design separately once the backend supports the radio options. ## Acceptance - [ ] No row in `nostrmarket.merchants` contains a plaintext nsec. - [ ] `helpers.sign_message_hash` is either removed or only sees cleartext nsecs for the duration of a single signature (no long-lived references / no in-memory caching across signs). - [ ] `provision_merchant` no longer accepts a plaintext nsec at the API boundary OR accepts it only to encrypt-and-discard within the same function. - [ ] Migration runs cleanly against a production DB snapshot with existing `private_key` values. - [ ] `_create_default_merchant` is re-enabled on the lnbits core side and lands rows that round-trip through `resolve_signer → signer.sign_event` end-to-end on a dev-tier host. - [ ] (Phase C, optional) `signer_type='Delegated'` flow works end-to-end against `nak bunker` and nsec.app. ## Cross-references - `aiolabs/lnbits#9` — parent: user nsec hardening + signer abstraction in core. - `aiolabs/lnbits#17` — PR landing phase 1 of #9 (the abstraction this extension will integrate against). Includes the deliberately disabled `_create_default_merchant` stub that re-enables once this extension migrates. - `aiolabs/lnbits#8` — extension `migrations_fork` pattern (the schema migration shape this issue's phase A uses). - NIP-46 — connecting clients without sharing private keys. - NIP-26 — delegated event signing.
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/nostrmarket#5
No description provided.