Refresh checkpoint with NIP-46 approval gate

This commit is contained in:
Avi 2026-08-06 10:04:59 -05:00
commit da35af0271

View file

@ -1,4 +1,4 @@
# Checkpoint — NIP-46 remote signer (2026-08-05)
# Checkpoint — NIP-46 remote signer + approval gate (2026-08-06)
A stopping point you can return to if this session is closed. Everything below was
verified green at the moment this file was written.
@ -6,58 +6,74 @@ verified green at the moment this file was written.
## Where things are
- Project: `/home/avi/Projects/skills/nost-feed-manager`
- Git repo: `master` @ `73cb08d` ("Add NIP-46 remote signer (external signing)"), on top of
`13efee6` ("Refresh checkpoint with Forgejo README"), `3eb9fa9` ("Rewrite README for Forgejo
hosting"), and the history described in the previous checkpoint.
- Git repo: `master` @ `ca3203c` ("Require user approval for NIP-46 sign/encrypt requests"),
on top of `593fc6a` ("Refresh checkpoint with NIP-46 remote signer"), `73cb08d` ("Add NIP-46
remote signer (external signing)"), `13efee6` ("Refresh checkpoint with Forgejo README"),
`3eb9fa9` ("Rewrite README for Forgejo hosting"), and the history described in earlier
checkpoints.
- The working tree is **clean** — everything from this session is committed.
- The signer feature is split across one commit (`73cb08d`) that contains both the Rust backend
and the Electron/React frontend.
- The signer feature spans `73cb08d` (the signer itself) plus `ca3203c` (the approval gate).
## What was completed: NIP-46 remote signer (bunker role)
The app can now act as a **remote signer** (NIP-46, "bunker") for another Nostr app. Instead of
pasting your nsec into other apps, they send signing requests to this app, which approves them
with the active profile's keys. Decisions made this session: the connected peer is
**auto-approved** once the handshake succeeds, the signer runs **inside the `serve` process as a
concurrent tokio task** (sharing `Arc<Mutex<App>>`), and the frontend gets a **dedicated Signer
screen** as its own sidebar nav item.
with the active profile's keys. The signer runs **inside the `serve` process as a concurrent
tokio task** (sharing `Arc<Mutex<App>>`), and the frontend gets a **dedicated Signer screen** as
its own sidebar nav item.
**Security change this session (commit `ca3203c`): every key-using request now requires explicit
user approval.** The peer is still "trusted" after the handshake, but `sign_event`,
`nip44_encrypt`, and `nip44_decrypt` are no longer auto-approved. Each such request is parked in a
`pending` queue, exposed in `SignerStatus.pending`, and only runs after the user clicks
**Approve** on the Signer screen (or is answered with **Reject** / a 300-second timeout). All
non-key methods (`connect`, `get_public_key`, `ping`, `get_relays`, `switch_relays`, `logout`)
still run immediately.
- **Backend** (`src/signer.rs`): a hand-rolled NIP-46 implementation (the SDK has no ready-made
NIP-46 server). `Signer` exposes `status()`, `connect()`, `disconnect()`, `fail()`,
`set_connected()`. `parse_connect_uri` accepts `nostrconnect://` links (and clearly rejects the
opposite-role `bunker://` scheme). The async `run_sign_task` connects over the chosen relays,
listens for kind 24133 requests decrypted with a NIP-44 v2 conversation key, and answers
`get_public_key`, `sign_event`, and `nip44_encrypt`/`nip44_decrypt`. Responses are NIP-44-encrypted
and published back to the peer. Six unit tests cover URI parsing, bunker rejection, NIP-44
round-trip, get_public_key, sign_event, and unknown-method error responses.
- **IPC** (`src/ipc.rs`): `serve`/`handle`/`run` now share `Arc<Mutex<App>>`; new variants
`SignerConnect { uri }`, `SignerDisconnect`, `SignerStatus`. The connect/disconnect/status
requests are serviced by `run()` before the (long-lived) vault lock so a slow signer task never
blocks ordinary commands.
- **CLI** (`src/main.rs`): `signer status` and `signer connect <uri>`.
- **Electron/React frontend**: new **Signer screen** (sidebar nav item with the key icon) where you
paste a `nostrconnect://` link, Connect/Disconnect, and see the phase (Not connected /
Connecting… / Connected), the connected client's hex pubkey (shortened), the relays in use, and
any error. Wired through `api.ts` (`signerConnect`/`signerDisconnect`/`signerStatus`) and
`AppProvider`. When the vault is locked, a warning explains the signer needs an unlocked vault.
Tests: `SignerScreen.test.tsx` (new) + `apiMock.ts`/`fakeBackend.ts` signer methods.
`set_connected()`, `approve()`, and `await_approval()`. `parse_connect_uri` accepts
`nostrconnect://` links (and clearly rejects the opposite-role `bunker://` scheme). The async
`run_sign_task` connects over the chosen relays, listens for kind 24133 requests decrypted with
a NIP-44 v2 conversation key, and answers `get_public_key`, `sign_event`, and
`nip44_encrypt`/`nip44_decrypt`. Responses are NIP-44-encrypted and published back to the peer.
Key-using methods route through `gated_response``await_approval` (a `HashMap` of pending
entries, each with a `oneshot` decision channel) → `approved_response`, while everything else
flows through the non-blocking `handle_request` dispatcher. The pending list clears on
disconnect and on failure, so a slow/abandoned approval never leaks into a new connection.
Unit tests cover URI parsing, bunker rejection, NIP-44 round-trip, get_public_key, sign_event,
unknown-method, and the approval gate (wait-then-sign, reject-never-uses-key, unknown-id error).
- **IPC** (`src/ipc.rs`): `serve`/`handle`/`run` share `Arc<Mutex<App>>`; variants
`SignerConnect { uri }`, `SignerDisconnect`, `SignerStatus`, and the new `SignerApprove { id,
approved }`. The signer-status/approve requests are serviced by `run()` before the (long-lived)
vault lock so a slow signer task never blocks ordinary commands.
- **CLI** (`src/main.rs`): `signer status` and `signer connect <uri>`; `signer status` notes that
approvals happen in the GUI (the CLI's blocking `connect` loop cannot answer approvals itself).
- **Electron/React frontend**: the **Signer screen** (sidebar nav item with the key icon) where you
paste a `nostrconnect://` link, **Connect**/**Disconnect**, see the phase, peer, relays, and any
error, and now review pending requests with **Approve**/**Reject** buttons. The screen polls
`signer_status` every second while mounted so approval requests appear without a manual
refresh. Wired through `api.ts` (`signerConnect`/`signerDisconnect`/`signerStatus`/
`signerApprove`) and `AppProvider`.
## Commits this session (newest first)
- `73cb08d` "Add NIP-46 remote signer (external signing)" — everything above in one commit.
Full verification suite re-run green (below).
- `ca3203c` "Require user approval for NIP-46 sign/encrypt requests" — the approval gate
(backend pending queue + `SignerApprove` IPC + Signer screen Approve/Reject + tests).
- `593fc6a` "Refresh checkpoint with NIP-46 remote signer" — checkpoint refresh (previous session).
- `73cb08d` "Add NIP-46 remote signer (external signing)" — the signer feature itself.
## How it was verified (all green)
```
cargo test # 70 passed
cargo test # 75 passed
cargo clippy --all-targets # clean
cargo fmt --check # clean
cargo build --release # builds (rebuilt so the GUI runs the new backend)
npm run typecheck # clean (frontend/)
npm run lint # clean (pre-existing module warning only)
npm run format:check # clean
npm test # 69 passed (13 files)
npm test # 71 passed (13 files)
npm run electron:build # compiles the Electron main process
npm run build # rebuilds the React bundle (dist/)
```
@ -69,19 +85,23 @@ a `bunker://` link and a non-`nostrconnect` URI were both rejected with clear er
1. Open the repo: `cd /home/avi/Projects/skills/nost-feed-manager`
2. State is committed: `git status` should be clean; `git log --oneline -5` shows
`73cb08d` at the top.
`ca3203c` at the top.
3. To try the signer:
- GUI: `cd frontend && npm start`, then the **Signer** screen in the sidebar. Paste a
`nostrconnect://` link from a Nostr app to sign for it. (Note: `npm start` alone does NOT
rebuild the React bundle — run `npm run build` first, or use `npm run dev` +
`NOSTR_GUI_DEV_URL=http://localhost:5173 npm start` for live reload.)
`nostrconnect://` link from a Nostr app to sign for it. When the app sends a sign/decrypt
request it appears under "Requests waiting for approval" — click **Approve** or **Reject**.
(Note: `npm start` alone does NOT rebuild the React bundle — run `npm run build` first, or
use `npm run dev` + `NOSTR_GUI_DEV_URL=http://localhost:5173 npm start` for live reload.)
- CLI: `cargo build --release`, then `./target/release/nostr-manager-backend signer status`.
Note: the CLI's blocking `signer connect` loop cannot answer approval prompts — run the GUI
for that.
4. Re-run verification with the commands above.
## Outstanding / next steps (if you continue)
- Nothing uncommitted. Possible follow-ups: a real feed view (currently Home only shows the last
publication); a NIP-46 **client** role (initiating `bunker://` from this app is intentionally
not implemented — only the signer role is). Live cross-client verification against an actual
Nostr app (e.g. a NIP-46-compatible client that produces a `nostrconnect://` link) has not yet
been done end-to-end, only the unit and CLI smoke tests.
not implemented — only the signer role is); a CLI-side approval escape hatch for `signer
connect` (prompt on stdin) so approvals also work headlessly. Live cross-client verification
against an actual Nostr app (e.g. a NIP-46-compatible client that produces a `nostrconnect://`
link) has not yet been done end-to-end, only the unit and CLI smoke tests.