Migrate merchant signing off plaintext nsec — integrate with the lnbits #9 signer abstraction #5
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?
Problem
nostrmarket.merchants.private_key TEXT NOT NULLstores the merchant'sNostr 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 instantiatescoincurve.PrivateKeyfrom theplaintext column on every publish. A DB dump or any read access to
the
merchantstable is a wholesale compromise of every merchant'sidentity — exactly the same shape of failure that
aiolabs/lnbits#9is closing on the core
accountstable.aiolabs/lnbits#9 phase 1(now in flight via PRaiolabs/lnbits#17) lands theNostrSignerabstraction in core. Thecore
_create_default_merchantauto-provision path is disabled inphase 1 for exactly this reason — it cannot in good conscience copy
the user's encrypted-at-rest nsec out of
signer_configinto aplaintext column in this extension. Re-enabling it depends on this
extension migrating onto the same abstraction. That's what this issue
tracks.
Goals
nostrmarket.merchants. Endstate: 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).NostrSignerABCinstead of a local
sign_message_hash(plaintext_hex, …)helper.keep their nsec entirely off the server (the user's bunker signs
each merchant event over a NIP-44-encrypted RPC channel).
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
models.py:55Merchant.private_key: strmodels.py:65sign_message_hash(self.private_key, hash_)Merchant.sign_hashhelpers.py:5–6sign_message_hashimplcoincurve.PrivateKey(bytes.fromhex(private_key))crud.py:29–35INSERT … private_key …crud.py:59–69UPDATE … SET private_key = :private_keymigrations.py:10private_key TEXT NOT NULLservices.py:188–214provision_merchant(private_key=…, …)Already-aligned (no change needed)
pubkeycolumn already duplicatesmerchants.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_configinstead of a rawprivate_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.
lnbits.core.services.key_encryption.encrypt_blob/decrypt_blob_str(the same envelope shape that lnbits user nsecsride in — ChaCha20-Poly1305, per-row data key wrapped under
LNBITS_KEY_MASTER). One dep, already in the runtime.private_key TEXT NOT NULL→signer_blob TEXT NOT NULL(or add a new column and drop the oldin a follow-up). The blob holds the encrypted nsec; the cleartext
exists only inside
sign_hashfor the duration of one signature.helpers.sign_message_hash(private_key, hash_)becomessign_message_hash(signer_blob, hash_)— decrypts on demand, signs,drops the cleartext from scope.
provision_merchantaccepts a plaintext nsec at the API boundary,encrypts immediately, NEVER persists cleartext. (The core's
_create_default_merchantwon't pass plaintext — see phase B/C —but the migration path needs this for in-place conversion of
existing rows.)
m00N_encrypt_existing_merchant_nsecs—idempotent fork-migration that encrypts every row's
private_keyinto 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 —
LocalSignerparity via core's abstractionReplace the extension-local helper with a call into core. Each
Merchantinstance resolves aLocalSignerfrom its ownsigner_blob(or, for the merchants whose account-sidesigner_typeis
LocalSigner, defers to the core account's signer entirely).Two sub-cases worth covering:
LocalSignerin core. The merchant tabledoesn't need its own signer config at all — sign via
resolve_signer(account).sign_event(event). Single source of truthfor the nsec.
ClientSideOnlySignerin core (NIP-98login 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).
signer_type = RemoteBunkerSigner. Server holds a localephemeral keypair (transport identity for the bunker channel), the
user's pubkey, and the bunker's pubkey + relay list. NO server-held
nsec.
event, encrypts a
sign_eventRPC 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.
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.
available, or the user must run a self-hosted bunker (
nak bunker,Promenade, etc.).
Variant 2 — NIP-26 delegation (recommended default for shops).
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).
encryption from phase A. The cleartext nsec is never server-
held, only this delegated subordinate.
delegationtag per NIP-26semantics — clients verify the signing key was authorized by the
user's master nostr identity.
to be online at delegation renewal time.
point. Server's signing capability vanishes the moment the token
expires.
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_typeenum value; no fundamental architectureswap to flip.
Migration plan
Concrete order I'd ship in:
migrations_fork.py m00Nadds
signer_blob TEXTcolumn; one-shot startup job encrypts everyexisting row's
private_keyand clears the legacy column. Phase Apasses its own acceptance bar without depending on the others.
Merchant.sign_hashreadssigner_blobinstead of
private_key;helpers.sign_message_hasheitheraccepts a blob and decrypts internally, or is replaced entirely by
resolve_signer(account).sign_event(event)when we want tocollapse onto the core abstraction. Tests + smoke on a dev-tier
host.
_create_default_merchanton the lnbits core side(the disabled function in
lnbits/core/services/users.pywhosestub was added in
aiolabs/lnbits#17). Auto-provision now passesthe user's nsec encrypted, NOT plaintext. Coordinate the deploy
so the lnbits side bumps to a version that includes the re-enable.
later behind a
signer_type='Delegated'flag.throughput trade-off limits its applicability; ship for users who
actively choose it.
Out of scope (intentional)
cryptosystem (HSM, KMS, hardware-backed wrapping). Same wrapping
primitives as lnbits #9 — a single rotation story across the whole
stack.
does. Each merchant row gets its own
signer_blob; the per-useruniqueness story is unchanged.
separately once the backend supports the radio options.
Acceptance
nostrmarket.merchantscontains a plaintext nsec.helpers.sign_message_hashis either removed or only seescleartext nsecs for the duration of a single signature (no
long-lived references / no in-memory caching across signs).
provision_merchantno longer accepts a plaintext nsec at theAPI boundary OR accepts it only to encrypt-and-discard within the
same function.
existing
private_keyvalues._create_default_merchantis re-enabled on the lnbits coreside and lands rows that round-trip through
resolve_signer → signer.sign_eventend-to-end on a dev-tier host.signer_type='Delegated'flow worksend-to-end against
nak bunkerand nsec.app.Cross-references
aiolabs/lnbits#9— parent: user nsec hardening + signerabstraction in core.
aiolabs/lnbits#17— PR landing phase 1 of #9 (the abstraction thisextension will integrate against). Includes the deliberately
disabled
_create_default_merchantstub that re-enables once thisextension migrates.
aiolabs/lnbits#8— extensionmigrations_forkpattern (the schemamigration shape this issue's phase A uses).