Compare commits

..

No commits in common. "704addc773c9f573118b7f9777be20c73d142f5b" and "1d5940fb820f51f37e6d200d8a48f4edf1fc62d4" have entirely different histories.

45 changed files with 585 additions and 7379 deletions

5
.gitignore vendored
View file

@ -6,11 +6,6 @@ profiles_vault.json
profiles_vault.json.backup-*
*.json.tmp
# Editor / tool artifacts
.directory
.opencode/
.impeccable/
# Frontend
frontend/node_modules/
frontend/dist/

File diff suppressed because it is too large Load diff

3
Cargo.lock generated
View file

@ -1169,7 +1169,6 @@ dependencies = [
"argon2",
"async-trait",
"base64",
"futures-util",
"getrandom 0.2.17",
"hex",
"keyring",
@ -1180,7 +1179,6 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"tokio",
"tokio-tungstenite",
"uuid",
"zeroize",
]
@ -1283,7 +1281,6 @@ version = "0.45.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0ba32ce43631188586469ba1a4c40bcfa63641f1ad5de89ef77f74d801cc17a"
dependencies = [
"aes 0.8.4",
"base64",
"bech32",
"bip39",

View file

@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
nostr = { version = "0.45", features = ["nip44", "nip46", "nip98"] }
nostr = { version = "0.45", features = ["nip44", "nip98"] }
nostr-sdk = "0.45"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
@ -20,11 +20,3 @@ rpassword = "7"
sha2 = "0.10"
async-trait = "0.1"
keyring = "4.2"
futures-util = "0.3"
[dev-dependencies]
base64 = "0.22"
futures-util = "0.3"
getrandom = "0.2"
nostr = "0.45"
tokio-tungstenite = "0.28"

View file

@ -109,7 +109,7 @@ components:
**Creative North Star: "Vault & Atelier"**
Nostr Keynctr is an atelier, not a dashboard — a warm, quiet workshop where identity work is done with care. The space feels like heavy paper and soft stone, with ink that is near-black, not pure black. Instruments are laid out plainly; nothing shouts for attention. Trust is built through precision: consistent edges, settled type, and state that is always legible. The product truth — in local modes keys never leave Rust, and in external-signer mode they never arrive on this machine at all — is mirrored visually: the UI is restrained, the material is honest, and every destructive or security-relevant moment is given deliberate weight.
Nostr Keynctr is an atelier, not a dashboard — a warm, quiet workshop where identity work is done with care. The space feels like heavy paper and soft stone, with ink that is near-black, not pure black. Instruments are laid out plainly; nothing shouts for attention. Trust is built through precision: consistent edges, settled type, and state that is always legible. The product truth — keys never leave Rust — is mirrored visually: the UI is restrained, the material is honest, and every destructive or security-relevant moment is given deliberate weight.
The aesthetic is *warm and human*, not technical or bold. Density is Operate: scannable lists, clear hierarchies, and generous but not loose spacing (8/12/16/20/32). The four themes (light, dark, glass/Aurora, neon) share the same semantic roles; only the material values shift. Neon and glass are gated expressions, never the default.

View file

@ -11,10 +11,10 @@ Primary: Linux Nostr users (daily use) who manage one or more keypairs and need
Secondary/expanded: Newcomers creating their first Nostr identity via a friendly GUI. The product is progressive — zero-to-first-profile onboarding is frictionless, but the same vault scales to power workflows (multiple profiles, CLI, remote signer). Success means the user can create, select, and publish as any profile, keep keys encrypted at rest, and never feel forced to paste an `nsec` elsewhere.
## Product Purpose
Nostr Keynctr (Nostr Feed Manager) pairs a hardened Rust core with an Electron + React desktop shell so private keys stay under your control: in embedded/bunker modes they live only in the local encrypted vault, and in external-signer mode they never touch this machine at all. It makes self-custodied Nostr publishing practical: generate/switch profiles, compose with preview and rich attachments, publish with per-relay receipts, curate relays and feeds, and serve as a NIP-46 remote signer ("bunker") for other Nostr apps. Success is a trustworthy, local-first identity manager you can use daily, via GUI or the same Rust CLI.
Nostr Keynctr (Nostr Feed Manager) pairs a hardened Rust core with an Electron + React desktop shell so private keys never leave the machine. It makes self-custodied Nostr publishing practical: generate/switch profiles, compose with preview and rich attachments, publish with per-relay receipts, curate relays and feeds, and serve as a NIP-46 remote signer ("bunker") for other Nostr apps. Success is a trustworthy, local-first identity manager you can use daily, via GUI or the same Rust CLI.
## Positioning
**Keys under your control, in whichever mode you choose — and the architecture proves it.** In embedded/bunker modes the renderer never receives secret material: all key generation, signing, relay communication, and encryption happen inside the Rust backend over a JSON-lines IPC channel, with NIP-46 approval gating every external sign/decrypt request. In external-signer mode (Amber, hardware signer, remote bunker) no secret key is present on this machine at all — a stronger posture when the desktop itself is the thing you distrust, since a compromise of this machine cannot extract a key it never held. A neighboring app could copy features, but cannot truthfully copy this verifiable separation while offering the same dual CLI + GUI surface.
**Keys never leave the machine — and the architecture proves it.** The renderer never receives secret material; all key generation, signing, relay communication, and encryption happen inside the Rust backend over a JSON-lines IPC channel, with NIP-46 approval gating every external sign/decrypt request. A neighboring app could copy features, but cannot truthfully copy this verifiable separation while offering the same dual CLI + GUI surface.
## Operating Context
Workflows: create/switch/delete/undo profiles, compose (Write/Preview, character count, up to 3 link previews, image pick → nostr.build upload with NIP-92 imeta), publish with per-relay receipts, feed aggregation (all vs. My contacts, 24h window), relay add/remove/enable/disable/test, NIP-05 assignment, secret reveal after unlock, vault backup, lock/unlock.
@ -37,7 +37,7 @@ Name: Nostr Keynctr / Nostr Feed Manager (early beta v0.1.0, MIT, Forgejo-hosted
Real content: Rust crate (`src/app, vault, crypto, profiles, publish, relays, signer, feed`), React screens (`Compose, Home, Profiles, Relays, Settings, Signer`), `frontend/src/lib/types`, `AppProvider` context, `fakeBackend` Vitest suite (99 tests), `CHECKPOINT-encryption.md`. No marketing site or pricing; no external testimonials to preserve.
## Product Principles
1. **Keys under your control** — every feature must preserve the Rust/renderer boundary and approval gates (secrets never reach the GUI in local modes, and never reach this machine in external-signer mode); convenience never bypasses explicit consent.
1. **Keys never leave** — every feature must preserve the Rust/renderer boundary and approval gates; convenience never bypasses explicit consent.
2. **Local-first, verifiable** — encrypt at rest, least-privilege files, per-relay receipts, and auditable IPC over transient convenience.
3. **Progressive disclosure** — newcomer can succeed in two clicks; power user can stay in CLI or manage many profiles without UI churn.
4. **One core, two doors** — GUI and CLI remain interchangeable via the same Rust engine; no feature lives only in one surface without justification.

View file

@ -1,8 +1,7 @@
# Nostr Feed Manager
> A friendly Linux desktop app for managing Nostr profiles, publishing notes, and acting as a
> **NIP-46 remote signer** — your private keys stay under your control: encrypted in a local
> vault, or, when you connect an external signer, held only on that device.
> **NIP-46 remote signer** — all while your private keys never leave your machine.
[![Version](https://img.shields.io/badge/version-0.1.0-blue)]()
[![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](LICENSE)
@ -259,13 +258,8 @@ Your keys are the crown jewels in any Nostr app, and nothing here compromises th
(`set-password`, or Settings → Storage). Once set, every secret key is encrypted with
**AES-256-GCM** under a key derived from your password with **Argon2id**. Labels and public keys
remain readable so you can browse profiles while the vault is locked.
- **In-memory key only.** You unlock once per session; the derived key lives only in memory and
is never written to disk.
- **External signer mode can be *more* secure.** The Signer screen can also use a NIP-46 signer
located elsewhere (Amber on your phone, a hardware-backed signer, a bunker you host). In that
mode no secret key exists on this desktop at all — signing happens on the signer device, so a
compromise of this machine cannot expose the key. "Keys never leave the machine" describes
embedded and bunker modes; in external mode the key never *arrives* on this machine.
- **In-memory key only.** You unlock once per session; the derived key lives only in memory and is
never written to disk.
- **Approve-before-any-signing.** The NIP-46 remote signer will not sign, encrypt, or decrypt
until you explicitly approve each request.
- **Least-privileged storage.** Files are written with directories `0700` and files `0600`.

View file

@ -524,15 +524,12 @@ const RENDERER_METHODS: ReadonlySet<string> = new Set([
'signer_disconnect',
'signer_status',
'signer_approve',
'signer_grants_list',
'signer_grant_revoke',
// New signer modes (default: nip46_client most secure)
'signer_mode_get',
'signer_mode_set',
'embedded_signer_status',
'embedded_signer_approve',
'nip46_connect',
'nip46_pair_start',
'nip46_disconnect',
'nip46_status',
'nip46_approve',

View file

@ -9,7 +9,6 @@
"version": "0.1.0",
"dependencies": {
"nostr-tools": "^2.25.1",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
@ -20,7 +19,6 @@
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^26.1.2",
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^6.1.0",
@ -1539,16 +1537,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
@ -2040,6 +2028,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -2049,6 +2038,7 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@ -2526,15 +2516,6 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@ -2627,6 +2608,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@ -2639,6 +2621,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
@ -2786,15 +2769,6 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@ -2924,12 +2898,6 @@
"license": "MIT",
"optional": true
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dir-compare": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz",
@ -3219,6 +3187,7 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/end-of-stream": {
@ -3792,6 +3761,7 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@ -4248,6 +4218,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -5279,15 +5250,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -5318,6 +5280,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -5431,15 +5394,6 @@
"node": ">=10.4.0"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
@ -5647,141 +5601,6 @@
"node": ">=16.0.0"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/quick-lru": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
@ -5874,6 +5693,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -5889,12 +5709,6 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resedit": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
@ -6122,12 +5936,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -6255,6 +6063,7 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@ -6269,6 +6078,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@ -6954,12 +6764,6 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",

View file

@ -28,7 +28,6 @@
},
"dependencies": {
"nostr-tools": "^2.25.1",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
@ -39,7 +38,6 @@
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^26.1.2",
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^6.1.0",

View file

@ -14,7 +14,6 @@ import { SignerScreen } from './screens/SignerScreen';
import { SignerModeScreen } from './screens/SignerModeScreen';
import { SettingsScreen } from './screens/SettingsScreen';
import { CreateProfileModal } from './screens/CreateProfileModal';
import { ImportProfileModal } from './screens/ImportProfileModal';
import { AppProvider, useApp, useThemeSync } from './state/AppProvider';
import type { Screen } from './lib/navigation';
@ -22,7 +21,6 @@ function Shell() {
const { state, loading, bootstrapError } = useApp();
const [screen, setScreen] = useState<Screen>('home');
const [createOpen, setCreateOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [unlockOpen, setUnlockOpen] = useState(false);
useThemeSync(state?.settings.theme);
@ -70,11 +68,7 @@ function Shell() {
</div>
)}
{screen === 'home' && (
<HomeScreen
onNavigate={setScreen}
onCreateProfile={() => setCreateOpen(true)}
onImportProfile={() => setImportOpen(true)}
/>
<HomeScreen onNavigate={setScreen} onCreateProfile={() => setCreateOpen(true)} />
)}
{screen === 'feed' && <FeedScreen onNavigate={setScreen} />}
{screen === 'profiles' && <ProfilesScreen onCreateProfile={() => setCreateOpen(true)} />}
@ -85,7 +79,6 @@ function Shell() {
{screen === 'settings' && <SettingsScreen />}
</main>
<CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} />
<ImportProfileModal open={importOpen} onClose={() => setImportOpen(false)} />
<UnlockModal open={unlockOpen} onClose={() => setUnlockOpen(false)} />
</div>
);

View file

@ -89,13 +89,9 @@ export function ExportSecretKeyModal({ open, onClose, profile }: ExportSecretKey
} else if (code === 'profile_not_found') {
setFatal({ message: 'That profile is not stored on this computer.' });
setPhase('error');
} else if (
code === 'external_signer_not_connected' ||
code === 'external_signer_identity_mismatch'
) {
} else if (code === 'external_signer_not_connected' || code === 'external_signer_identity_mismatch') {
setFatal({
message:
'This profile uses an external signer. Secret key export is not possible for externally managed accounts.',
message: 'This profile uses an external signer. Secret key export is not possible for externally managed accounts.',
});
setPhase('error');
} else {

View file

@ -12,7 +12,6 @@ import type {
RelayTestResult,
RevealedKey,
Settings,
SignerGrant,
SignerMode,
SignerStatus,
UpdateApplyReport,
@ -119,26 +118,17 @@ export const api = {
// NIP-46 client signer
nip46Connect: (uri: string, label: string) =>
call<Nip46SignerStatus>('nip46_connect', { uri, label }),
nip46PairStart: (label: string) => call<Nip46SignerStatus>('nip46_pair_start', { label }),
nip46Disconnect: () => call<Nip46SignerStatus>('nip46_disconnect'),
nip46Status: () => call<Nip46SignerStatus>('nip46_status'),
nip46Approve: (id: string, approved: boolean, always = false) =>
call<Nip46SignerStatus>('nip46_approve', { id, approved, always }),
nip46Approve: (id: string, approved: boolean) =>
call<Nip46SignerStatus>('nip46_approve', { id, approved }),
// Legacy NIP-46 bunker (deprecated, kept for compatibility)
signerConnect: (uri: string) => call<SignerStatus>('signer_connect', { uri }),
signerDisconnect: () => call<SignerStatus>('signer_disconnect'),
signerStatus: () => call<SignerStatus>('signer_status'),
signerApprove: (id: string, approved: boolean, always = false) =>
call<SignerStatus>('signer_approve', { id, approved, always }),
// Standing "always allow" grants for apps using us as their signer.
signerGrantsList: () => call<SignerGrant[]>('signer_grants_list'),
signerGrantRevoke: (appPubkey: string, grantMethod: string) =>
call<{ removed: boolean }>('signer_grant_revoke', {
app_pubkey: appPubkey,
grant_method: grantMethod,
}),
signerApprove: (id: string, approved: boolean) =>
call<SignerStatus>('signer_approve', { id, approved }),
deleteProfile: (npub: string) => call<AppState>('delete_profile', { npub }),
undoDelete: () => call<AppState>('undo_delete'),

View file

@ -322,21 +322,14 @@ export class SignerManager {
// ==================== CLIENT MODE (connect TO external signer) ====================
/** Parse nostrconnect:// or bunker:// URI from external signer (Amber, Nostr Connect, etc.) */
/** Parse nostrconnect:// URI from external signer (Amber, Nostr Connect, etc.) */
parseExternalSignerURI(uri: string): ExternalSignerConnection {
const isBunker = uri.startsWith('bunker://');
if (!uri.startsWith('nostrconnect://') && !isBunker) {
throw new SignerError(
'INVALID_NOSTRCONNECT_URI',
'URI must start with nostrconnect:// or bunker://',
);
if (!uri.startsWith('nostrconnect://')) {
throw new SignerError('INVALID_NOSTRCONNECT_URI', 'URI must start with nostrconnect://');
}
const withoutScheme = uri.slice(isBunker ? 'bunker://'.length : 'nostrconnect://'.length);
const [authorityRaw, queryString] = withoutScheme.split('?');
// bunker://<key>@<primary-relay>?relay=… carries a display relay in the
// authority; the key is what precedes the '@'.
const signerPubkey = authorityRaw.split('@')[0];
const [authority, queryString] = uri.slice('nostrconnect://'.length).split('?');
const signerPubkey = authority;
const params = new URLSearchParams(queryString || '');
const relays = params.getAll('relay');
const secret = params.get('secret') || undefined;

View file

@ -29,16 +29,6 @@ export interface PendingApproval {
details?: ApprovalDetails;
}
/** A standing "always allow" grant: one app may use one method without a
* prompt. Created by choosing "Always allow" on an approval; revoked from
* the Signer screen. */
export interface SignerGrant {
/** App's hex pubkey this grant applies to. */
app_pubkey: string;
/** NIP-46 method that runs without prompting (e.g. "sign_event"). */
method: string;
}
/** Non-secret snapshot of the NIP-46 remote signer for display. */
export interface SignerStatus {
phase: SignerPhase;
@ -73,8 +63,6 @@ export interface Nip46SignerStatus {
connected_relays: string[];
error?: string;
pending_approvals: PendingApproval[];
/** nostrconnect:// pairing token while a QR pairing is in flight. */
pairing_uri?: string;
}
/** Union of all signer statuses. */
@ -142,10 +130,6 @@ export interface FeedItem {
author: string;
/** Bech32 `npub` of the author, for display. */
author_npub: string;
/** Author display name from their latest kind-0, when one was found. */
author_name?: string | null;
/** Author picture URL from their latest kind-0, when one was found. */
author_picture?: string | null;
content: string;
/** Unix timestamp the note was created. */
created_at: number;

View file

@ -1,5 +1,4 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import QRCode from 'qrcode';
import { useApp } from '../state/AppProvider';
import { shortenNpub } from '../lib/format';
import { Button } from '../components/Button';
@ -12,15 +11,14 @@ interface CreateProfileModalProps {
onClose: () => void;
}
type Phase = 'choice' | 'pairing' | 'paired' | 'local' | 'creating' | 'success';
type Phase = 'form' | 'creating' | 'success';
export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
const { state, createProfile, nip46PairStart, nip46Status, nip46Disconnect, refresh } = useApp();
const { state, createProfile } = useApp();
const [label, setLabel] = useState('');
const [phase, setPhase] = useState<Phase>('choice');
const [phase, setPhase] = useState<Phase>('form');
const [error, setError] = useState<string | null>(null);
const [createdNpub, setCreatedNpub] = useState<string | null>(null);
const [pairingQr, setPairingQr] = useState<string | null>(null);
const [errorId] = useState(() => `create-profile-error-${Math.random().toString(36).slice(2)}`);
const inputRef = useRef<HTMLInputElement>(null);
const shorten = state?.settings.shorten_npub ?? true;
@ -28,88 +26,15 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
useEffect(() => {
if (open) {
setLabel('');
setPhase('choice');
setPhase('form');
setError(null);
setCreatedNpub(null);
setPairingQr(null);
return undefined;
}
return undefined;
}, [open]);
// Let the local-form input take focus once that step mounts.
useEffect(() => {
if (phase === 'local') {
// Let the modal mount before focusing.
const frame = requestAnimationFrame(() => inputRef.current?.focus());
return () => cancelAnimationFrame(frame);
}
return undefined;
}, [phase]);
// Pairing is a server-side handshake with no push channel: poll the
// signer status while this modal sits on the QR, exactly like the Signer
// Mode screen does. Connected → show success; an error → show it (the
// backend also clears pairing_uri, so the QR view exits on failure).
useEffect(() => {
if (phase !== 'pairing') return undefined;
let cancelled = false;
const tick = async () => {
try {
const status = await nip46Status();
if (cancelled) return;
if (status.connected) {
await refresh().catch(() => {});
if (!cancelled) setPhase('paired');
} else if (status.error) {
if (!cancelled) setError(status.error);
}
} catch {
// Transient IPC errors are fine; the next poll retries.
}
};
void tick();
const timer = setInterval(() => void tick(), 2000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [phase, nip46Status, refresh]);
const [livePairingUri, setLivePairingUri] = useState<string | null>(null);
useEffect(() => {
if (phase !== 'pairing' || livePairingUri) return undefined;
let cancelled = false;
void nip46Status()
.then((status) => {
if (!cancelled && status.pairing_uri) setLivePairingUri(status.pairing_uri);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [phase, livePairingUri, nip46Status]);
useEffect(() => {
if (!livePairingUri) {
setPairingQr(null);
return;
}
let cancelled = false;
QRCode.toDataURL(livePairingUri, {
width: 480,
margin: 2,
errorCorrectionLevel: 'M',
})
.then((url) => {
if (!cancelled) setPairingQr(url);
})
.catch(() => {
if (!cancelled) setError('Could not render the pairing QR code.');
});
return () => {
cancelled = true;
};
}, [livePairingUri]);
}, [open]);
const canSubmit = label.trim().length > 0 && phase !== 'creating';
@ -125,157 +50,13 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
setCreatedNpub(summary.npub);
setPhase('success');
} catch (err) {
setPhase('local');
setPhase('form');
setError(err instanceof Error ? err.message : String(err));
}
};
const startPairing = async () => {
setError(null);
try {
// No user-typed label: the profile is named automatically. The
// backend fetches the account's kind-0 after the handshake and
// upgrades this seed label to the account's real display name
// (adopt_identity background enrichment); a nameless account keeps
// 'Amber', which beats a manual step the user must fight with a
// backspace key (Sep 25 feedback).
const status = await nip46PairStart(label.trim() || 'Amber');
if (status.pairing_uri) setLivePairingUri(status.pairing_uri);
setPhase('pairing');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
// Leaving the QR view mid-pairing aborts the in-flight pairing; nothing
// was persisted yet, so teardown is safe at any point (same as Signer
// Mode's "Cancel pairing").
const cancelPairing = async () => {
setLivePairingUri(null);
setPairingQr(null);
setPhase('choice');
try {
await nip46Disconnect();
} catch {
// Best-effort abort; a dead pairing attempt expires on its own.
}
};
const handleClose = () => {
if (phase === 'pairing') {
void cancelPairing();
}
onClose();
};
if (phase === 'choice') {
return (
<Modal open={open} title="Add a profile" onClose={handleClose}>
<div className="create-explainer">
<p>How do you want this profile to sign?</p>
</div>
{error && <ErrorText id={errorId}>{error}</ErrorText>}
<div style={{ display: 'grid', gap: 12 }}>
<Button variant="primary" onClick={() => void startPairing()}>
<Icon name="key" size={16} />
Sign in with a signer app (Amber)
</Button>
<p className="hint" style={{ marginTop: -4 }}>
Show a QR code to Amber on your phone — your keys stay on the phone, and every signature
is approved there.
</p>
<Button variant="secondary" onClick={() => setPhase('local')}>
<Icon name="shield" size={16} />
Create a new key on this computer
</Button>
<p className="hint" style={{ marginTop: -4 }}>
A brand-new local identity whose private key lives in this app&apos;s vault.
</p>
</div>
</Modal>
);
}
if (phase === 'pairing') {
return (
<Modal open={open} title="Sign in with Amber" onClose={handleClose}>
<div className="signer-pairing">
<p>
Scan this code with <strong>Amber</strong> (or any NIP-46 signer) and approve the
connection.
</p>
{pairingQr ? (
<img
src={pairingQr}
alt="Pairing QR code"
style={{
width: 260,
height: 260,
imageRendering: 'pixelated',
borderRadius: 8,
display: 'block',
margin: '12px auto',
background: '#fff',
padding: 8,
}}
/>
) : (
<p className="hint" style={{ textAlign: 'center' }}>
Preparing the pairing code…
</p>
)}
<p className="hint" style={{ textAlign: 'center' }}>
Waiting for the signer to scan… the profile appears automatically once approved. The
code expires after a few minutes.
</p>
{error && <ErrorText>{error}</ErrorText>}
<div className="modal-actions">
<Button variant="ghost" onClick={() => void cancelPairing()}>
Cancel pairing
</Button>
</div>
{livePairingUri && (
<details style={{ marginTop: 12 }}>
<summary className="hint">Or copy the pairing link</summary>
<code className="mono" style={{ wordBreak: 'break-all', fontSize: 11 }}>
{livePairingUri}
</code>
</details>
)}
</div>
</Modal>
);
}
if (phase === 'paired') {
return (
<Modal open={open} title="Signer connected" onClose={onClose}>
<div className="create-success">
<div className="create-success-icon" aria-hidden="true">
<Icon name="check" size={26} />
</div>
<h3>Amber is now your signer!</h3>
<p>
The connected account was added as a profile and selected. Every signature will ask for
approval in Amber — nothing to install here, and the connection comes back automatically
after restarts.
</p>
<div className="modal-actions">
<Button variant="primary" onClick={onClose}>
Done
</Button>
</div>
</div>
</Modal>
);
}
return (
<Modal
open={open}
title={phase === 'local' || phase === 'creating' ? 'Create a Nostr profile' : 'Add a profile'}
onClose={handleClose}
>
<Modal open={open} title="Create a Nostr profile" onClose={onClose}>
{phase === 'success' && createdNpub ? (
<div className="create-success">
<div className="create-success-icon" aria-hidden="true">
@ -334,12 +115,8 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
</div>
<div className="modal-actions">
<Button
variant="ghost"
onClick={() => setPhase('choice')}
disabled={phase === 'creating'}
>
Back
<Button variant="ghost" onClick={onClose} disabled={phase === 'creating'}>
Cancel
</Button>
<Button
variant="primary"

View file

@ -219,20 +219,12 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) {
<ul className="feed-list">
{items.map((item) => (
<li key={item.id} className="feed-item">
<Avatar
npub={item.author_npub}
label={item.author_name ?? shortenNpub(item.author_npub, shorten)}
picture={item.author_picture ?? null}
/>
<Avatar npub={item.author_npub} label={shortenNpub(item.author_npub, shorten)} />
<div className="feed-item-body">
<div className="feed-item-meta">
{item.author_name ? (
<span title={item.author_npub}>{item.author_name}</span>
) : (
<span className="mono" title={item.author_npub}>
{shortenNpub(item.author_npub, shorten)}
</span>
)}
<span className="feed-item-time">{formatDate(item.created_at)}</span>
{item.relays.length > 1 && (
<Badge tone="neutral">{item.relays.length} relays</Badge>

View file

@ -15,10 +15,9 @@ import { useApp } from '../state/AppProvider';
interface HomeScreenProps {
onNavigate: (screen: Screen) => void;
onCreateProfile: () => void;
onImportProfile: () => void;
}
export function HomeScreen({ onNavigate, onCreateProfile, onImportProfile }: HomeScreenProps) {
export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
const { state, selectProfile } = useApp();
const { publications, fullyPublished, loading, error } = useProfilePublications();
const [selecting, setSelecting] = useState<string | null>(null);
@ -44,27 +43,16 @@ export function HomeScreen({ onNavigate, onCreateProfile, onImportProfile }: Hom
title="Welcome to Keynctr"
description={
<span>
A Nostr profile is your identity on the public Nostr network — a <code>npub</code>{' '}
address you can share. You can create a new one here (its private key is generated
and kept in this computer&rsquo;s encrypted vault), import an account you already
have, or connect an external signer like Amber so the private key never lives on
this device.
You haven't created a profile yet. A Nostr profile is your identity on the public
Nostr network — a <code>npub</code> address you can share, plus a private key kept
safely on this computer. Create your first profile to start publishing notes.
</span>
}
action={
<div className="onboarding-actions">
<Button variant="primary" onClick={onCreateProfile}>
<Icon name="plus" size={18} />
Create a new profile
Create your first profile
</Button>
<Button variant="secondary" onClick={onImportProfile}>
<Icon name="key" size={18} />I already have an account
</Button>
<Button variant="ghost" onClick={() => onNavigate('signer-mode')}>
<Icon name="server" size={18} />
Sign in with a signer (Amber, NIP-46)
</Button>
</div>
}
/>
<FirstRunGuide />

View file

@ -576,9 +576,7 @@ function RenameModal({
npub: target.npub,
perform: () => renameProfile(target.npub, trimmed),
successMessage: (report) =>
report.succeeded.length === 0 && report.failed.length === 0
? `Renamed to "${trimmed}" and saved on this device. Use "Publish name" to announce it network-wide — Amber will ask you to approve.`
: report.failed.length === 0
report.failed.length === 0
? `Renamed to "${trimmed}" and published to ${report.succeeded.length} relay(s). It may take a minute to appear on other clients.`
: `Renamed to "${trimmed}", but ${report.failed.length} relay(s) did not accept it. Use "Publish name" to retry.`,
onSaved,

View file

@ -1,5 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import QRCode from 'qrcode';
import { Alert } from '../components/Alert';
import { Badge } from '../components/Badge';
import { Button } from '../components/Button';
@ -15,7 +14,6 @@ export function SignerModeScreen() {
embeddedSignerStatus,
nip46Status,
nip46Connect,
nip46PairStart,
nip46Disconnect,
nip46Approve,
embeddedSignerApprove,
@ -32,15 +30,14 @@ export function SignerModeScreen() {
const [error, setError] = useState<string | null>(null);
const [connecting, setConnecting] = useState(false);
const [loading, setLoading] = useState(true);
const [pairingQr, setPairingQr] = useState<string | null>(null);
const [pairError, setPairError] = useState<string | null>(null);
// Single source of truth: backend state (defaults to most secure)
const mode = (state?.signer_mode ?? 'nip46_client') as SignerMode;
const isNip46Active =
(mode === 'nip46_client' || mode === 'nip46_bunker') && !!nip46StatusState?.connected;
const isNip46Active = (mode === 'nip46_client' || mode === 'nip46_bunker') && !!nip46StatusState?.connected;
const isEmbeddedActive = mode === 'embedded' && !!embeddedStatus?.available;
const refreshStatus = useCallback(async () => {
try {
// Mode comes from AppProvider state, just refresh signer statuses
@ -56,24 +53,14 @@ export function SignerModeScreen() {
const status = await embeddedSignerStatus();
setEmbeddedStatus(status);
} catch {
setEmbeddedStatus({
type: 'embedded',
available: false,
pending_count: 0,
pending: [],
} as any);
setEmbeddedStatus({ type: 'embedded', available: false, pending_count: 0, pending: [] } as any);
}
} else {
try {
const status = await nip46Status();
setNip46StatusState(status);
} catch {
setNip46StatusState({
connected: false,
relays: [],
connected_relays: [],
pending_approvals: [],
} as any);
setNip46StatusState({ connected: false, relays: [], connected_relays: [], pending_approvals: [] } as any);
}
}
} catch (err) {
@ -109,18 +96,12 @@ export function SignerModeScreen() {
await refresh();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (
msg.includes('No keypair') ||
msg.includes('No active profile') ||
msg.includes('no active profile')
) {
if (msg.includes('No keypair') || msg.includes('No active profile') || msg.includes('no active profile')) {
setError('No keypair found: Please import a key first.');
} else if (msg.includes('vault_locked') || msg.toLowerCase().includes('vault locked')) {
setError('Vault locked: Please unlock to switch modes.');
} else if (msg.includes('ACTIVE_SESSION') || msg.toLowerCase().includes('active session')) {
setError(
'Invalid mode transition: Cannot switch while active session exists. Disconnect first.',
);
setError('Invalid mode transition: Cannot switch while active session exists. Disconnect first.');
} else {
setError(msg || 'That operation is not permitted.');
}
@ -131,12 +112,8 @@ export function SignerModeScreen() {
const handleNip46Connect = useCallback(async () => {
const trimmed = uri.trim();
// Amber and self-hosted bunkers show a bunker:// link; Nostr Connect
// apps use nostrconnect://. Both are accepted by the backend parser.
if (!trimmed.startsWith('nostrconnect://') && !trimmed.startsWith('bunker://')) {
setError(
'Paste a bunker:// or nostrconnect:// link from Amber, Nostr Connect, or your bunker.',
);
if (!trimmed.startsWith('nostrconnect://')) {
setError('Paste a nostrconnect:// link from Amber, Nostr Connect, or your bunker.');
return;
}
setError(null);
@ -152,58 +129,6 @@ export function SignerModeScreen() {
}
}, [uri, label, nip46Connect]);
// Start a client-initiated pairing: the backend mints a nostrconnect://
// token and waits for the signer (Amber) to scan it. The token arrives via
// status().pairing_uri; we render it as a QR.
const handlePairStart = useCallback(async () => {
setPairError(null);
setConnecting(true);
try {
const status = await nip46PairStart(label.trim() || 'Remote Signer');
setNip46StatusState(status);
} catch (err) {
setPairError(err instanceof Error ? err.message : String(err));
} finally {
setConnecting(false);
}
}, [label, nip46PairStart]);
const pairingUri = nip46StatusState?.pairing_uri ?? null;
useEffect(() => {
if (!pairingUri) {
setPairingQr(null);
return;
}
let cancelled = false;
QRCode.toDataURL(pairingUri, {
width: 480,
margin: 2,
errorCorrectionLevel: 'M',
})
.then((url) => {
if (!cancelled) setPairingQr(url);
})
.catch(() => {
if (!cancelled) setPairError('Could not render the pairing QR code.');
});
return () => {
cancelled = true;
};
}, [pairingUri]);
// Abort an in-flight pairing (e.g. expired QR) — same teardown as a
// disconnect; nothing was persisted yet so it is safe at any point.
const handlePairCancel = useCallback(async () => {
setPairError(null);
try {
const status = await nip46Disconnect();
setNip46StatusState(status);
} catch (err) {
setPairError(err instanceof Error ? err.message : String(err));
}
}, [nip46Disconnect]);
const handleNip46Disconnect = useCallback(async () => {
setError(null);
try {
@ -228,10 +153,10 @@ export function SignerModeScreen() {
);
const handleNip46Approve = useCallback(
async (id: string, approved: boolean, always = false) => {
async (id: string, approved: boolean) => {
setError(null);
try {
const status = await nip46Approve(id, approved, always);
const status = await nip46Approve(id, approved);
setNip46StatusState(status);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
@ -258,16 +183,12 @@ export function SignerModeScreen() {
return isEmbeddedActive ? (
<Badge tone="success">Embedded (Least Secure)</Badge>
) : (
<Badge tone={vaultLocked ? 'warning' : 'neutral'}>
Embedded {vaultLocked ? '(Vault Locked)' : ''}
</Badge>
<Badge tone={vaultLocked ? 'warning' : 'neutral'}>Embedded {vaultLocked ? '(Vault Locked)' : ''}</Badge>
);
};
const handleImportKey = useCallback(async () => {
const nsec = prompt(
'Enter your nsec (npub will be derived) or leave blank to generate a new key:',
);
const nsec = prompt('Enter your nsec (npub will be derived) or leave blank to generate a new key:');
if (nsec === null) return;
setError(null);
try {
@ -316,15 +237,11 @@ export function SignerModeScreen() {
<div className="status-grid">
<div className={`status-item ${hasProfile ? 'ok' : 'missing'}`}>
<span className="status-label">Keypair</span>
<span className="status-value">
{hasProfile ? `${state?.active_profile?.npub.slice(0, 16)}…` : 'Not imported'}
</span>
<span className="status-value">{hasProfile ? `${state?.active_profile?.npub.slice(0, 16)}…` : 'Not imported'}</span>
</div>
<div className={`status-item ${!vaultLocked ? 'ok' : 'locked'}`}>
<span className="status-label">Vault</span>
<span className="status-value">
{vaultLocked ? 'Locked' : 'Unlocked / No password'}
</span>
<span className="status-value">{vaultLocked ? 'Locked' : 'Unlocked / No password'}</span>
</div>
<div className="status-item">
<span className="status-label">Current Mode</span>
@ -332,20 +249,12 @@ export function SignerModeScreen() {
</div>
</div>
{!hasProfile && (
<Button
variant="primary"
onClick={() => void handleImportKey()}
style={{ marginTop: 12 }}
>
<Button variant="primary" onClick={() => void handleImportKey()} style={{ marginTop: 12 }}>
<Icon name="key" size={16} /> Import / Generate Key
</Button>
)}
{hasProfile && vaultLocked && (
<Button
variant="secondary"
onClick={() => void handleUnlockVault()}
style={{ marginTop: 12 }}
>
<Button variant="secondary" onClick={() => void handleUnlockVault()} style={{ marginTop: 12 }}>
<Icon name="shield" size={16} /> Unlock Vault
</Button>
)}
@ -360,9 +269,7 @@ export function SignerModeScreen() {
<div className="card-body">
<div className="mode-options">
{/* 1. Most Secure */}
<label
className={`mode-option${mode === 'nip46_client' ? ' active' : ''} security-most`}
>
<label className={`mode-option${mode === 'nip46_client' ? ' active' : ''} security-most`}>
<input
type="radio"
name="signer-mode"
@ -375,9 +282,9 @@ export function SignerModeScreen() {
<div className="mode-option-content">
<h3>NIP-46 Client (Connect to External Signer)</h3>
<p className="security-desc">
<strong>Most Secure:</strong> Private key never touches this device. Connects to
an external signer (Amber, Nostr Connect, hardware wallet). Every signing
request is approved on the external device.
<strong>Most Secure:</strong> Private key never touches this device. Connects to an
external signer (Amber, Nostr Connect, hardware wallet). Every signing request is
approved on the external device.
</p>
<ul className="mode-features">
<li>✓ Private key NEVER on this device</li>
@ -385,16 +292,12 @@ export function SignerModeScreen() {
<li>✓ Every request approved externally</li>
<li>✓ Key cannot be extracted if this app is compromised</li>
</ul>
{mode === 'nip46_client' && isNip46Active && (
<span className="mode-badge active">Connected</span>
)}
{mode === 'nip46_client' && isNip46Active && <span className="mode-badge active">Connected</span>}
</div>
</label>
{/* 2. Moderately Secure */}
<label
className={`mode-option${mode === 'nip46_bunker' ? ' active' : ''} security-moderate`}
>
<label className={`mode-option${mode === 'nip46_bunker' ? ' active' : ''} security-moderate`}>
<input
type="radio"
name="signer-mode"
@ -407,8 +310,8 @@ export function SignerModeScreen() {
<div className="mode-option-content">
<h3>NIP-46 Bunker (This App Signs)</h3>
<p className="security-desc">
<strong>Moderately Secure:</strong> This app acts as a signer for other clients.
Key stays in this app&apos;s encrypted vault; other clients connect via{' '}
<strong>Moderately Secure:</strong> This app acts as a signer for other clients. Key
stays in this app&apos;s encrypted vault; other clients connect via{' '}
<code>nostrconnect://</code>.
</p>
<ul className="mode-features">
@ -417,16 +320,12 @@ export function SignerModeScreen() {
<li>✓ Works with Amber, Nostr Connect, etc.</li>
<li>⚠ Key in memory when vault unlocked</li>
</ul>
{mode === 'nip46_bunker' && isNip46Active && (
<span className="mode-badge active">Running</span>
)}
{mode === 'nip46_bunker' && isNip46Active && <span className="mode-badge active">Running</span>}
</div>
</label>
{/* 3. Least Secure */}
<label
className={`mode-option${mode === 'embedded' ? ' active' : ''} security-least`}
>
<label className={`mode-option${mode === 'embedded' ? ' active' : ''} security-least`}>
<input
type="radio"
name="signer-mode"
@ -439,8 +338,8 @@ export function SignerModeScreen() {
<div className="mode-option-content">
<h3>Embedded Signer (Local Keys)</h3>
<p className="security-desc">
<strong>Least Secure:</strong> Keys stored locally, signing on this device.
Convenient but key exists in memory when vault unlocked.
<strong>Least Secure:</strong> Keys stored locally, signing on this device. Convenient
but key exists in memory when vault unlocked.
</p>
<ul className="mode-features">
<li>✓ Keys never leave this device</li>
@ -448,18 +347,14 @@ export function SignerModeScreen() {
<li>✓ Encrypted vault (Argon2id + AES-256-GCM)</li>
<li>⚠ Vulnerable to device compromise</li>
</ul>
{mode === 'embedded' && isEmbeddedActive && (
<span className="mode-badge active">Active</span>
)}
{mode === 'embedded' && isEmbeddedActive && <span className="mode-badge active">Active</span>}
</div>
</label>
</div>
{mode === 'nip46_bunker' && !canSwitchToBunker && (
<Alert tone="warning" title="Cannot enable bunker">
{hasProfile
? 'Unlock vault to enable bunker mode.'
: 'No keypair found: Please import a key first.'}
{hasProfile ? 'Unlock vault to enable bunker mode.' : 'No keypair found: Please import a key first.'}
</Alert>
)}
{mode === 'embedded' && !canSwitchToEmbedded && hasProfile && vaultLocked && (
@ -469,8 +364,7 @@ export function SignerModeScreen() {
)}
{!hasProfile && mode !== 'nip46_client' && (
<Alert tone="warning" title="No keypair">
No keypair found: Please import a key first. (NIP-46 Client can be selected without
a local key.)
No keypair found: Please import a key first. (NIP-46 Client can be selected without a local key.)
</Alert>
)}
{error && <ErrorText>{error}</ErrorText>}
@ -485,14 +379,12 @@ export function SignerModeScreen() {
<Badge tone="warning">{embeddedStatus!.pending.length}</Badge>
</header>
<div className="card-body">
{embeddedStatus!.pending.map((req, idx) => (
{(embeddedStatus!.pending).map((req, idx) => (
<div key={req.id} className="signer-pending-item">
<div className="signer-pending-info">
<code className="mono">{req.method}</code>
<p>{req.summary}</p>
{req.details?.is_sensitive && (
<span className="sensitive-badge">Sensitive</span>
)}
{req.details?.is_sensitive && <span className="sensitive-badge">Sensitive</span>}
</div>
<div className="settings-inline">
<Button variant="primary" onClick={() => void handleEmbeddedApprove(idx, true)}>
@ -512,24 +404,16 @@ export function SignerModeScreen() {
{(mode === 'nip46_client' || mode === 'nip46_bunker') && (
<section className="card">
<header className="card-header">
<h2>
{mode === 'nip46_client' ? 'External Signer Connection' : 'Bunker Connection'}
</h2>
<h2>{mode === 'nip46_client' ? 'External Signer Connection' : 'Bunker Connection'}</h2>
</header>
<div className="card-body">
{isNip46Active && nip46StatusState ? (
<div className="signer-actions">
<p className="hint">
Connected to{' '}
<code className="mono">{nip46StatusState.signer_pubkey?.slice(0, 16)}…</code>{' '}
via {nip46StatusState.connected_relays?.length ?? 0} of{' '}
{nip46StatusState.relays?.length ?? 0} relays
Connected to <code className="mono">{nip46StatusState.signer_pubkey?.slice(0, 16)}…</code> via{' '}
{(nip46StatusState.connected_relays?.length ?? 0)} of {(nip46StatusState.relays?.length ?? 0)} relays
</p>
{nip46StatusState.error && (
<Alert tone="error" title="Connection error">
{nip46StatusState.error}
</Alert>
)}
{nip46StatusState.error && <Alert tone="error" title="Connection error">{nip46StatusState.error}</Alert>}
<Button variant="danger" onClick={() => void handleNip46Disconnect()}>
<Icon name="trash" size={16} /> Disconnect
</Button>
@ -543,22 +427,10 @@ export function SignerModeScreen() {
<p>{r.summary}</p>
</div>
<div className="settings-inline">
<Button
variant="primary"
onClick={() => void handleNip46Approve(r.id, true)}
>
<Button variant="primary" onClick={() => void handleNip46Approve(r.id, true)}>
Approve
</Button>
<Button
variant="secondary"
onClick={() => void handleNip46Approve(r.id, true, true)}
>
Always allow
</Button>
<Button
variant="danger"
onClick={() => void handleNip46Approve(r.id, false)}
>
<Button variant="danger" onClick={() => void handleNip46Approve(r.id, false)}>
Reject
</Button>
</div>
@ -567,59 +439,12 @@ export function SignerModeScreen() {
</div>
)}
</div>
) : pairingUri ? (
<div className="signer-pairing">
<p>
Scan this code with <strong>Amber</strong> (or any NIP-46 signer) to connect.
</p>
{pairingQr && (
<img
src={pairingQr}
alt="Pairing QR code"
style={{
width: 260,
height: 260,
imageRendering: 'pixelated',
borderRadius: 8,
display: 'block',
margin: '12px auto',
background: '#fff',
padding: 8,
}}
/>
)}
<p className="hint" style={{ textAlign: 'center' }}>
Waiting for the signer to scan… the connection appears automatically once
approved. The code expires after a few minutes.
</p>
{nip46StatusState?.error && (
<Alert tone="error" title="Pairing failed">
{nip46StatusState.error}
</Alert>
)}
{pairError && <ErrorText>{pairError}</ErrorText>}
<div className="settings-inline" style={{ justifyContent: 'center' }}>
<Button variant="ghost" onClick={() => void handlePairCancel()}>
Cancel pairing
</Button>
</div>
<details style={{ marginTop: 12 }}>
<summary className="hint">Or copy the pairing link</summary>
<code className="mono" style={{ wordBreak: 'break-all', fontSize: 11 }}>
{pairingUri}
</code>
</details>
</div>
) : (
<div>
<div className="field">
<input
type="text"
placeholder={
mode === 'nip46_client'
? 'bunker://… or nostrconnect://… (from Amber / Nostr Connect)'
: 'nostrconnect://…'
}
placeholder={mode === 'nip46_client' ? 'nostrconnect://… (from Amber / Nostr Connect)' : 'nostrconnect://…'}
value={uri}
onChange={(e) => setUri(e.target.value)}
autoComplete="off"
@ -627,51 +452,17 @@ export function SignerModeScreen() {
/>
<p className="hint">
{mode === 'nip46_client'
? 'Easiest: press “Show QR” below and scan it with Amber. Or paste a bunker:// link from a self-hosted bunker / nostrconnect:// link from another app.'
? 'In Amber / Nostr Connect, choose “Connect external app” and paste the nostrconnect:// link here.'
: 'Share this with client apps that want to connect to this bunker.'}
</p>
</div>
<div className="field">
<label>Label</label>
<input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Remote Signer"
/>
<input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="Remote Signer" />
</div>
{error && <ErrorText>{error}</ErrorText>}
{pairError && <ErrorText>{pairError}</ErrorText>}
{/* A failed handshake must never look like an idle form:
surface the backend's error so a timeout is visible. */}
{nip46StatusState?.error && (
<Alert tone="error" title="Connection failed">
{nip46StatusState.error}
</Alert>
)}
{/* A sent-but-unapproved connection request is in flight:
say so instead of showing a blank form. */}
{!nip46StatusState?.error && (nip46StatusState?.relays?.length ?? 0) > 0 && (
<p className="hint">
Connection request sent — approve it in Amber. This updates automatically; it
can take up to a couple of minutes on a slow network.
</p>
)}
<div className="settings-inline">
{mode === 'nip46_client' && (
<Button
variant="primary"
loading={connecting}
onClick={() => void handlePairStart()}
>
<Icon name="key" size={16} /> Show QR
</Button>
)}
<Button
variant={mode === 'nip46_client' ? 'ghost' : 'primary'}
loading={connecting}
disabled={!uri.trim()}
onClick={() => void handleNip46Connect()}
>
<Button variant="primary" loading={connecting} disabled={!uri.trim()} onClick={() => void handleNip46Connect()}>
<Icon name="key" size={16} /> Connect
</Button>
<Button variant="ghost" onClick={() => void refreshStatus()} disabled={loading}>
@ -691,16 +482,15 @@ export function SignerModeScreen() {
<div className="card-body">
<ul className="security-notes">
<li>
<strong>NIP-46 Client (Most Secure):</strong> Private key never on this device.
External signer (hardware wallet / Amber) holds key.
<strong>NIP-46 Client (Most Secure):</strong> Private key never on this device. External
signer (hardware wallet / Amber) holds key.
</li>
<li>
<strong>NIP-46 Bunker (Moderate):</strong> Key in this app&apos;s vault, you approve
each remote request.
<strong>NIP-46 Bunker (Moderate):</strong> Key in this app&apos;s vault, you approve each
remote request.
</li>
<li>
<strong>Embedded (Least Secure):</strong> Local signing, key in memory when
unlocked.
<strong>Embedded (Least Secure):</strong> Local signing, key in memory when unlocked.
</li>
</ul>
</div>

View file

@ -5,7 +5,7 @@ import { Button } from '../components/Button';
import { ErrorText } from '../components/ErrorText';
import { Icon } from '../components/Icon';
import { shortHexId } from '../lib/format';
import type { SignerGrant, SignerStatus } from '../lib/types';
import type { SignerStatus } from '../lib/types';
import { useApp } from '../state/AppProvider';
const EMPTY_STATUS: SignerStatus = {
@ -18,17 +18,8 @@ const EMPTY_STATUS: SignerStatus = {
};
export function SignerScreen() {
const {
state,
signerConnect,
signerDisconnect,
signerStatus,
signerApprove,
signerGrantsList,
signerGrantRevoke,
} = useApp();
const { state, signerConnect, signerDisconnect, signerStatus, signerApprove } = useApp();
const [status, setStatus] = useState<SignerStatus>(EMPTY_STATUS);
const [grants, setGrants] = useState<SignerGrant[]>([]);
const [uri, setUri] = useState('');
const [error, setError] = useState<string | null>(null);
const [connecting, setConnecting] = useState(false);
@ -37,7 +28,6 @@ export function SignerScreen() {
const refresh = async () => {
try {
setStatus(await signerStatus());
setGrants(await signerGrantsList());
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@ -92,21 +82,10 @@ export function SignerScreen() {
}
};
const onApprove = async (id: string, approved: boolean, always = false) => {
const onApprove = async (id: string, approved: boolean) => {
setError(null);
try {
setStatus(await signerApprove(id, approved, always));
setGrants(await signerGrantsList());
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const onRevokeGrant = async (grant: SignerGrant) => {
setError(null);
try {
await signerGrantRevoke(grant.app_pubkey, grant.method);
setGrants(await signerGrantsList());
setStatus(await signerApprove(id, approved));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
@ -221,13 +200,6 @@ export function SignerScreen() {
<Icon name="check" size={16} />
Approve
</Button>
<Button
variant="secondary"
onClick={() => void onApprove(request.id, true, true)}
>
<Icon name="check" size={16} />
Always allow
</Button>
<Button variant="danger" onClick={() => void onApprove(request.id, false)}>
<Icon name="trash" size={16} />
Reject
@ -239,33 +211,6 @@ export function SignerScreen() {
</section>
)}
{grants.length > 0 && (
<section className="card">
<header className="card-header">
<h2>Always-allow permissions</h2>
<Badge>{grants.length}</Badge>
</header>
<div className="card-body signer-pending">
<p className="hint">
These requests run without asking. Revoke one to go back to approving it every time.
</p>
{grants.map((grant) => (
<div key={`${grant.app_pubkey}:${grant.method}`} className="signer-pending-item">
<div className="signer-pending-info">
<code className="mono signer-pending-method">{grant.method}</code>
<p>for {shortHexId(grant.app_pubkey)}</p>
</div>
<div className="settings-inline">
<Button variant="secondary" onClick={() => void onRevokeGrant(grant)}>
Revoke
</Button>
</div>
</div>
))}
</div>
</section>
)}
<section className="card">
<header className="card-header">
<h2>Connect a Nostr app</h2>

View file

@ -21,7 +21,6 @@ import type {
RelayTestResult,
RevealedKey,
Settings,
SignerGrant,
SignerMode,
SignerStatus,
Theme,
@ -80,17 +79,14 @@ interface AppContextValue {
embeddedSignerApprove: (index: number, approved: boolean) => Promise<EmbeddedSignerStatus>;
// NIP-46 client signer
nip46Connect: (uri: string, label: string) => Promise<Nip46SignerStatus>;
nip46PairStart: (label: string) => Promise<Nip46SignerStatus>;
nip46Disconnect: () => Promise<Nip46SignerStatus>;
nip46Status: () => Promise<Nip46SignerStatus>;
nip46Approve: (id: string, approved: boolean, always?: boolean) => Promise<Nip46SignerStatus>;
nip46Approve: (id: string, approved: boolean) => Promise<Nip46SignerStatus>;
// Legacy NIP-46 bunker (deprecated)
signerConnect: (uri: string) => Promise<SignerStatus>;
signerDisconnect: () => Promise<SignerStatus>;
signerStatus: () => Promise<SignerStatus>;
signerApprove: (id: string, approved: boolean, always?: boolean) => Promise<SignerStatus>;
signerGrantsList: () => Promise<SignerGrant[]>;
signerGrantRevoke: (appPubkey: string, grantMethod: string) => Promise<{ removed: boolean }>;
signerApprove: (id: string, approved: boolean) => Promise<SignerStatus>;
deleteProfile: (npub: string) => Promise<AppState>;
undoDelete: () => Promise<AppState>;
clearLastDeleted: () => void;
@ -107,17 +103,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
const refresh = useCallback(async () => {
const fresh = await api.getState();
// The 5s poll must be silent when nothing changed: a fresh object
// identity every tick would re-render every screen and refire effects
// keyed on state slices (e.g. Home's publications loader flickering
// between "Loading…" and done forever).
setState((prev) => {
try {
return JSON.stringify(prev) === JSON.stringify(fresh) ? prev : fresh;
} catch {
return fresh;
}
});
setState(fresh);
}, []);
useEffect(() => {
@ -151,18 +137,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
};
}, []);
// Background pairing completes server-side with no push channel to the UI
// (IPC is request/response), so poll for fresh state: otherwise Home and
// Profiles keep showing the pre-pairing snapshot after Amber connects.
// getState is a cheap local vault read; errors are ignored here since every
// screen surfaces its own request failures.
useEffect(() => {
const timer = setInterval(() => {
void refresh().catch(() => {});
}, 5000);
return () => clearInterval(timer);
}, [refresh]);
const createProfile = useCallback(
async (label: string): Promise<ProfileSummary> => {
const result = await api.createProfile(label, state?.settings);
@ -290,15 +264,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
);
const nip46Disconnect = useCallback(() => api.nip46Disconnect(), []);
const nip46Status = useCallback(() => api.nip46Status(), []);
const nip46PairStart = useCallback((label: string) => api.nip46PairStart(label), []);
const nip46Approve = useCallback(
(id: string, approved: boolean, always = false) => api.nip46Approve(id, approved, always),
[],
);
const signerGrantsList = useCallback(() => api.signerGrantsList(), []);
const signerGrantRevoke = useCallback(
(appPubkey: string, grantMethod: string) => api.signerGrantRevoke(appPubkey, grantMethod),
(id: string, approved: boolean) => api.nip46Approve(id, approved),
[],
);
@ -317,7 +284,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
[applyState],
);
const exportSecretKey = useCallback(
(npub: string, password: string, reason: string) => api.exportSecretKey(npub, password, reason),
(npub: string, password: string, reason: string) =>
api.exportSecretKey(npub, password, reason),
[],
);
const pickImages = useCallback(() => api.pickImages(), []);
@ -383,7 +351,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
embeddedSignerStatus,
embeddedSignerApprove,
nip46Connect,
nip46PairStart,
nip46Disconnect,
nip46Status,
nip46Approve,
@ -391,8 +358,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
signerDisconnect,
signerStatus,
signerApprove,
signerGrantsList,
signerGrantRevoke,
deleteProfile,
undoDelete,
publishProfileMetadata,
@ -443,7 +408,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
embeddedSignerStatus,
embeddedSignerApprove,
nip46Connect,
nip46PairStart,
nip46Disconnect,
nip46Status,
nip46Approve,
@ -451,8 +415,6 @@ export function AppProvider({ children }: { children: ReactNode }) {
signerDisconnect,
signerStatus,
signerApprove,
signerGrantsList,
signerGrantRevoke,
copyText,
],
);

View file

@ -1402,18 +1402,6 @@ select {
margin-top: 8px;
}
.onboarding-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.onboarding-actions .btn {
min-width: 260px;
justify-content: center;
}
/* -------------------------------------------------------------------------
Home
------------------------------------------------------------------------- */

View file

@ -16,13 +16,13 @@ describe('App', () => {
render(<App />);
expect(await screen.findByText('Welcome to Keynctr')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Create a new profile/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /I already have an account/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Sign in with a signer/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Create your first profile/i })).toBeInTheDocument();
expect(screen.getByText(/A Nostr profile is your identity/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Create a new profile/i }));
expect(await screen.findByRole('dialog', { name: 'Add a profile' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Create your first profile/i }));
expect(
await screen.findByRole('dialog', { name: 'Create a Nostr profile' }),
).toBeInTheDocument();
});
it('renders the main screen after loading with an existing profile', async () => {

View file

@ -5,39 +5,14 @@ import { renderWithApp } from './render';
import { makeEmptyState } from './apiMock';
import { createFakeBackend, installFakeBackend } from './fakeBackend';
vi.mock('qrcode', () => ({
default: { toDataURL: vi.fn(async () => 'data:image/png;base64,QR') },
}));
async function startPairingFlow(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }));
}
describe('CreateProfileModal', () => {
it('offers the signer (Amber) and local-key choices first', async () => {
const backend = createFakeBackend(makeEmptyState());
installFakeBackend(backend);
renderWithApp(<CreateProfileModal open onClose={vi.fn()} />);
await screen.findByRole('dialog', { name: 'Add a profile' });
expect(
screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /Create a new key on this computer/ }),
).toBeInTheDocument();
});
it('creates a local-key profile through the backend and shows a success confirmation', async () => {
it('creates a profile through the backend and shows a success confirmation', async () => {
const backend = createFakeBackend(makeEmptyState());
installFakeBackend(backend);
const onClose = vi.fn();
renderWithApp(<CreateProfileModal open onClose={onClose} />);
await screen.findByRole('dialog', { name: 'Add a profile' });
await userEvent
.setup()
.click(screen.getByRole('button', { name: /Create a new key on this computer/ }));
await screen.findByRole('dialog', { name: 'Create a Nostr profile' });
const input = screen.getByLabelText('Profile name');
await userEvent.setup().type(input, 'Sam');
@ -61,85 +36,20 @@ describe('CreateProfileModal', () => {
installFakeBackend(backend);
renderWithApp(<CreateProfileModal open onClose={vi.fn()} />);
await screen.findByRole('dialog', { name: 'Add a profile' });
await userEvent
.setup()
.click(screen.getByRole('button', { name: /Create a new key on this computer/ }));
await screen.findByRole('dialog', { name: 'Create a Nostr profile' });
expect(screen.getByRole('button', { name: 'Create profile' })).toBeDisabled();
});
it('closes without creating when Back then close is used', async () => {
it('closes without creating when Cancel is clicked', async () => {
const backend = createFakeBackend(makeEmptyState());
installFakeBackend(backend);
const onClose = vi.fn();
renderWithApp(<CreateProfileModal open onClose={onClose} />);
await userEvent
.setup()
.click(screen.getByRole('button', { name: /Create a new key on this computer/ }));
await userEvent.setup().type(screen.getByLabelText('Profile name'), 'Sam');
await userEvent.setup().click(screen.getByRole('button', { name: 'Back' }));
// Back returns to the choice step; nothing was created yet.
await userEvent.setup().click(screen.getByRole('button', { name: 'Cancel' }));
expect(onClose).toHaveBeenCalled();
expect(backend.state.profiles).toHaveLength(0);
expect(
screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }),
).toBeInTheDocument();
});
it('starts Amber pairing from the choice step and shows the QR', async () => {
const backend = createFakeBackend(makeEmptyState());
installFakeBackend(backend);
renderWithApp(<CreateProfileModal open onClose={vi.fn()} />);
const user = userEvent.setup();
// No label step (Sep 25 feedback: typing/fighting a prefilled name was
// friction). One click mints the QR with the 'Amber' seed label; the
// backend upgrades it to the account's real kind-0 display name.
await user.click(screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }));
expect(await screen.findByText(/Waiting for the signer to scan/i)).toBeInTheDocument();
const start = backend.requests.find((r) => r.method === 'nip46_pair_start');
expect(start?.params.label).toBe('Amber');
expect(await screen.findByAltText('Pairing QR code')).toBeInTheDocument();
});
it('shows the connected confirmation once the poll reports the signer online', async () => {
const backend = createFakeBackend(makeEmptyState());
installFakeBackend(backend);
renderWithApp(<CreateProfileModal open onClose={vi.fn()} />);
const user = userEvent.setup();
await startPairingFlow(user);
await screen.findByText(/Waiting for the signer to scan/i);
// Amber approves: the fake backend now reports the handshake done. The
// modal polls the signer status every 2s, so wait past one interval.
backend.setNip46({
type: 'nip46',
connected: true,
relays: ['wss://relay.test'],
connected_relays: ['wss://relay.test'],
pending_approvals: [],
});
expect(
await screen.findByText('Amber is now your signer!', {}, { timeout: 5000 }),
).toBeInTheDocument();
});
it('aborts an in-flight pairing when Cancel pairing is clicked', async () => {
const backend = createFakeBackend(makeEmptyState());
installFakeBackend(backend);
renderWithApp(<CreateProfileModal open onClose={vi.fn()} />);
const user = userEvent.setup();
await startPairingFlow(user);
await screen.findByText(/Waiting for the signer to scan/i);
await user.click(screen.getByRole('button', { name: 'Cancel pairing' }));
expect(backend.requests.some((r) => r.method === 'nip46_disconnect')).toBe(true);
expect(
screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }),
).toBeInTheDocument();
});
});

View file

@ -146,9 +146,7 @@ describe('exporting a secret key', () => {
// Reopen — fields should be empty
const dialog2 = await openExport(user);
expect((within(dialog2).getByLabelText('Vault password') as HTMLInputElement).value).toBe('');
expect((within(dialog2).getByLabelText('Reason for export') as HTMLInputElement).value).toBe(
'',
);
expect((within(dialog2).getByLabelText('Reason for export') as HTMLInputElement).value).toBe('');
});
it('shows an error for an incorrect password', async () => {
@ -187,7 +185,9 @@ describe('exporting a secret key', () => {
await user.click(within(dialog).getByRole('button', { name: 'Export' }));
await waitFor(() => {
expect(within(dialog).getByText(/not stored on this computer/)).toBeInTheDocument();
expect(
within(dialog).getByText(/not stored on this computer/),
).toBeInTheDocument();
});
});
@ -226,7 +226,9 @@ describe('exporting a secret key', () => {
await user.click(within(dialog).getByRole('button', { name: 'Export' }));
await waitFor(() => {
expect(within(dialog).getByText(/external signer/i)).toBeInTheDocument();
expect(
within(dialog).getByText(/external signer/i),
).toBeInTheDocument();
});
});

View file

@ -22,22 +22,6 @@ describe('FeedScreen', () => {
expect(screen.getByText('2 relays')).toBeInTheDocument();
});
it('shows the author name and picture when the feed resolved them', async () => {
const backend = createFakeBackend();
backend.feedItems = backend.feedItems.map((item, index) =>
index === 0
? { ...item, author_name: 'Alice Liddell', author_picture: 'https://example.com/alice.png' }
: item,
);
renderFeed(backend);
renderWithApp(<FeedScreen onNavigate={vi.fn()} />);
expect(await screen.findByText('Alice Liddell')).toBeInTheDocument();
// The avatar image is decorative (empty alt), so query by src.
const avatar = document.querySelector('img[src="https://example.com/alice.png"]');
expect(avatar).not.toBeNull();
});
it('disable relays shows an empty state that can navigate to relays', async () => {
const settings = {
theme: 'light' as const,

View file

@ -7,18 +7,13 @@ import { createFakeBackend, installFakeBackend } from './fakeBackend';
function renderHome(
backend: ReturnType<typeof createFakeBackend>,
overrides: {
onNavigate?: () => void;
onCreateProfile?: () => void;
onImportProfile?: () => void;
} = {},
overrides: { onNavigate?: () => void; onCreateProfile?: () => void } = {},
) {
installFakeBackend(backend);
return {
user: userEvent.setup(),
onNavigate: overrides.onNavigate ?? vi.fn(),
onCreateProfile: overrides.onCreateProfile ?? vi.fn(),
onImportProfile: overrides.onImportProfile ?? vi.fn(),
};
}
@ -26,9 +21,7 @@ describe('HomeScreen', () => {
it('shows the active profile, a shortened npub, and a compose button', async () => {
const backend = createFakeBackend();
const { onNavigate } = renderHome(backend);
renderWithApp(
<HomeScreen onNavigate={onNavigate} onCreateProfile={vi.fn()} onImportProfile={vi.fn()} />,
);
renderWithApp(<HomeScreen onNavigate={onNavigate} onCreateProfile={vi.fn()} />);
// The active profile appears in the profile list with its shortened npub.
const profileList = await screen.findByRole('listbox');
@ -43,9 +36,7 @@ describe('HomeScreen', () => {
it('copies the complete npub when the copy button is clicked', async () => {
const backend = createFakeBackend();
renderHome(backend);
renderWithApp(
<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} onImportProfile={vi.fn()} />,
);
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />);
// The active profile row carries the "Selected" badge; find Alice via the profile list.
const profileList = await screen.findByRole('listbox');
@ -60,32 +51,20 @@ describe('HomeScreen', () => {
it('shows the first-run state and guides the user to create a profile', async () => {
const backend = createFakeBackend(makeEmptyState());
const { onCreateProfile, onImportProfile } = renderHome(backend);
renderWithApp(
<HomeScreen
onNavigate={vi.fn()}
onCreateProfile={onCreateProfile}
onImportProfile={onImportProfile}
/>,
);
const { onCreateProfile } = renderHome(backend);
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={onCreateProfile} />);
expect(await screen.findByText('Welcome to Keynctr')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /I already have an account/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Sign in with a signer/i })).toBeInTheDocument();
await userEvent.setup().click(screen.getByRole('button', { name: /Create a new profile/i }));
expect(onCreateProfile).toHaveBeenCalled();
await userEvent
.setup()
.click(screen.getByRole('button', { name: /I already have an account/i }));
expect(onImportProfile).toHaveBeenCalled();
.click(screen.getByRole('button', { name: /Create your first profile/i }));
expect(onCreateProfile).toHaveBeenCalled();
});
it('selects a profile when its row is clicked (not just the Select button)', async () => {
const backend = createFakeBackend();
renderHome(backend);
renderWithApp(
<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} onImportProfile={vi.fn()} />,
);
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />);
const bobRow = (await screen.findByText('Bob')).closest('.home-profile-row') as HTMLElement;
// Clicking the name (not the Select button) should select the profile.
@ -98,31 +77,4 @@ describe('HomeScreen', () => {
const aliceRow = screen.getByText('Alice').closest('.home-profile-row') as HTMLElement;
expect(within(aliceRow).getByRole('button', { name: 'Select' })).toBeInTheDocument();
});
it('does not refetch publications on every background state poll', async () => {
// Regression: the 5s AppProvider poll must not refire the publications
// loader (it flickered Home between "Loading…" and done forever).
// Fake timers from the start: the poll interval must be scheduled under
// fake time, and RTL async queries stall under fake timers, so drive
// everything with explicit timer advances instead.
vi.useFakeTimers();
try {
const backend = createFakeBackend();
renderHome(backend);
renderWithApp(
<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} onImportProfile={vi.fn()} />,
);
// Initial load completes.
await vi.advanceTimersByTimeAsync(500);
const initialFetches = backend.requests.filter((r) => r.method === 'feed_get').length;
expect(initialFetches).toBeGreaterThan(0);
// Two full poll ticks with unchanged state must not refetch.
await vi.advanceTimersByTimeAsync(12000);
expect(backend.requests.filter((r) => r.method === 'feed_get').length).toBe(initialFetches);
} finally {
vi.useRealTimers();
}
});
});

View file

@ -1,72 +0,0 @@
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, vi } from 'vitest';
import { SignerModeScreen } from '../screens/SignerModeScreen';
import { renderWithApp } from './render';
import { createFakeBackend, installFakeBackend } from './fakeBackend';
import { makeState } from './apiMock';
vi.mock('qrcode', () => ({
default: { toDataURL: vi.fn(async () => 'data:image/png;base64,QR') },
}));
function installNip46Backend() {
const backend = createFakeBackend(makeState({ signer_mode: 'nip46_client' }));
installFakeBackend(backend);
return backend;
}
beforeEach(() => {
vi.clearAllMocks();
});
describe('SignerModeScreen handshake states', () => {
it('shows the QR waiting hint while a pairing is in flight', async () => {
const backend = installNip46Backend();
const user = userEvent.setup();
renderWithApp(<SignerModeScreen />);
await screen.findByRole('button', { name: /Show QR/i });
await user.click(screen.getByRole('button', { name: /Show QR/i }));
expect(await screen.findByText(/Waiting for the signer to scan/i)).toBeInTheDocument();
expect(backend.requests.some((r) => r.method === 'nip46_pair_start')).toBe(true);
});
it('shows a connecting hint after a paste-URI connect is sent but unapproved', async () => {
const backend = installNip46Backend();
backend.setNip46({
type: 'nip46',
connected: false,
relays: ['wss://relay.test'],
connected_relays: ['wss://relay.test'],
pending_approvals: [],
});
renderWithApp(<SignerModeScreen />);
expect(await screen.findByText(/Connection request sent/i)).toBeInTheDocument();
});
it('surfaces a failed handshake as a visible error, not a silent idle form', async () => {
const backend = installNip46Backend();
backend.setNip46({
type: 'nip46',
connected: false,
relays: ['wss://relay.test'],
connected_relays: [],
error:
'The signer would not reveal its public key (timeout). Keep Amber open in the foreground with network access and try again.',
pending_approvals: [],
});
renderWithApp(<SignerModeScreen />);
expect(await screen.findByText('Connection failed')).toBeInTheDocument();
expect(
await screen.findByText(/The signer would not reveal its public key/i),
).toBeInTheDocument();
// The failure must poll through the same status channel the screen reads.
await waitFor(() =>
expect(backend.requests.some((r) => r.method === 'nip46_status')).toBe(true),
);
});
});

View file

@ -2,12 +2,10 @@ import type {
AppState,
BackendResponse,
FeedItem,
Nip46SignerStatus,
ProfileSummary,
PublishReport,
RelayTestResult,
Settings,
SignerGrant,
SignerStatus,
UpdateApplyReport,
UpdateCheckReport,
@ -43,11 +41,6 @@ export interface FakeBackend {
/** Current NIP-46 signer status. */
signer: SignerStatus;
setSigner: (next: SignerStatus) => void;
/** NIP-46 client handshake status backing the nip46_* methods. */
nip46: Nip46SignerStatus;
setNip46: (next: Nip46SignerStatus) => void;
/** Standing "always allow" grants returned by signer_grants_list. */
signerGrants: SignerGrant[];
/** Notes returned by `feed_get`. */
feedItems: FeedItem[];
/** Notes returned by `feed_get` with `contacts_only: true`. */
@ -113,17 +106,6 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
setSigner(next) {
backend.signer = next;
},
nip46: {
type: 'nip46',
connected: false,
relays: [],
connected_relays: [],
pending_approvals: [],
},
setNip46(next) {
backend.nip46 = next;
},
signerGrants: [],
feedItems: [
{
id: 'note1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
@ -193,41 +175,7 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
switch (method) {
case 'init':
case 'get_state':
// Deep-copy like the real IPC boundary (fresh JSON per call), so
// tests observe new object identities exactly as production does.
return structuredClone(state);
// NIP-46 client handshake surface used by SignerModeScreen. The fake
// keeps a Nip46SignerStatus-shaped object so handshake-state tests
// (pairing URI, connecting relays, failure errors) run without relays.
case 'nip46_status':
return backend.nip46;
case 'nip46_pair_start': {
const next = {
...backend.nip46,
pairing_uri: `nostrconnect://deadbeef?relay=${encodeURIComponent('wss://relay.test')}&secret=fake`,
};
backend.setNip46(next);
return next;
}
case 'nip46_connect': {
const next = { ...backend.nip46, relays: ['wss://relay.test'] };
backend.setNip46(next);
return next;
}
case 'nip46_disconnect': {
const next: Nip46SignerStatus = {
type: 'nip46',
connected: false,
relays: [],
connected_relays: [],
pending_approvals: [],
};
backend.setNip46(next);
return next;
}
case 'nip46_approve':
return backend.nip46;
return state;
case 'create_profile': {
const label = String(params.label ?? '');
@ -396,36 +344,14 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
case 'signer_approve': {
const id = String(params.id ?? '');
const entry = backend.signer.pending.find((request) => request.id === id);
const next: SignerStatus = {
...backend.signer,
pending: backend.signer.pending.filter((request) => request.id !== id),
};
backend.setSigner(next);
if (params.approved === true && params.always === true && entry) {
if (!backend.signerGrants.some((g) => g.method === entry.method)) {
backend.signerGrants = [
...backend.signerGrants,
{ app_pubkey: backend.signer.peer ?? '', method: entry.method },
];
}
}
return next;
}
case 'signer_grants_list':
return backend.signerGrants;
case 'signer_grant_revoke': {
const app = String(params.app_pubkey ?? '');
const method = String(params.grant_method ?? '');
const before = backend.signerGrants.length;
backend.signerGrants = backend.signerGrants.filter(
(g) => !(g.app_pubkey === app && g.method === method),
);
return { removed: backend.signerGrants.length < before };
}
case 'relay_add': {
const url = String(params.url);
const nextSettings: Settings = {
@ -519,9 +445,10 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
// Check profile exists first
const profile = state.profiles.find((p) => p.npub === npub);
if (!profile) {
throw Object.assign(new Error('That profile is not stored on this computer.'), {
code: 'profile_not_found',
});
throw Object.assign(
new Error('That profile is not stored on this computer.'),
{ code: 'profile_not_found' },
);
}
// External signer profiles cannot export secret keys
@ -534,19 +461,24 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
if (state.encrypted_storage) {
if (!password) {
throw Object.assign(new Error('Password required to export secret key.'), {
code: 'wrong_password',
});
throw Object.assign(
new Error('Password required to export secret key.'),
{ code: 'wrong_password' },
);
}
// Fake password check: accept "test" or "password"
if (password !== 'test' && password !== 'password') {
throw Object.assign(new Error('Wrong password.'), { code: 'wrong_password' });
throw Object.assign(
new Error('Wrong password.'),
{ code: 'wrong_password' },
);
}
}
if (!reason) {
throw Object.assign(new Error('A reason is required for key export.'), {
code: 'config',
});
throw Object.assign(
new Error('A reason is required for key export.'),
{ code: 'config' },
);
}
const hex = `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64);
return { hex, nsec: `nsec1${npub.slice(5)}` };

View file

@ -23,9 +23,7 @@ function makeItem(overrides: Partial<FeedItem> & { id: string; relays: string[]
function renderHome(backend: ReturnType<typeof createFakeBackend>) {
installFakeBackend(backend);
renderWithApp(
<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} onImportProfile={vi.fn()} />,
);
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />);
}
async function waitForData() {

View file

@ -9,12 +9,9 @@ use crate::crypto::{self, VaultKey};
use crate::errors::AppError;
use crate::profiles::{self, ProfileSummary};
use crate::settings::Settings;
use crate::signer::Signer as SignerTrait;
use crate::signer::Signing;
use crate::vault::{
self, KdfParams, SignerMode, StoredProfile, StoredPublishReport, Vault, VaultCrypto,
};
use nostr_sdk::prelude::{Keys, PublicKey};
/// Minimum password length accepted when encrypting the vault.
pub const MIN_PASSWORD_LEN: usize = 8;
@ -25,10 +22,8 @@ pub struct App {
pub settings: Settings,
/// Derived vault key, present only while the encrypted vault is unlocked.
unlock_key: Option<VaultKey>,
/// Stack of deleted profiles for undo functionality. Holds the full
/// stored record (including secret key material, exactly as it was on
/// disk) so undo restores a working profile, not an empty shell.
pub undo_history: Vec<crate::profiles::DeletedProfile>,
/// Stack of deleted profiles for undo functionality.
pub undo_history: Vec<ProfileSummary>,
/// The most recent publish report, persisted across restarts.
pub last_publish: Option<StoredPublishReport>,
/// Active signer mode.
@ -65,8 +60,7 @@ pub struct AppStateView {
pub active_profile: Option<ProfileSummary>,
pub profiles: Vec<ProfileSummary>,
pub settings: Settings,
/// Recently deleted profiles (safe summaries only — never secret material),
/// newest last, for undo.
/// Recently deleted profiles, newest last, for undo.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub undo_history: Vec<ProfileSummary>,
/// The most recent publish report, persisted across restarts.
@ -119,56 +113,6 @@ impl App {
self.vault.is_encrypted() && self.unlock_key.is_none()
}
/// The [`Signing`] source for user content of a specific profile.
///
/// The single place the "where does signing happen" decision is made, so
/// no caller branches on signer mode itself:
///
/// - Profile mode `Embedded` → [`Signing::Local`] with the vault-resolved
/// key (locked vault surfaces as the usual `VaultLocked` error).
/// - Profile mode `Nip46Client` → [`Signing::External`] wrapping the live
/// NIP-46 client signer, **only** when one is present and connected.
/// Never falls back to the local key: an external profile that cannot
/// reach its signer fails with `ExternalSignerNotConnected`.
/// - `Nip46Bunker` (legacy, not wired) → fails closed like a missing
/// connection.
pub async fn signing_for(&self, npub: &str) -> Result<Signing, AppError> {
let profile = profiles::find_stored_profile(&self.vault, npub)?;
match profile.signer_mode {
SignerMode::Embedded => {
let secret_hex = profiles::resolve_secret_key(&self.vault, npub, self.vault_key())?;
let secret_key = profiles::parse_secret_key(&secret_hex)?;
Ok(Signing::Local(Keys::new(secret_key)))
}
SignerMode::Nip46Client => {
let Some(signer) = self.nip46_signer.clone() else {
return Err(AppError::external_signer_not_connected());
};
if !signer.is_available().await {
return Err(AppError::external_signer_not_connected());
}
let profile_pubkey = PublicKey::parse(npub).map_err(|e| {
AppError::internal(format!("Stored profile npub is not valid: {e}"))
})?;
Ok(Signing::External {
signer,
profile_pubkey,
})
}
SignerMode::Nip46Bunker => Err(AppError::external_signer_not_connected()),
}
}
/// [`Signing`] for the active profile (see [`App::signing_for`]).
pub async fn signing_active(&self) -> Result<Signing, AppError> {
let npub = self
.vault
.active_profile
.clone()
.ok_or_else(AppError::no_active_profile)?;
self.signing_for(&npub).await
}
/// Verify a password and keep the derived key in memory for the session.
pub fn unlock(&mut self, password: &str) -> Result<(), AppError> {
let crypto = self
@ -271,45 +215,36 @@ impl App {
Ok(revealed)
}
/// Undo the last profile deletion, restoring the profile — with its real
/// stored secret key — to the vault.
/// Undo the last profile deletion, restoring the profile to the vault.
/// Returns the restored profile summary, or an error if there is no undo history.
pub fn undo_delete(&mut self) -> Result<ProfileSummary, AppError> {
let Some(deleted) = self.undo_history.pop() else {
if self.undo_history.is_empty() {
return Err(AppError::config("No profile deletions to undo."));
};
let restored = deleted.stored.clone();
// Re-add the profile to the vault unless it is somehow already there.
}
let restored = self.undo_history.pop().unwrap();
// Re-add the profile to the vault
if !self
.vault
.profiles
.iter()
.any(|p| p.public_key == restored.public_key)
.any(|p| p.public_key == restored.npub)
{
// A secret-less record (should not happen for records created by
// delete_profile_record) must not silently create a hollow
// profile: refuse and hand the entry back rather than corrupt the
// vault.
if restored.secret_key.trim().is_empty() {
self.undo_history.push(deleted);
return Err(AppError::internal(
"The undo entry is missing its secret key; the profile was not restored.",
));
}
self.vault
.active_profile
.get_or_insert(restored.public_key.clone());
self.vault.profiles.push(restored.clone());
}
let is_active = self.vault.active_profile.as_deref() == Some(restored.public_key.as_str());
Ok(ProfileSummary {
let stored = StoredProfile {
label: restored.label.clone(),
npub: restored.public_key.clone(),
public_key: restored.npub.clone(),
secret_key: "".to_string(),
created_at: restored.created_at,
is_active,
picture: restored.picture.clone(),
nip05: restored.nip05.clone(),
})
signer_mode: SignerMode::Embedded,
};
self.vault.profiles.push(stored);
// If no active profile, this restored one becomes active
if self.vault.active_profile.is_none() {
self.vault.active_profile = Some(restored.npub.clone());
}
}
Ok(restored)
}
/// Protect the vault with `new_password`, re-encrypting every stored key.
@ -426,11 +361,7 @@ impl App {
active_profile: profiles::active_summary(&self.vault),
profiles: profiles::summaries(&self.vault),
settings: self.settings.clone(),
undo_history: self
.undo_history
.iter()
.map(|deleted| deleted.summary.clone())
.collect(),
undo_history: self.undo_history.clone(),
last_publish: self.last_publish.clone(),
signer_mode: self.signer_mode,
}
@ -504,70 +435,6 @@ mod tests {
}
}
#[test]
fn signing_for_embedded_profile_yields_local_signing() {
let app = sample_app();
let npub = app.vault.profiles[0].public_key.clone();
let runtime = tokio::runtime::Runtime::new().unwrap();
let signing = runtime
.block_on(app.signing_for(&npub))
.expect("embedded profile must select local signing");
assert!(matches!(signing, crate::signer::Signing::Local(_)));
}
#[test]
fn signing_for_external_profile_without_connection_fails_closed() {
let mut app = sample_app();
// Mark the active profile as externally signed; no signer is
// connected (and none can be without a live NIP-46 session).
app.vault.profiles[0].signer_mode = SignerMode::Nip46Client;
let npub = app.vault.profiles[0].public_key.clone();
let runtime = tokio::runtime::Runtime::new().unwrap();
match runtime.block_on(app.signing_for(&npub)) {
Err(err) => assert_eq!(err.kind(), ErrorKind::ExternalSignerNotConnected),
Ok(_) => panic!("external profile with no signer must fail closed"),
}
}
#[test]
fn signing_active_requires_a_profile() {
let mut app = sample_app();
app.vault.active_profile = None;
let runtime = tokio::runtime::Runtime::new().unwrap();
match runtime.block_on(app.signing_active()) {
Err(err) => assert_eq!(err.kind(), ErrorKind::NoActiveProfile),
Ok(_) => panic!("no active profile must error"),
}
}
#[test]
fn store_remote_profile_creates_secretless_external_profile() {
use nostr::nips::nip19::ToBech32;
let mut vault = plaintext_vault();
let remote = Keys::generate();
let npub = remote.public_key().to_bech32().unwrap();
let summary = profiles::store_remote_profile(&mut vault, &npub, "Remote".to_string())
.expect("remote profile must be created");
assert_eq!(summary.npub, npub);
assert!(summary.is_active);
let stored = profiles::find_stored_profile(&vault, &npub).unwrap();
assert_eq!(stored.signer_mode, SignerMode::Nip46Client);
assert!(stored.secret_key.is_empty(), "no local secret for remote");
}
#[test]
fn store_remote_profile_refuses_to_clobber_local_profile() {
let mut vault = plaintext_vault();
let existing = vault.profiles[0].public_key.clone();
let err = profiles::store_remote_profile(&mut vault, &existing, "Hijack".to_string())
.expect_err("a local profile must not be converted silently");
assert!(err.message().contains("local profile"));
// Untouched: still embedded, secret intact, active unchanged.
let stored = profiles::find_stored_profile(&vault, &existing).unwrap();
assert_eq!(stored.signer_mode, SignerMode::Embedded);
assert!(!stored.secret_key.is_empty());
}
#[test]
fn set_password_encrypts_every_secret() {
let mut app = sample_app();
@ -755,32 +622,4 @@ mod tests {
assert_eq!(view.profiles.len(), 2);
assert!(view.profiles.iter().all(|p| p.npub.starts_with("npub1")));
}
#[test]
fn undo_delete_restores_working_profile_without_leaking_secret() {
let mut app = sample_app();
let target = app.vault.profiles[0].clone();
let secret = target.secret_key.clone();
let deleted = profiles::delete_profile_record(&mut app.vault, &target.public_key).unwrap();
app.undo_history.push(deleted);
assert_eq!(app.vault.profiles.len(), 1);
let restored = app.undo_delete().unwrap();
assert_eq!(restored.npub, target.public_key);
assert_eq!(app.vault.profiles.len(), 2);
let stored = app
.vault
.profiles
.iter()
.find(|p| p.public_key == target.public_key)
.unwrap();
assert_eq!(stored.secret_key, secret, "undo must restore the real key");
assert!(!stored.secret_key.is_empty());
// The UI-facing view carries summaries only — never secret material.
let view_json = serde_json::to_string(&app.state_view()).unwrap();
assert!(!view_json.contains(&secret));
assert!(app.undo_history.is_empty());
}
}

View file

@ -750,21 +750,10 @@ async fn run_sign_task(signer: Signer, app: Arc<tokio::sync::Mutex<App>>, uri: C
Ok(request) => request,
Err(_) => continue,
};
// Key-using methods wait for an explicit user approval before they
// run — unless the user granted this app standing "always allow"
// permission for that method. Everything else is answered immediately.
// Key-using methods wait for an explicit user approval before they run;
// everything else is answered immediately.
let response = if requires_approval(&request.method) {
let granted = {
let guard = app.lock().await;
guard
.vault
.has_signer_grant(&uri.peer.to_hex(), &request.method)
};
if granted {
approved_response(&keys, &request)
} else {
gated_response(&signer, &keys, &request).await
}
} else {
handle_request(&signer, &keys, &uri, &request)
};

View file

@ -44,11 +44,6 @@ pub struct FeedItem {
pub author: String,
/// Bech32 `npub` of the author, for display.
pub author_npub: String,
/// Author display name from their latest kind-0, when one was found.
/// `None` means "show the npub" — never an error.
pub author_name: Option<String>,
/// Author picture URL from their latest kind-0, when one was found.
pub author_picture: Option<String>,
pub content: String,
/// Unix timestamp the note was created.
pub created_at: u64,
@ -186,96 +181,7 @@ async fn aggregate_for(
}
client.disconnect().await;
let mut items = feed.finish();
// Best-effort author enrichment: one batched kind-0 lookup for every
// distinct author, so the feed can show names/pictures instead of bare
// npubs. Runs on a fresh throwaway pool (the client above is already
// disconnected); any failure just leaves the npub fallback in place.
attach_author_metadata(&mut items, &relay_urls).await;
Ok(items)
}
/// How long the batched kind-0 author lookup may take. Short on purpose:
/// names are decoration, and the notes themselves are already in hand.
const AUTHOR_TIMEOUT: Duration = Duration::from_secs(8);
/// Fill `author_name` / `author_picture` for feed items from the authors'
/// latest kind-0 metadata. One batched relay query for all distinct authors;
/// silently does nothing when relays are unreachable, so the npub fallback
/// always survives.
async fn attach_author_metadata(items: &mut [FeedItem], relay_urls: &[String]) {
use std::collections::HashSet;
let authors: Vec<PublicKey> = {
let mut seen = HashSet::new();
items
.iter()
.filter_map(|item| PublicKey::from_hex(&item.author).ok())
.filter(|key| seen.insert(key.to_hex()))
.collect()
};
if authors.is_empty() || relay_urls.is_empty() {
return;
}
let client = Client::builder()
.authenticator(SignerAuthenticator::new(Keys::generate()))
.build();
for url in relay_urls {
let _ = client.add_relay(url.as_str()).await;
}
client.connect().await;
let events = client
.fetch_events(
Filter::new()
.kind(Kind::Metadata)
.authors(authors)
.limit(100),
)
.timeout(AUTHOR_TIMEOUT)
.await
.ok();
client.disconnect().await;
if let Some(events) = events {
apply_author_metadata(items, events.into_iter());
}
}
/// Fold kind-0 events into feed items: newest event per author wins; the
/// display name prefers `display_name` over `name`; blank values stay `None`
/// so the UI falls back to the npub.
fn apply_author_metadata<I>(items: &mut [FeedItem], events: I)
where
I: Iterator<Item = Event>,
{
use std::collections::HashMap;
let mut best: HashMap<String, &Event> = HashMap::new();
let mut order: Vec<Event> = events.collect();
order.sort_by_key(|e| e.created_at);
for event in &order {
best.insert(event.pubkey.to_hex(), event);
}
for item in items.iter_mut() {
let Some(event) = best.get(&item.author) else {
continue;
};
let Ok(meta) = serde_json::from_str::<Metadata>(&event.content) else {
continue;
};
item.author_name = meta
.display_name
.as_deref()
.or(meta.name.as_deref())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
item.author_picture = meta
.picture
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
}
Ok(feed.finish())
}
/// URLs of every enabled relay.
@ -350,8 +256,6 @@ impl FeedItem {
id,
author: event.pubkey.to_hex(),
author_npub,
author_name: None,
author_picture: None,
content: event.content.trim().to_string(),
created_at: event.created_at.as_secs(),
relays: relay.map(|url| vec![url.to_string()]).unwrap_or_default(),
@ -396,8 +300,6 @@ mod tests {
id,
author: "a".into(),
author_npub: "npub1a".into(),
author_name: None,
author_picture: None,
content: "c".into(),
created_at: created,
relays: vec![],
@ -433,68 +335,6 @@ mod tests {
assert!(builder.items.is_empty());
}
#[test]
fn author_metadata_newest_wins_and_blanks_fall_back() {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async {
let alice = Keys::generate();
let bob = Keys::generate();
let mut items = vec![
FeedItem {
id: "1".into(),
author: alice.public_key().to_hex(),
author_npub: alice.public_key().to_bech32().unwrap(),
author_name: None,
author_picture: None,
content: "hi".into(),
created_at: 100,
relays: vec![],
},
FeedItem {
id: "2".into(),
author: bob.public_key().to_hex(),
author_npub: bob.public_key().to_bech32().unwrap(),
author_name: None,
author_picture: None,
content: "hey".into(),
created_at: 90,
relays: vec![],
},
];
// Older Alice metadata loses to the newer one; display_name wins.
let old = EventBuilder::new(
Kind::Metadata,
r#"{"name":"A","picture":"https://old.example/a.png"}"#.to_string(),
)
.custom_created_at(Timestamp::from(10))
.finalize_async(&alice)
.await
.unwrap();
let new = EventBuilder::new(
Kind::Metadata,
r#"{"name":"A","display_name":"Alice Liddell","picture":"https://new.example/a.png"}"#.to_string(),
)
.custom_created_at(Timestamp::from(20))
.finalize_async(&alice)
.await
.unwrap();
// Bob's metadata is blank: fallback stays npub.
let blank = EventBuilder::new(Kind::Metadata, r#"{"name":" "}"#.to_string())
.custom_created_at(Timestamp::from(30))
.finalize_async(&bob)
.await
.unwrap();
apply_author_metadata(&mut items, vec![old, new, blank].into_iter());
assert_eq!(items[0].author_name.as_deref(), Some("Alice Liddell"));
assert_eq!(
items[0].author_picture.as_deref(),
Some("https://new.example/a.png")
);
assert!(items[1].author_name.is_none());
assert!(items[1].author_picture.is_none());
});
}
#[tokio::test]
async fn limit_stops_collection_when_full() {
let mut builder = FeedBuilder::new(2, None);

View file

@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::sync::Mutex;
use crate::app::{App, Nip46ClientSignerHandle};
use crate::app::App;
use crate::errors::AppError;
use crate::feed;
use crate::profiles;
@ -165,24 +165,14 @@ pub enum Request {
uri: String,
label: String,
},
/// Start a client-initiated pairing: the reply carries `pairing_uri`
/// (a nostrconnect:// token) for the GUI to render as a QR the signer
/// app scans. Status polls report when the scan lands.
Nip46PairStart {
label: String,
},
/// Disconnect from the NIP-46 signer.
Nip46Disconnect,
/// Get NIP-46 connection status.
Nip46Status,
/// Approve/reject a pending NIP-46 request. `always = true` additionally
/// records a standing grant so this peer's future requests of the same
/// method run without prompting.
/// Approve/reject a pending NIP-46 request.
Nip46Approve {
id: String,
approved: bool,
#[serde(default)]
always: bool,
},
/// ===== LEGACY NIP-46 BUNKER (server mode) =====
/// Start the NIP-46 remote signer for a `nostrconnect://` link (acting as bunker).
@ -200,18 +190,6 @@ pub enum Request {
id: String,
/// `true` to run the request, `false` to reject it.
approved: bool,
/// `true` alongside `approved` records a standing "always allow"
/// grant for this peer + method.
#[serde(default)]
always: bool,
},
/// List standing "always allow" grants for apps using us as signer.
SignerGrantsList,
/// Revoke one standing grant (app pubkey + method). The field avoids the
/// name `method` because the request enum is internally tagged on it.
SignerGrantRevoke {
app_pubkey: String,
grant_method: String,
},
DeleteProfile {
npub: String,
@ -259,31 +237,6 @@ pub async fn serve() -> Result<(), AppError> {
// Shared state, so the NIP-46 signer's background task and the request loop
// both see the same vault (including its unlock key) without racing writes.
let app = Arc::new(Mutex::new(App::load()?));
// Restore saved NIP-46 signer sessions (spec: "reuse previously
// established signer sessions whenever possible"). When the vault is not
// password-encrypted the stored client keys resolve right now, so Amber
// never sees a fresh scan for an already-approved connection. An
// encrypted vault restores later, on UnlockVault, once the keys can be
// decrypted — this call simply no-ops until then. Fail-safe: a restore
// error must never prevent the backend from serving the GUI.
{
let restorable = {
let guard = app.lock().await;
matches!(guard.signer_mode, SignerMode::Nip46Client) && guard.vault.crypto.is_none()
};
if restorable {
// `ensure_nip46_signer` constructs the handle lazily; App::load
// leaves it None until the mode is touched, so go through it
// rather than reading the field.
if let Some(signer) = ensure_nip46_signer(&app).await {
if let Err(e) = signer.reactivate_saved_sessions().await {
eprintln!("[NIP46] session restore at startup failed: {e}");
}
}
}
}
let stdout = Arc::new(tokio::sync::Mutex::new(tokio::io::stdout()));
let stdin = tokio::io::stdin();
@ -378,46 +331,6 @@ fn error_code(err: &AppError) -> String {
.unwrap_or_else(|_| "error".to_string())
}
/// Lazily ensure the NIP-46 client signer handle exists and return it.
///
/// App startup defaults to `signer_mode = Nip46Client` but leaves the handle
/// `None` (only SignerModeSet builds one), so a fresh backend answers
/// nip46_* requests with "not initialized" until the user re-saves the mode.
/// Any nip46_* request initializes the handle when the mode is the NIP-46
/// client mode, matching what SignerModeSet would do. The guard is dropped
/// before returning so callers can await on the handle without deadlocking.
async fn ensure_nip46_signer(app: &Arc<Mutex<App>>) -> Option<Nip46ClientSignerHandle> {
let mut guard = app.lock().await;
if guard.nip46_signer.is_none() && matches!(guard.signer_mode, SignerMode::Nip46Client) {
guard.nip46_signer = Some(Arc::new(Nip46ClientSigner::new(app.clone())));
}
guard.nip46_signer.clone()
}
/// Translate the NIP-46 client signer's status into the shape the Signer
/// (bunker) screen consumes, so one live session serves both UIs.
fn nip46_status_as_bunker_json(status: &crate::signer::types::Nip46Status) -> serde_json::Value {
let phase = if status.connected {
"connected"
} else if status.pairing_uri.is_some() {
"connecting"
} else {
"stopped"
};
json!({
"phase": phase,
"peer": status.signer_pubkey,
"relays": status.relays,
"connectedRelays": status.connected_relays,
"error": status.error,
"pending": status.pending_approvals.iter().map(|p| json!({
"id": p.id,
"method": p.method,
"summary": p.summary,
})).collect::<Vec<_>>(),
})
}
/// Main request dispatcher.
async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Value, AppError> {
match request {
@ -483,58 +396,44 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
// NIP-46 client signer
Request::Nip46Connect { uri, label } => {
// Take the signer handle (initializing it if needed), then drop
// the guard before awaiting: connect() re-locks the App
// internally (to persist the connection and resolve its secret),
// so holding the guard across the await would deadlock.
let Some(signer) = ensure_nip46_signer(app).await else {
return Err(AppError::config(
"NIP-46 signer not initialized. Set signer mode to nip46 first.",
));
};
let guard = app.lock().await;
if let Some(signer) = &guard.nip46_signer {
let status = signer.connect(&uri, label).await?;
Ok(json!(status))
}
Request::Nip46PairStart { label } => {
// Same lock discipline as Nip46Connect: pairing persists to the
// vault from its background task, so the guard must not be held
// across the await.
let Some(signer) = ensure_nip46_signer(app).await else {
return Err(AppError::config(
} else {
Err(AppError::config(
"NIP-46 signer not initialized. Set signer mode to nip46 first.",
));
};
let status = signer.start_pairing(label).await?;
Ok(json!(status))
))
}
}
Request::Nip46Disconnect => {
let Some(signer) = ensure_nip46_signer(app).await else {
return Err(AppError::config("NIP-46 signer not initialized"));
};
let guard = app.lock().await;
if let Some(signer) = &guard.nip46_signer {
signer.disconnect().await?;
let status = signer.status().await;
Ok(json!(status))
} else {
Err(AppError::config("NIP-46 signer not initialized"))
}
}
Request::Nip46Status => {
let Some(signer) = ensure_nip46_signer(app).await else {
return Ok(json!({ "connected": false, "error": "Not initialized" }));
};
let guard = app.lock().await;
if let Some(signer) = &guard.nip46_signer {
let status = signer.status().await;
Ok(json!(status))
} else {
Ok(json!({ "connected": false, "error": "Not initialized" }))
}
Request::Nip46Approve {
id,
approved,
always,
} => {
let Some(signer) = ensure_nip46_signer(app).await else {
return Err(AppError::config("NIP-46 signer not initialized"));
};
signer
.respond_to_approval_with_always(&id, approved, always)
.await?;
}
Request::Nip46Approve { id, approved } => {
let guard = app.lock().await;
if let Some(signer) = &guard.nip46_signer {
signer.respond_to_approval(&id, approved).await?;
let status = signer.status().await;
Ok(json!(status))
} else {
Err(AppError::config("NIP-46 signer not initialized"))
}
}
// Legacy NIP-46 bunker (server mode)
@ -543,7 +442,7 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
if let Some(signer) = &guard.nip46_signer {
let status = signer.connect(&uri, "Legacy Bunker".to_string()).await?;
return Ok(nip46_status_as_bunker_json(&status));
return Ok(json!(status));
}
}
Err(AppError::config(
@ -556,7 +455,7 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
if let Some(signer) = &guard.nip46_signer {
signer.disconnect().await?;
let status = signer.status().await;
return Ok(nip46_status_as_bunker_json(&status));
return Ok(json!(status));
}
}
Err(AppError::config("Not in NIP-46 client mode"))
@ -566,43 +465,22 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
if let Some(signer) = &guard.nip46_signer {
let status = signer.status().await;
return Ok(nip46_status_as_bunker_json(&status));
return Ok(json!(status));
}
}
Err(AppError::config("Not in NIP-46 client mode"))
}
Request::SignerApprove {
id,
approved,
always,
} => {
Request::SignerApprove { id, approved } => {
let guard = app.lock().await;
if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
if let Some(signer) = &guard.nip46_signer {
signer
.respond_to_approval_with_always(&id, approved, always)
.await?;
signer.respond_to_approval(&id, approved).await?;
let status = signer.status().await;
return Ok(nip46_status_as_bunker_json(&status));
return Ok(json!(status));
}
}
Err(AppError::config("Not in NIP-46 client mode"))
}
Request::SignerGrantsList => {
let guard = app.lock().await;
Ok(json!(guard.vault.signer_grants))
}
Request::SignerGrantRevoke {
app_pubkey,
grant_method,
} => {
let mut guard = app.lock().await;
let removed = guard.vault.revoke_signer_grant(&app_pubkey, &grant_method);
if removed {
guard.save_vault()?;
}
Ok(json!({ "removed": removed }))
}
// Network-only requests (no shared state lock)
Request::RelayTest { url } => {
@ -727,23 +605,9 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
}
Request::PublishProfileMetadata { npub } => {
// Route through the profile's Signing source, exactly like
// PublishNote: an embedded profile signs locally, a paired
// profile's kind-0 is signed by the remote signer (Amber shows
// an approval prompt), and a disconnected one fails closed.
// The shared App guard is held across the round-trip; the
// signer's demux needs no App lock to deliver the response.
let signing = app.signing_for(&npub).await?;
let stored = profiles::find_stored_profile(&app.vault, &npub)?.clone();
let settings = app.settings.clone();
let report = profiles::publish_metadata_signed(
&settings,
&stored.label,
stored.picture.clone(),
stored.nip05.clone(),
&signing,
)
.await?;
let key = app.vault_key().copied();
let report =
profiles::publish_profile_metadata(&app.vault, &npub, key.as_ref(), &app.settings)?;
Ok(json!(report))
}
@ -782,17 +646,9 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
}
Request::PublishNote { content } => {
// Signer selection lives in App::signing_for: an embedded profile
// signs with the vault key; an external (NIP-46) profile's note
// round-trips to the connected signer, and an unconnected one
// fails closed — never with a silent fallback to the local key.
//
// As before, the shared App guard is held across the publish.
// The signer's background task needs no App lock to deliver the
// sign response (only audit paths take it, briefly), so the
// round-trip completes with the guard held.
let signing = app.signing_active().await?;
let report = publish::publish_signed(&app.settings, &content, &signing).await?;
let report =
publish::publish_active(&app.vault, &app.settings, &content, app.vault_key())
.await?;
let stored = crate::vault::StoredPublishReport {
event_id: report.event_id.clone(),
succeeded: report.succeeded.clone(),
@ -854,12 +710,6 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
if let Some(npub) = &app.vault.active_profile {
signer.set_active_profile(Some(npub.clone())).await;
}
// The vault key is now in memory: the stored NIP-46
// client keys decrypt, so a saved signer session can be
// re-dialed without a fresh scan (spec: session restore).
if let Err(e) = signer.reactivate_saved_sessions().await {
eprintln!("[NIP46] session restore after unlock failed: {e}");
}
}
}
Ok(json!(app.state_view()))
@ -915,9 +765,13 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
}
Request::UploadAuth { url, http_method } => {
let signing = app.signing_active().await?;
let authorization =
crate::uploads::nip98_authorization(&url, &http_method, &signing).await?;
let authorization = crate::uploads::nip98_authorization(
&app.vault,
&url,
&http_method,
app.vault_key(),
)
.await?;
Ok(json!({ "authorization": authorization }))
}
@ -939,9 +793,9 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
Ok(json!(app.settings))
}
Request::DeleteProfile { npub } => {
let deleted = profiles::delete_profile_record(&mut app.vault, &npub)?;
let deleted = profiles::delete_profile(&mut app.vault, &npub)?;
app.save_vault()?;
app.undo_history.push(deleted);
app.undo_history.push(deleted.clone());
Ok(json!(app.state_view()))
}
Request::UndoDelete => {

View file

@ -5,11 +5,11 @@ use keynectr::app::App;
use keynectr::bunker::Signer;
use keynectr::errors::{AppError, ErrorKind};
use keynectr::ipc;
use keynectr::profiles;
use keynectr::profiles::{self, ProfileSummary};
use keynectr::publish;
use keynectr::relays;
use keynectr::settings::Theme;
use keynectr::vault::{self, Vault};
use keynectr::vault::{self, StoredProfile, Vault};
const USAGE: &str = "\
keynectr <command> [args...]
@ -629,13 +629,26 @@ fn cli_info() -> Result<String, AppError> {
}
/// Delete a profile by npub, moving it to the undo stack.
/// Returns the full deleted record so the CLI can report it and push the
/// same entry the IPC path uses.
fn delete_profile_direct(
vault: &mut Vault,
npub: &str,
) -> Result<profiles::DeletedProfile, AppError> {
profiles::delete_profile_record(vault, npub)
/// Returns the deleted profile summary, or an error if not found.
fn delete_profile_direct(vault: &mut Vault, npub: &str) -> Result<ProfileSummary, AppError> {
let pos = vault
.profiles
.iter()
.position(|p| p.public_key == npub)
.ok_or_else(|| AppError::profile_not_found(npub))?;
let stored = vault.profiles.remove(pos);
// Clear the active_profile if it was the one deleted
if vault.active_profile.as_deref() == Some(npub) {
vault.active_profile = None;
}
Ok(ProfileSummary {
label: stored.label,
npub: stored.public_key,
created_at: stored.created_at,
is_active: false,
picture: stored.picture,
nip05: stored.nip05,
})
}
fn cli_delete_profile(args: &[String]) -> Result<String, AppError> {
@ -644,22 +657,37 @@ fn cli_delete_profile(args: &[String]) -> Result<String, AppError> {
}
let npub = args[2].clone();
let mut app = App::load()?;
// Capture the label BEFORE removal; the profile is gone afterwards.
let label = profiles::profile_label(&app.vault, &npub)
.unwrap_or(&npub)
.to_string();
let deleted = delete_profile_direct(&mut app.vault, &npub)?;
app.save_vault()?;
// Add to undo history (full record: undo restores a working profile).
app.undo_history.push(deleted);
// Add to undo history
app.undo_history.push(deleted.clone());
Ok(format!(
"Profile '{label}' deleted (npub: {npub}). Use 'undo-delete' to restore."
"Profile '{}' deleted (npub: {}). Use 'undo-delete' to restore.",
profiles::profile_label(&app.vault, &npub).unwrap_or(&npub),
npub
))
}
fn cli_undo_delete() -> Result<String, AppError> {
let mut app = App::load()?;
let restored = app.undo_delete()?;
if app.undo_history.is_empty() {
return Err(AppError::config("No profile deletions to undo."));
}
let restored = app.undo_history.pop().unwrap();
// Re-add the profile to the vault
let stored = StoredProfile {
label: restored.label.clone(),
public_key: restored.npub.clone(),
secret_key: "".to_string(),
created_at: restored.created_at,
picture: restored.picture,
nip05: restored.nip05,
signer_mode: keynectr::vault::SignerMode::Embedded,
};
app.vault.profiles.push(stored);
if app.vault.active_profile.is_none() {
app.vault.active_profile = Some(restored.npub.clone());
}
app.save_vault()?;
Ok(format!(
"Profile '{}' restored from undo stack.",

View file

@ -9,7 +9,7 @@ use crate::errors::AppError;
use crate::publish::RelayFailure;
use crate::relays;
use crate::settings::Settings;
use crate::vault::{unix_timestamp, SignerMode, StoredProfile, Vault};
use crate::vault::{unix_timestamp, StoredProfile, Vault};
/// A safe view of a profile that contains no secret key material.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
@ -190,68 +190,11 @@ pub struct MetadataPublishReport {
pub failed: Vec<RelayFailure>,
}
/// Publish a profile's stored label (and picture, when set) as kind 0 metadata
/// through an explicit [`Signing`] source (embedded or external).
///
/// This is what the GUI "Publish name" path uses: for a paired profile the
/// kind-0 event is signed by the remote signer (Amber shows an approval
/// prompt), so the name becomes visible network-wide instead of staying a
/// local vault label. A disconnected external profile fails closed with
/// `ExternalSignerNotConnected` — never with a silent local-key fallback.
pub async fn publish_metadata_signed(
settings: &Settings,
label: &str,
picture: Option<String>,
nip05: Option<String>,
signing: &crate::signer::Signing,
) -> Result<MetadataPublishReport, AppError> {
let relay_urls = relays::enabled_urls(settings);
if relay_urls.is_empty() {
return Err(AppError::no_enabled_relays());
}
let mut metadata = Metadata::new().name(label).display_name(label);
if let Some(picture) = &picture {
if let Ok(parsed) = Url::parse(picture) {
metadata = metadata.picture(parsed);
}
}
if let Some(nip05) = &nip05 {
metadata = metadata.nip05(nip05);
}
// Identity first (validates the signer controls this profile), then sign
// through `Signing` — local key or the NIP-46 round-trip.
let pubkey = signing.pubkey().await.map_err(AppError::from)?;
let unsigned = EventBuilder::new(Kind::Metadata, metadata.as_json()).finalize_unsigned(pubkey);
let signed = signing
.sign(unsigned)
.await
.map_err(|e| AppError::sign_failed(format!("{e}")))?;
// Local signing can answer NIP-42 AUTH challenges; with an external
// signer the app holds no key, so the pool opens without an
// authenticator and auth-gated relays report per-relay.
let client = match signing {
crate::signer::Signing::Local(keys) => {
relays::open_pool(keys.clone(), &relay_urls, None).await?
}
crate::signer::Signing::External { .. } => {
relays::open_pool_anon(&relay_urls, None).await?
}
};
let (succeeded, failed) =
crate::publish::send_to_all_relays(&client, relay_urls, &signed, "metadata").await;
Ok(MetadataPublishReport { succeeded, failed })
}
/// Publish a profile's stored label (and picture, when set) as kind 0 metadata
/// so external clients (Iris, Yakihonne, ...) display its name. Returns a
/// per-relay report.
///
/// `key` must be the unlocked vault key when the vault is password-protected.
///
/// Local-only: always signs from the vault. The CLI uses this (it has no
/// signer instances); GUI callers use [`publish_metadata_signed`] with an
/// [`App::signing_for`] source so paired profiles sign remotely.
pub fn publish_profile_metadata(
vault: &Vault,
npub: &str,
@ -355,39 +298,6 @@ pub fn rename_profile(
return Err(AppError::config("The profile name cannot be empty."));
}
// Remote (secretless) profiles have no local key to sign a kind-0
// metadata event with — and the kind-0 reroute through the external
// signer is still pending (P2). The label is still useful as the local
// display name, so rename it vault-side and report zero relays rather
// than failing the whole rename outright.
let is_remote = vault
.profiles
.iter()
.find(|p| p.public_key == npub)
.is_some_and(|p| {
p.signer_mode == SignerMode::Nip46Client || p.secret_key.trim().is_empty()
});
if is_remote {
let is_active = vault.active_profile.as_deref() == Some(npub);
let stored = find_profile_mut(vault, npub)?;
stored.label = trimmed.to_string();
let summary = ProfileSummary {
label: stored.label.clone(),
npub: stored.public_key.clone(),
created_at: stored.created_at,
is_active,
picture: stored.picture.clone(),
nip05: stored.nip05.clone(),
};
return Ok((
summary,
MetadataPublishReport {
succeeded: Vec::new(),
failed: Vec::new(),
},
));
}
// Resolve and sign before mutating so a locked vault or bad key changes
// nothing on disk.
let secret_hex = resolve_secret_key(vault, npub, key)?;
@ -553,58 +463,6 @@ pub fn find_stored_profile<'a>(
.ok_or_else(|| AppError::profile_not_found(npub))
}
/// Create or refresh the vault profile for a remote (NIP-46) identity.
///
/// When a NIP-46 client connection is established the identity lives on the
/// remote signer, but the user still needs a profile row so publishing has a
/// selection. The row is marked `Nip46Client` and carries **no secret key**
/// (there is none locally): every signing operation for it must go through
/// the connected signer, and key export refuses it. Re-connecting updates the
/// label and re-activates the profile rather than duplicating it.
pub fn store_remote_profile(
vault: &mut Vault,
npub: &str,
label: String,
) -> Result<ProfileSummary, AppError> {
// Validate the identity before writing anything.
PublicKey::parse(npub)
.map_err(|e| AppError::internal(format!("Remote signer identity is not valid: {e}")))?;
// An existing local profile must never be silently converted to remote:
// refuse *before* mutating if it carries a local secret.
if let Some(existing) = vault.profiles.iter().find(|p| p.public_key == npub) {
if existing.signer_mode != SignerMode::Nip46Client && !existing.secret_key.trim().is_empty()
{
return Err(AppError::config(
"That identity already exists as a local profile. Delete it first if you want to use an external signer for it.",
));
}
}
if let Some(existing) = vault.profiles.iter_mut().find(|p| p.public_key == npub) {
existing.signer_mode = SignerMode::Nip46Client;
existing.label = label;
} else {
vault.profiles.push(StoredProfile {
label: label.clone(),
public_key: npub.to_string(),
secret_key: String::new(), // no local key — identity lives on the signer
created_at: unix_timestamp()?,
picture: None,
nip05: None,
signer_mode: SignerMode::Nip46Client,
});
}
vault.active_profile = Some(npub.to_string());
let stored = find_profile(vault, npub)?;
Ok(ProfileSummary {
label: stored.label.clone(),
npub: stored.public_key.clone(),
created_at: stored.created_at,
is_active: true,
picture: stored.picture.clone(),
nip05: stored.nip05.clone(),
})
}
fn find_profile<'a>(vault: &'a Vault, npub: &str) -> Result<&'a StoredProfile, AppError> {
vault
.profiles
@ -651,7 +509,7 @@ fn publish_metadata_blocking(
/// Best-effort lookup of the account's latest kind-0 metadata. Import must
/// still succeed when relays are unavailable, so lookup failures are ignored.
pub fn fetch_profile_metadata(public_key: &PublicKey, relay_urls: &[String]) -> Option<Metadata> {
fn fetch_profile_metadata(public_key: &PublicKey, relay_urls: &[String]) -> Option<Metadata> {
if relay_urls.is_empty() {
return None;
}
@ -887,19 +745,9 @@ pub fn parse_secret_key(hex_str: &str) -> Result<SecretKey, AppError> {
SecretKey::from_slice(&bytes).map_err(|e| AppError::invalid_secret(format!("{e}")))
}
/// A profile removed from the vault together with everything needed to put it
/// back: the safe summary for the UI *and* the full `StoredProfile` including
/// its secret key material (plaintext or encrypted blob, exactly as stored).
#[derive(Debug, Clone)]
pub struct DeletedProfile {
pub summary: ProfileSummary,
pub stored: StoredProfile,
}
/// Delete a profile by npub, returning the deleted record for undo. The
/// returned `DeletedProfile` carries the real stored secret so undo can
/// restore a fully functional profile. Never serialize it to the UI.
pub fn delete_profile_record(vault: &mut Vault, npub: &str) -> Result<DeletedProfile, AppError> {
/// Delete a profile by npub, returning the deleted profile for undo.
/// The vault must not be encrypted, or the key must be provided.
pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result<ProfileSummary, AppError> {
let pos = vault
.profiles
.iter()
@ -909,22 +757,14 @@ pub fn delete_profile_record(vault: &mut Vault, npub: &str) -> Result<DeletedPro
if vault.active_profile.as_deref() == Some(npub) {
vault.active_profile = None;
}
let summary = ProfileSummary {
label: stored.label.clone(),
npub: stored.public_key.clone(),
Ok(ProfileSummary {
label: stored.label,
npub: stored.public_key,
created_at: stored.created_at,
is_active: false,
picture: stored.picture.clone(),
nip05: stored.nip05.clone(),
};
Ok(DeletedProfile { summary, stored })
}
/// Delete a profile by npub, returning only the safe summary. The secret key
/// is still recoverable in the returned value's vault removal only via
/// [`delete_profile_record`]; prefer that wherever an undo entry is kept.
pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result<ProfileSummary, AppError> {
delete_profile_record(vault, npub).map(|deleted| deleted.summary)
picture: stored.picture,
nip05: stored.nip05,
})
}
#[cfg(test)]
@ -1320,34 +1160,6 @@ mod tests {
assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound);
}
#[test]
fn rename_profile_updates_label_for_secretless_remote_profiles() {
// Paired (NIP-46) profiles carry no local key, so no kind-0 can be
// signed — but the local display label must still be renameable.
use nostr::nips::nip19::ToBech32;
let mut vault = Vault::empty();
let remote = Keys::generate();
let npub = remote.public_key().to_bech32().unwrap();
store_remote_profile(&mut vault, &npub, "Remote Signer".to_string()).unwrap();
let (renamed, report) = rename_profile(
&mut vault,
&npub,
"Phone Key".to_string(),
None,
&offline_settings(),
)
.expect("remote rename must succeed locally");
assert_eq!(renamed.label, "Phone Key");
assert_eq!(vault.profiles[0].label, "Phone Key");
assert!(
vault.profiles[0].secret_key.is_empty(),
"rename must not fabricate a secret"
);
assert!(report.succeeded.is_empty());
assert!(report.failed.is_empty());
}
#[test]
fn set_nip05_stores_identifier_and_skips_publish_without_relays() {
let mut vault = Vault::empty();

View file

@ -48,9 +48,6 @@ impl PublishReport {
/// Publish a text note with the active profile.
///
/// `key` must be the unlocked vault key when the vault is password-protected.
/// Always signs locally from the vault — used by the CLI, which has no signer
/// instances. GUI callers use [`publish_signed`] with an [`App::signing_for`]
/// signing source so external-signer profiles route to their remote signer.
pub async fn publish_active(
vault: &Vault,
settings: &Settings,
@ -64,17 +61,6 @@ pub async fn publish_active(
publish_with_keys(settings, content, &signing).await
}
/// Publish a text note through an explicit [`Signing`] source (embedded or
/// external). This is what the GUI publish path uses.
pub async fn publish_signed(
settings: &Settings,
content: &str,
signing: &Signing,
) -> Result<PublishReport, AppError> {
validate_content(content)?;
publish_with_keys(settings, content, signing).await
}
/// Publish a text note as a specific profile (used by the CLI).
///
/// `key` must be the unlocked vault key when the vault is password-protected.
@ -178,19 +164,33 @@ async fn publish_with_keys(
return Err(AppError::no_enabled_relays());
}
// The pubkey comes from the Signing itself. For an external signer this
// performs identity validation first: a signer that does not control the
// active profile's key fails here, before any event is built.
let pubkey = signing.pubkey().await.map_err(AppError::from)?;
// Extract &Keys from Signing::Local for EventBuilder operations.
// Currently Signing::Local is used from publish_active/publish_as,
// but the pattern supports External signers in the future.
let keys = match signing {
Signing::Local(k) => k,
Signing::External {
signer: _,
profile_pubkey: _,
} => {
return Err(AppError::sign_failed(
"External signer not yet supported in publish_with_keys",
));
}
};
// Build the unsigned event under the signing identity, then sign it
// through `Signing` (local key or the NIP-46 round-trip). This is the
// core reroute: the IPC layer never calls Keys::sign_event directly, and
// an external profile never needs a local secret.
// Build the unsigned event.
let builder = EventBuilder::new(Kind::TextNote, content.to_string()).tags(image_tags(content));
let unsigned = builder.finalize_unsigned(pubkey);
let unsigned = builder
.finalize_async(keys)
.await
.map_err(|e| AppError::sign_failed(format!("{e}")))?;
// Sign the event through the Signing trait (routes to Keys::sign_event or
// Signer::sign_event depending on the variant). This is the core refactor:
// the IPC layer no longer calls Keys::sign_event directly.
let signed = signing
.sign(unsigned)
.sign(unsigned.into())
.await
.map_err(|e| AppError::sign_failed(format!("{e}")))?;
@ -199,13 +199,7 @@ async fn publish_with_keys(
.to_bech32()
.map_err(|e| AppError::internal(format!("Could not encode the event id: {e}")))?;
// Local signing can answer NIP-42 AUTH challenges; with an external
// signer the app holds no key, so the pool opens without an
// authenticator and auth-gated relays report their rejection per-relay.
let client = match signing {
Signing::Local(keys) => relays::open_pool(keys.clone(), &relay_urls, None).await?,
Signing::External { .. } => relays::open_pool_anon(&relay_urls, None).await?,
};
let client = relays::open_pool(keys.clone(), &relay_urls, None).await?;
let (succeeded, failed) = send_to_all_relays(&client, relay_urls, &signed, "note").await;
if succeeded.is_empty() {

View file

@ -14,43 +14,6 @@ pub fn default_relays() -> Vec<RelayConfig> {
]
}
/// Extra relays always included in the NIP-46 *pairing* set (on top of the
/// user's enabled relays). Signer apps (Amber et al.) are free to pick any
/// relay listed in the nostrconnect:// URI, and relays differ wildly in
/// reliability for ephemeral kind-24133 traffic; listening on a few extra
/// well-known relays costs nothing and makes pairing robust whichever one
/// the signer happens to choose.
///
/// This set was curated with a live write+readback canary (Sep 22, 2026):
/// - wss://purplepag.es REJECTS kind 24133 ("blocked: kind 24133 is not
/// allowed") — a signer that picks it reports "connected" while its
/// connect event is thrown away, so it must never be in the pairing URI.
/// - wss://relay.nostr.band currently hangs the WebSocket handshake; it is
/// also pay-to-read. Dropped from the pairing set.
/// - wss://nos.lol and wss://relay.primal.net are the two relays with
/// PROVEN bidirectional ephemeral-24133 traffic in the live Sep 23 scans
/// (both stored Amber's connect reply AND our identity RPC).
/// - wss://relay.damus.io returned HTTP 503 to reads and answered connects
/// inconsistently during the same scans; while flapping it splits the
/// conversation across relays the signer never reads.
/// - wss://relay.snort.social accepts ephemeral 24133 publishes but does
/// not persist them; with damus out it adds no shared ground.
///
/// Sep 23 PM addendum: Amber 6.6.5 receives our pairing publishes on NONE of
/// the above (its activity log shows only the Connect ack; our get_public_key
/// RPCs are accepted by both relays but never answered). Amber's own issue
/// history documents NIP-46 working over relay.damus.io, and a same-day
/// write-canary from this network shows damus connected + accepting ephemeral
/// 24133 publishes, so damus rejoins the set FIRST — the signer is most
/// likely to meet us where its own client is proven to work.
pub fn pairing_relays() -> Vec<String> {
vec![
"wss://relay.damus.io".to_string(),
"wss://relay.primal.net".to_string(),
"wss://nos.lol".to_string(),
]
}
/// Validate that a string is a well-formed relay URL.
pub fn validate_url(raw: &str) -> Result<(), AppError> {
let cleaned = raw.trim().trim_end_matches('/');
@ -115,36 +78,12 @@ pub(crate) async fn open_pool(
keys: Keys,
relay_urls: &[String],
wait: Option<Duration>,
) -> Result<Client, AppError> {
open_pool_inner(Some(keys), relay_urls, wait).await
}
/// Open a relay pool with no signing identity.
///
/// Used when user content is signed by an external (NIP-46) signer: the app
/// holds no key to answer NIP-42 AUTH challenges with, so the pool is built
/// without an authenticator. Relays that demand auth will reject reads/
/// writes at the protocol level, which `send_to_all_relays` already reports
/// per-relay.
pub(crate) async fn open_pool_anon(
relay_urls: &[String],
wait: Option<Duration>,
) -> Result<Client, AppError> {
open_pool_inner(None, relay_urls, wait).await
}
async fn open_pool_inner(
keys: Option<Keys>,
relay_urls: &[String],
wait: Option<Duration>,
) -> Result<Client, AppError> {
// The authenticator answers NIP-42 AUTH challenges automatically on every
// path that opens a client (nostr-sdk >= 0.45 has no implicit signer).
let builder = match keys {
Some(keys) => Client::builder().authenticator(SignerAuthenticator::new(keys)),
None => Client::builder(),
};
let client = builder.build();
let client = Client::builder()
.authenticator(SignerAuthenticator::new(keys))
.build();
for url in relay_urls {
client
.add_relay(url.as_str())
@ -217,25 +156,6 @@ mod tests {
assert!(relays.iter().all(|r| r.enabled));
}
#[test]
fn pairing_relays_exclude_24133_blockers() {
// purplepag.es returns "blocked: kind 24133 is not allowed" and
// relay.nostr.band hangs the handshake (canary, Sep 22 2026). A
// signer that picks a blocking relay says "connected" while its
// connect event is discarded, so neither may appear in the URI.
let relays = pairing_relays();
assert!(!relays.iter().any(|r| r.contains("purplepag")));
assert!(!relays.iter().any(|r| r.contains("nostr.band")));
// Every entry is a well-formed wss URL and the set is deduped.
for url in &relays {
validate_url(url).expect("pairing relay must be a valid wss URL");
}
let mut sorted = relays.clone();
sorted.sort();
sorted.dedup();
assert_eq!(sorted.len(), relays.len());
}
#[test]
fn validate_url_accepts_wss_and_ws() {
assert!(validate_url("wss://relay.example.com").is_ok());

View file

@ -79,7 +79,7 @@ pub trait Signer: Send + Sync {
///
/// Returns an owned value because the NIP-46 client must lock an async
/// mutex internally.
async fn permissions(&self) -> Option<permissions::Nip46Permissions> {
fn permissions(&self) -> Option<permissions::Nip46Permissions> {
None
}
@ -88,40 +88,40 @@ pub trait Signer: Send + Sync {
/// The default implementation returns `true` when there are no
/// permissions (local signers) and `false` when permissions exist but
/// do not allow the operation.
async fn can_sign_event(&self, kind: u16) -> bool {
match self.permissions().await {
fn can_sign_event(&self, kind: u16) -> bool {
match self.permissions() {
Some(ref perms) => perms.is_sign_event_kind_allowed(kind),
None => true,
}
}
/// Whether `nip44_encrypt` is permitted.
async fn can_encrypt(&self) -> bool {
match self.permissions().await {
fn can_encrypt(&self) -> bool {
match self.permissions() {
Some(ref perms) => perms.is_encrypt_allowed(),
None => true,
}
}
/// Whether `nip44_decrypt` is permitted.
async fn can_decrypt(&self) -> bool {
match self.permissions().await {
fn can_decrypt(&self) -> bool {
match self.permissions() {
Some(ref perms) => perms.is_decrypt_allowed(),
None => true,
}
}
/// Whether `get_public_key` is permitted.
async fn can_get_public_key(&self) -> bool {
match self.permissions().await {
fn can_get_public_key(&self) -> bool {
match self.permissions() {
Some(ref perms) => perms.is_get_public_key_allowed(),
None => true,
}
}
/// Whether `get_relays` is permitted.
async fn can_get_relays(&self) -> bool {
match self.permissions().await {
fn can_get_relays(&self) -> bool {
match self.permissions() {
Some(ref perms) => perms.is_get_relays_allowed(),
None => true,
}
@ -130,7 +130,7 @@ pub trait Signer: Send + Sync {
/// Whether the connection is currently valid (not expired, not revoked).
///
/// Local signers always return `true`.
async fn is_connection_valid(&self) -> bool {
fn is_connection_valid(&self) -> bool {
true
}
}

File diff suppressed because it is too large Load diff

View file

@ -93,11 +93,6 @@ pub struct Nip46Status {
pub connected_relays: Vec<String>,
pub error: Option<String>,
pub pending_approvals: Vec<PendingApproval>,
/// While pairing (client-initiated flow) this carries the
/// `nostrconnect://` URI to render as a QR code for the signer to scan.
/// `None` once paired or when not pairing.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pairing_uri: Option<String>,
}
/// A pending approval request from the signer.

View file

@ -1,22 +1,21 @@
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use nostr::nips::nip98::{HttpData, HttpMethod};
use nostr_sdk::prelude::*;
use crate::crypto::VaultKey;
use crate::errors::{AppError, ErrorKind};
use crate::signer::Signing;
use crate::profiles;
/// Sign a NIP-98 HTTP auth event for `url` with the given [`Signing`] source
/// and return the `Authorization` header value (`Nostr <base64>`).
/// Sign a NIP-98 HTTP auth event for `url` with the active profile's key and
/// return the `Authorization` header value (`Nostr <base64>`).
///
/// This is what image hosts like nostr.build require before accepting an
/// upload. It follows the same signer selection as publishing: an embedded
/// profile signs with the vault key, an external profile round-trips the
/// auth event through its connected NIP-46 signer.
/// upload. Like publishing, it needs an unlocked vault when the vault is
/// password-protected.
pub async fn nip98_authorization(
vault: &crate::vault::Vault,
url: &str,
method: &str,
signing: &Signing,
key: Option<&VaultKey>,
) -> Result<String, AppError> {
let http_method = match method.to_ascii_uppercase().as_str() {
"GET" => HttpMethod::GET,
@ -34,57 +33,112 @@ pub async fn nip98_authorization(
let parsed_url = Url::parse(url)
.map_err(|e| AppError::config(format!("The upload URL is not valid: {e}")))?;
// Build the same event HttpData::to_authorization would build (kind
// 27235 with the u/method tags), but sign it through `Signing` so an
// external profile never needs a local secret.
let http_data = HttpData::new(parsed_url, http_method);
let pubkey = signing.pubkey().await.map_err(AppError::from)?;
let unsigned = IntoEventBuilder::into_event_builder(http_data).finalize_unsigned(pubkey);
let event = signing
.sign(unsigned)
let secret_hex = profiles::resolve_active_secret_key(vault, key)?;
let secret_key = profiles::parse_secret_key(&secret_hex)?;
let keys = Keys::new(secret_key);
let header = HttpData::new(parsed_url, http_method)
.to_authorization(&keys)
.await
.map_err(|e| AppError::sign_failed(format!("Could not sign the upload request: {e}")))?;
let encoded = B64.encode(event.as_json());
Ok(format!("Nostr {encoded}"))
Ok(header)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::settings::Settings;
use crate::vault::Vault;
fn local_signing() -> Signing {
Signing::Local(Keys::generate())
/// Settings with no relays so tests never touch the network.
fn offline_settings() -> Settings {
Settings {
relays: Vec::new(),
..Default::default()
}
}
fn vault_with_profile() -> Vault {
let mut vault = Vault::empty();
crate::profiles::create_profile(&mut vault, "A".to_string(), None, &offline_settings())
.unwrap();
vault
}
#[test]
fn missing_profile_errors() {
let vault = Vault::empty();
let runtime = tokio::runtime::Runtime::new().unwrap();
let err = runtime
.block_on(nip98_authorization(
&vault,
"https://nostr.build/api/v2/upload/files",
"POST",
None,
))
.expect_err("no active profile must error");
assert_eq!(err.kind(), ErrorKind::NoActiveProfile);
}
#[test]
fn unsupported_method_errors() {
let signing = local_signing();
let vault = vault_with_profile();
let runtime = tokio::runtime::Runtime::new().unwrap();
let err = runtime
.block_on(nip98_authorization(
&vault,
"https://example.com/upload",
"DELETE",
&signing,
None,
))
.expect_err("unsupported method must error");
assert_eq!(err.kind(), ErrorKind::Config);
}
#[test]
fn signs_a_nip98_auth_header() {
let signing = local_signing();
fn locked_encrypted_vault_errors() {
let mut vault = vault_with_profile();
vault.crypto = Some(crate::vault::VaultCrypto {
kdf: crate::vault::KdfParams {
algorithm: "argon2id".to_string(),
salt: "c2FsdA==".to_string(),
m_cost: 1,
t_cost: 1,
p_cost: 1,
},
verifier: "dmVyaWZpZXI=".to_string(),
});
vault.profiles[0].secret_key = "encrypted-blob".to_string();
let runtime = tokio::runtime::Runtime::new().unwrap();
let err = runtime
.block_on(nip98_authorization(
&vault,
"https://nostr.build/api/v2/upload/files",
"POST",
None,
))
.expect_err("locked vault must error");
assert_eq!(err.kind(), ErrorKind::VaultLocked);
}
#[test]
fn signs_a_nip98_auth_header_for_the_active_profile() {
let vault = vault_with_profile();
let runtime = tokio::runtime::Runtime::new().unwrap();
let header = runtime
.block_on(nip98_authorization(
&vault,
"https://nostr.build/api/v2/upload/files",
"POST",
&signing,
None,
))
.expect("local signing must produce a header");
.expect("valid profile must sign");
assert!(header.starts_with("Nostr "), "expected a Nostr auth header");
let encoded = header.trim_start_matches("Nostr ").trim();
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
let raw = B64
.decode(encoded)
.expect("the header payload must be base64");

View file

@ -120,37 +120,6 @@ pub struct Vault {
/// handled; a password-protected vault encrypts them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub connection_secrets: Vec<ConnectionSecret>,
/// Our NIP-46 client secret keys, one per connection.
///
/// The client keypair minted at pairing is not just a handshake nonce:
/// the signer (Amber) remembers it as our identity for the whole
/// connection, so re-dialing after an app restart MUST reuse the exact
/// same key or the signer answers a stranger and the session cannot be
/// reactivated without a fresh scan. Stored encrypted under the vault
/// key — exactly like [`Vault::connection_secrets`] — keyed by the same
/// [`crate::signer::VaultRef`], never inline on `Nip46Connection`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub connection_client_keys: Vec<ConnectionClientKey>,
/// Standing "always allow" grants for apps that use this machine as
/// their NIP-46 signer (bunker mode). Keyed by the *app's* pubkey and
/// the gated method it was allowed to run; a matching request skips the
/// approval prompt until revoked. Revoke by deleting the grant.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub signer_grants: Vec<SignerGrant>,
}
/// A standing permission for one connected NIP-46 app: "always allow" a
/// gated method instead of asking on every request (like Amber and other
/// signer apps do). Covers exactly one (app, method) pair.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SignerGrant {
/// The app's public key (hex) whose requests may skip the prompt.
pub app_pubkey: String,
/// The gated NIP-46 method covered: `sign_event`, `nip44_encrypt`,
/// or `nip44_decrypt`.
pub method: String,
/// Unix timestamp of when the user granted it.
pub created_at: u64,
}
/// An encrypted NIP-46 connection secret, keyed by its
@ -172,23 +141,6 @@ pub struct ConnectionSecret {
pub secret: String,
}
/// Our NIP-46 client secret key for one connection, keyed by its
/// [`crate::signer::VaultRef`] — the same keying as [`ConnectionSecret`].
///
/// The stored value is the 64-char hex secret key: plaintext when the vault
/// has no password, a base64 AES-256-GCM blob under the vault key when it
/// does. It is a credential: the signer recognizes our client pubkey for the
/// life of the connection, so this key is what makes reactivation-after-
/// restart possible without a fresh scan, and it must never be serialized
/// anywhere the UI or logs can see it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionClientKey {
/// The opaque reference (profile npub + remote signer pubkey).
pub ref_: crate::signer::VaultRef,
/// Our client secret key (hex) — plaintext or encrypted, per the vault.
pub secret_hex: String,
}
impl Vault {
/// A fresh, empty vault.
pub fn empty() -> Self {
@ -200,42 +152,9 @@ impl Vault {
profiles: Vec::new(),
nip46_connections: Vec::new(),
connection_secrets: Vec::new(),
connection_client_keys: Vec::new(),
signer_grants: Vec::new(),
}
}
/// Whether `app_pubkey` may run gated `method` without a prompt.
pub fn has_signer_grant(&self, app_pubkey: &str, method: &str) -> bool {
self.signer_grants
.iter()
.any(|g| g.app_pubkey == app_pubkey && g.method == method)
}
/// Record an "always allow" grant (idempotent).
pub fn grant_signer_method(
&mut self,
app_pubkey: &str,
method: &str,
) -> Result<(), crate::errors::AppError> {
if !self.has_signer_grant(app_pubkey, method) {
self.signer_grants.push(SignerGrant {
app_pubkey: app_pubkey.to_string(),
method: method.to_string(),
created_at: crate::vault::unix_timestamp()?,
});
}
Ok(())
}
/// Drop a standing grant; returns whether one was removed.
pub fn revoke_signer_grant(&mut self, app_pubkey: &str, method: &str) -> bool {
let before = self.signer_grants.len();
self.signer_grants
.retain(|g| !(g.app_pubkey == app_pubkey && g.method == method));
self.signer_grants.len() != before
}
pub fn has_profiles(&self) -> bool {
!self.profiles.is_empty()
}
@ -425,8 +344,6 @@ pub fn parse_vault(content: &str) -> Result<Vault, AppError> {
profiles,
nip46_connections: Vec::new(),
connection_secrets: Vec::new(),
connection_client_keys: Vec::new(),
signer_grants: Vec::new(),
});
}
@ -439,28 +356,35 @@ pub fn parse_vault(content: &str) -> Result<Vault, AppError> {
/// left untouched. Only profiles with the legacy `None` value (or
/// missing the field entirely) are assigned `Embedded`.
///
/// Missing `signer_mode` fields are assigned `Embedded` by the serde
/// default during deserialization, and every vault at the current
/// `VAULT_VERSION` serialises `signer_mode` explicitly — so once the
/// version bump below has been saved, re-saving adds nothing. A change
/// is therefore reported only when the version actually moves, instead
/// of on every load (the old unconditional `changed = true` made
/// `App::load` rewrite the vault on each start).
///
/// Returns `true` if the vault was migrated (i.e. it should be re-saved).
/// Returns `true` if any profiles were migrated (i.e. the vault should
/// be re-saved).
pub fn migrate_vault_signer_modes(vault: &mut Vault) -> bool {
// Profiles need no per-field work: the serde default already filled
// any missing `signer_mode` at parse time and serialization at the
// current version writes it explicitly.
let mut changed = false;
for _profile in &mut vault.profiles {
// The serde default already handles missing fields during
// deserialization, but once loaded, profiles that were stored
// before signer_mode was introduced will have the default value.
// We write it explicitly so the on-disk format is canonical.
//
// After the first save, every profile will have an explicit
// signer_mode and this becomes a no-op.
//
// We cannot distinguish "user explicitly set Embedded" from
// "serde defaulted to Embedded", so we always write it — this is
// safe because Embedded is the correct default and the write is
// idempotent.
changed = true;
}
// Also ensure the nip46_connections vector exists (serde default
// handles this during deserialization, but we normalise here too).
if vault.version < VAULT_VERSION {
vault.version = VAULT_VERSION;
changed = true;
}
// Legacy connections without profile_npub (None) are left as-is.
// Ownership cannot be reliably inferred from active_profile, so these
// connections remain unusable until the user re-creates them.
if vault.version < VAULT_VERSION {
vault.version = VAULT_VERSION;
return true;
}
false
changed
}
/// Store (or replace) a NIP-46 connection secret in the vault, keyed by an
@ -536,76 +460,6 @@ pub fn delete_connection_secret(vault: &mut Vault, ref_: &crate::signer::VaultRe
vault.connection_secrets.len() != before
}
/// Store (or replace) our NIP-46 client secret key for a connection.
///
/// Encryption behavior mirrors [`store_connection_secret`]: encrypted under
/// the vault key when the vault is password-protected (fail-closed on a
/// locked vault), plaintext otherwise. Replacing in place keeps one key per
/// connection so reconnects never strand a stale secret.
pub fn store_connection_client_key(
vault: &mut Vault,
key: Option<&crate::crypto::VaultKey>,
ref_: &crate::signer::VaultRef,
secret_hex: &str,
) -> Result<(), AppError> {
let stored = match &vault.crypto {
Some(_) => {
let key = key.ok_or_else(AppError::vault_locked)?;
crate::crypto::encrypt_secret(key, secret_hex)?
}
None => secret_hex.to_string(),
};
if let Some(entry) = vault
.connection_client_keys
.iter_mut()
.find(|c| c.ref_ == *ref_)
{
entry.secret_hex = stored;
} else {
vault.connection_client_keys.push(ConnectionClientKey {
ref_: ref_.clone(),
secret_hex: stored,
});
}
Ok(())
}
/// Resolve (decrypt) the stored NIP-46 client secret key for a reference.
///
/// Same contract as [`resolve_connection_secret`]: `Ok(None)` when nothing is
/// stored, fail-closed `Err(vault_locked)` when encrypted-but-locked, and a
/// [`Zeroizing`] plaintext on success.
pub fn resolve_connection_client_key(
vault: &Vault,
key: Option<&crate::crypto::VaultKey>,
ref_: &crate::signer::VaultRef,
) -> Result<Option<Zeroizing<String>>, AppError> {
let entry = vault
.connection_client_keys
.iter()
.find(|c| c.ref_ == *ref_);
let Some(entry) = entry else {
return Ok(None);
};
match &vault.crypto {
Some(_) => {
let key = key.ok_or_else(AppError::vault_locked)?;
let plain = crate::crypto::decrypt_secret(key, &entry.secret_hex)?;
Ok(Some(plain))
}
None => Ok(Some(Zeroizing::new(entry.secret_hex.clone()))),
}
}
/// Remove a stored NIP-46 client secret key (e.g. on disconnect/revoke).
///
/// Returns `true` when an entry was removed.
pub fn delete_connection_client_key(vault: &mut Vault, ref_: &crate::signer::VaultRef) -> bool {
let before = vault.connection_client_keys.len();
vault.connection_client_keys.retain(|c| c.ref_ != *ref_);
vault.connection_client_keys.len() != before
}
/// Persist the vault to the stable application-data location with
/// restrictive permissions.
pub fn save_vault(vault: &Vault) -> Result<(), AppError> {
@ -793,29 +647,6 @@ mod tests {
use crate::errors::ErrorKind;
use std::sync::atomic::{AtomicU32, Ordering};
#[test]
fn signer_grants_roundtrip_and_revoke() {
let mut vault = Vault::empty();
assert!(!vault.has_signer_grant("aa", "sign_event"));
vault.grant_signer_method("aa", "sign_event").unwrap();
vault.grant_signer_method("aa", "sign_event").unwrap(); // idempotent
vault.grant_signer_method("bb", "sign_event").unwrap();
assert_eq!(vault.signer_grants.len(), 2);
assert!(vault.has_signer_grant("aa", "sign_event"));
assert!(!vault.has_signer_grant("aa", "nip04_decrypt"));
let json = serde_json::to_string(&vault).unwrap();
let mut loaded: Vault = serde_json::from_str(&json).unwrap();
assert!(loaded.has_signer_grant("aa", "sign_event"));
assert!(loaded.has_signer_grant("bb", "sign_event"));
assert!(loaded.revoke_signer_grant("aa", "sign_event"));
assert!(!loaded.revoke_signer_grant("aa", "sign_event"));
assert!(!loaded.has_signer_grant("aa", "sign_event"));
assert!(loaded.has_signer_grant("bb", "sign_event"));
}
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn temp_vault_path() -> PathBuf {
@ -1021,14 +852,11 @@ mod tests {
});
let changed1 = migrate_vault_signer_modes(&mut vault);
// Vault::empty() is already at the current version with an
// explicit signer_mode, so migration must report no change —
// the old always-true return made App::load rewrite the vault
// on every start.
assert!(!changed1, "current-version vault should not report change");
assert!(changed1, "first migration should report change");
let changed2 = migrate_vault_signer_modes(&mut vault);
assert!(!changed2, "re-running migration stays a no-op");
let _changed2 = migrate_vault_signer_modes(&mut vault);
// The function always returns true because it normalises the version.
// The important thing is that running it twice doesn't corrupt data.
assert_eq!(vault.profiles[0].signer_mode, SignerMode::Embedded);
assert_eq!(vault.version, VAULT_VERSION);
}
@ -1179,90 +1007,4 @@ mod tests {
// Connection remains None - cannot infer ownership
assert!(vault.nip46_connections[0].profile_npub.is_none());
}
#[test]
fn connection_client_key_plaintext_roundtrip() {
let mut vault = Vault::empty();
let ref_ = crate::signer::VaultRef::new(Some("npub1bob".to_string()), "aabb".to_string());
assert!(resolve_connection_client_key(&vault, None, &ref_)
.unwrap()
.is_none());
store_connection_client_key(&mut vault, None, &ref_, "00ff").unwrap();
assert_eq!(
resolve_connection_client_key(&vault, None, &ref_)
.unwrap()
.unwrap()
.as_str(),
"00ff"
);
// Storing again replaces (one entry per ref).
store_connection_client_key(&mut vault, None, &ref_, "11ee").unwrap();
assert_eq!(vault.connection_client_keys.len(), 1);
assert_eq!(
resolve_connection_client_key(&vault, None, &ref_)
.unwrap()
.unwrap()
.as_str(),
"11ee"
);
assert!(delete_connection_client_key(&mut vault, &ref_));
assert!(!delete_connection_client_key(&mut vault, &ref_));
assert!(resolve_connection_client_key(&vault, None, &ref_)
.unwrap()
.is_none());
}
#[test]
fn connection_client_key_encrypted_when_vault_locked() {
use crate::crypto;
let mut vault = Vault::empty();
let salt = crypto::generate_salt().unwrap();
let key = crypto::derive_key("pw", &salt, 1024, 1, 1).unwrap();
vault.crypto = Some(VaultCrypto {
kdf: crate::vault::KdfParams {
algorithm: "argon2id".to_string(),
m_cost: 1024,
t_cost: 1,
p_cost: 1,
salt: B64.encode(salt),
},
verifier: crypto::make_verifier(&key).unwrap(),
});
let ref_ = crate::signer::VaultRef::new(Some("npub1bob".to_string()), "aabb".to_string());
store_connection_client_key(&mut vault, Some(&key), &ref_, "deadbeef").unwrap();
// The on-disk form must not carry the plaintext key.
let serialized = serde_json::to_string(&vault).unwrap();
assert!(!serialized.contains("deadbeef"));
// Locked vault: fail closed.
let err = resolve_connection_client_key(&vault, None, &ref_).unwrap_err();
assert_eq!(err.kind(), ErrorKind::VaultLocked);
// Unlocked: exact roundtrip.
assert_eq!(
resolve_connection_client_key(&vault, Some(&key), &ref_)
.unwrap()
.unwrap()
.as_str(),
"deadbeef"
);
}
#[test]
fn connection_client_keys_absent_in_legacy_vault() {
// A vault JSON without the new field must still parse (serde default).
let json = r#"{
"version": 2,
"profiles": [],
"nip46_connections": []
}"#;
let vault: Vault = serde_json::from_str(json).unwrap();
assert!(vault.connection_client_keys.is_empty());
}
}

File diff suppressed because it is too large Load diff