Compare commits
74 commits
1d5940fb82
...
704addc773
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
704addc773 | ||
|
|
bc736ff339 | ||
|
|
b5c61deec6 | ||
|
|
fc2fe93161 | ||
|
|
e8d11701a3 | ||
|
|
01ce5de417 | ||
|
|
929d791240 | ||
|
|
a809a67023 | ||
|
|
2ef2cd1181 | ||
|
|
5b13162a36 | ||
|
|
224df38598 | ||
|
|
a6a4e6f485 | ||
|
|
aa3c514e96 | ||
|
|
0982dad566 | ||
|
|
73bf17c3c8 | ||
|
|
f53bc56497 | ||
|
|
8117b7913b | ||
|
|
a76d8dff4e | ||
|
|
cd8b6711a4 | ||
|
|
6f4dbb2ca1 | ||
|
|
327bf0ffe0 | ||
|
|
3d1c30662a | ||
|
|
5714349ea3 | ||
|
|
6ebc364fcf | ||
|
|
ca62111092 | ||
|
|
a8d112b368 | ||
|
|
fb149763d5 | ||
|
|
e02417b18c | ||
|
|
5456835cde | ||
|
|
8f055f71bf | ||
|
|
2bc6321d58 | ||
|
|
be51f67797 | ||
|
|
88815710ba | ||
|
|
9cfc1cfeb1 | ||
|
|
2e98e69ddf | ||
|
|
13a66f28d8 | ||
|
|
963b740992 | ||
|
|
c789cb4784 | ||
|
|
2e5938a3fa | ||
|
|
d4d87b85b6 | ||
|
|
e6ddc53261 | ||
|
|
f6bf6a979a | ||
|
|
4e79d7ed00 | ||
|
|
deeb4f9e88 | ||
|
|
d52fa58354 | ||
|
|
5f9c64fb82 | ||
|
|
edd4e565fb | ||
|
|
a28d76e7d0 | ||
|
|
21c522ba99 | ||
|
|
e97d8a75f0 | ||
|
|
188b2eb61d | ||
|
|
aedde8ffb8 | ||
|
|
759b5ddfc9 | ||
|
|
5aa122d92d | ||
|
|
049219b0f1 | ||
|
|
f59c2b1b45 | ||
|
|
937fcc67cb | ||
|
|
22d7c01193 | ||
|
|
81b082f238 | ||
|
|
286bbcaa04 | ||
|
|
3d5302fbf3 | ||
|
|
9cfab4bce6 | ||
|
|
dc58f3889f | ||
|
|
c5004ebb26 | ||
|
|
38499d4506 | ||
|
|
764406e5a9 | ||
|
|
85756df081 | ||
|
|
84f11e615d | ||
|
|
c09670530c | ||
|
|
f917e5ecfd | ||
|
|
6e5d80ba0b | ||
|
|
1af79d81cd | ||
|
|
8780ef3fbb | ||
|
|
d101b8e236 |
45 changed files with 7379 additions and 585 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -6,6 +6,11 @@ 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
3
Cargo.lock
generated
|
|
@ -1169,6 +1169,7 @@ dependencies = [
|
|||
"argon2",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"futures-util",
|
||||
"getrandom 0.2.17",
|
||||
"hex",
|
||||
"keyring",
|
||||
|
|
@ -1179,6 +1180,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"uuid",
|
||||
"zeroize",
|
||||
]
|
||||
|
|
@ -1281,6 +1283,7 @@ version = "0.45.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0ba32ce43631188586469ba1a4c40bcfa63641f1ad5de89ef77f74d801cc17a"
|
||||
dependencies = [
|
||||
"aes 0.8.4",
|
||||
"base64",
|
||||
"bech32",
|
||||
"bip39",
|
||||
|
|
|
|||
10
Cargo.toml
10
Cargo.toml
|
|
@ -4,7 +4,7 @@ version = "0.1.0"
|
|||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
nostr = { version = "0.45", features = ["nip44", "nip98"] }
|
||||
nostr = { version = "0.45", features = ["nip44", "nip46", "nip98"] }
|
||||
nostr-sdk = "0.45"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
|
@ -20,3 +20,11 @@ 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"
|
||||
|
|
|
|||
|
|
@ -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 — 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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.
|
||||
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.
|
||||
|
||||
## Positioning
|
||||
**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.
|
||||
**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.
|
||||
|
||||
## 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 never leave** — every feature must preserve the Rust/renderer boundary and approval gates; convenience never bypasses explicit consent.
|
||||
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.
|
||||
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.
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -1,7 +1,8 @@
|
|||
# Nostr Feed Manager
|
||||
|
||||
> A friendly Linux desktop app for managing Nostr profiles, publishing notes, and acting as a
|
||||
> **NIP-46 remote signer** — all while your private keys never leave your machine.
|
||||
> **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.
|
||||
|
||||
[]()
|
||||
[](LICENSE)
|
||||
|
|
@ -258,8 +259,13 @@ 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.
|
||||
- **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.
|
||||
- **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`.
|
||||
|
|
|
|||
|
|
@ -524,12 +524,15 @@ 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',
|
||||
|
|
|
|||
218
frontend/package-lock.json
generated
218
frontend/package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
|||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"nostr-tools": "^2.25.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
|
|
@ -19,6 +20,7 @@
|
|||
"@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",
|
||||
|
|
@ -1537,6 +1539,16 @@
|
|||
"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",
|
||||
|
|
@ -2028,7 +2040,6 @@
|
|||
"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"
|
||||
|
|
@ -2038,7 +2049,6 @@
|
|||
"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"
|
||||
|
|
@ -2516,6 +2526,15 @@
|
|||
"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",
|
||||
|
|
@ -2608,7 +2627,6 @@
|
|||
"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"
|
||||
|
|
@ -2621,7 +2639,6 @@
|
|||
"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": {
|
||||
|
|
@ -2769,6 +2786,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
|
@ -2898,6 +2924,12 @@
|
|||
"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",
|
||||
|
|
@ -3187,7 +3219,6 @@
|
|||
"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": {
|
||||
|
|
@ -3761,7 +3792,6 @@
|
|||
"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.*"
|
||||
|
|
@ -4218,7 +4248,6 @@
|
|||
"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"
|
||||
|
|
@ -5250,6 +5279,15 @@
|
|||
"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",
|
||||
|
|
@ -5280,7 +5318,6 @@
|
|||
"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"
|
||||
|
|
@ -5394,6 +5431,15 @@
|
|||
"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",
|
||||
|
|
@ -5601,6 +5647,141 @@
|
|||
"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",
|
||||
|
|
@ -5693,7 +5874,6 @@
|
|||
"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"
|
||||
|
|
@ -5709,6 +5889,12 @@
|
|||
"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",
|
||||
|
|
@ -5936,6 +6122,12 @@
|
|||
"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",
|
||||
|
|
@ -6063,7 +6255,6 @@
|
|||
"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",
|
||||
|
|
@ -6078,7 +6269,6 @@
|
|||
"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"
|
||||
|
|
@ -6764,6 +6954,12 @@
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"nostr-tools": "^2.25.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
|
|
@ -38,6 +39,7 @@
|
|||
"@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",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ 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';
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ 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);
|
||||
|
|
@ -68,7 +70,11 @@ function Shell() {
|
|||
</div>
|
||||
)}
|
||||
{screen === 'home' && (
|
||||
<HomeScreen onNavigate={setScreen} onCreateProfile={() => setCreateOpen(true)} />
|
||||
<HomeScreen
|
||||
onNavigate={setScreen}
|
||||
onCreateProfile={() => setCreateOpen(true)}
|
||||
onImportProfile={() => setImportOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{screen === 'feed' && <FeedScreen onNavigate={setScreen} />}
|
||||
{screen === 'profiles' && <ProfilesScreen onCreateProfile={() => setCreateOpen(true)} />}
|
||||
|
|
@ -79,6 +85,7 @@ 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -89,9 +89,13 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
RelayTestResult,
|
||||
RevealedKey,
|
||||
Settings,
|
||||
SignerGrant,
|
||||
SignerMode,
|
||||
SignerStatus,
|
||||
UpdateApplyReport,
|
||||
|
|
@ -118,17 +119,26 @@ 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) =>
|
||||
call<Nip46SignerStatus>('nip46_approve', { id, approved }),
|
||||
nip46Approve: (id: string, approved: boolean, always = false) =>
|
||||
call<Nip46SignerStatus>('nip46_approve', { id, approved, always }),
|
||||
|
||||
// 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) =>
|
||||
call<SignerStatus>('signer_approve', { id, approved }),
|
||||
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,
|
||||
}),
|
||||
|
||||
deleteProfile: (npub: string) => call<AppState>('delete_profile', { npub }),
|
||||
undoDelete: () => call<AppState>('undo_delete'),
|
||||
|
|
|
|||
|
|
@ -322,14 +322,21 @@ export class SignerManager {
|
|||
|
||||
// ==================== CLIENT MODE (connect TO external signer) ====================
|
||||
|
||||
/** Parse nostrconnect:// URI from external signer (Amber, Nostr Connect, etc.) */
|
||||
/** Parse nostrconnect:// or bunker:// URI from external signer (Amber, Nostr Connect, etc.) */
|
||||
parseExternalSignerURI(uri: string): ExternalSignerConnection {
|
||||
if (!uri.startsWith('nostrconnect://')) {
|
||||
throw new SignerError('INVALID_NOSTRCONNECT_URI', 'URI must start with nostrconnect://');
|
||||
const isBunker = uri.startsWith('bunker://');
|
||||
if (!uri.startsWith('nostrconnect://') && !isBunker) {
|
||||
throw new SignerError(
|
||||
'INVALID_NOSTRCONNECT_URI',
|
||||
'URI must start with nostrconnect:// or bunker://',
|
||||
);
|
||||
}
|
||||
|
||||
const [authority, queryString] = uri.slice('nostrconnect://'.length).split('?');
|
||||
const signerPubkey = authority;
|
||||
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 params = new URLSearchParams(queryString || '');
|
||||
const relays = params.getAll('relay');
|
||||
const secret = params.get('secret') || undefined;
|
||||
|
|
|
|||
|
|
@ -29,6 +29,16 @@ 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;
|
||||
|
|
@ -63,6 +73,8 @@ 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. */
|
||||
|
|
@ -130,6 +142,10 @@ 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;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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';
|
||||
|
|
@ -11,14 +12,15 @@ interface CreateProfileModalProps {
|
|||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Phase = 'form' | 'creating' | 'success';
|
||||
type Phase = 'choice' | 'pairing' | 'paired' | 'local' | 'creating' | 'success';
|
||||
|
||||
export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
|
||||
const { state, createProfile } = useApp();
|
||||
const { state, createProfile, nip46PairStart, nip46Status, nip46Disconnect, refresh } = useApp();
|
||||
const [label, setLabel] = useState('');
|
||||
const [phase, setPhase] = useState<Phase>('form');
|
||||
const [phase, setPhase] = useState<Phase>('choice');
|
||||
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;
|
||||
|
|
@ -26,15 +28,88 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
|
|||
useEffect(() => {
|
||||
if (open) {
|
||||
setLabel('');
|
||||
setPhase('form');
|
||||
setPhase('choice');
|
||||
setError(null);
|
||||
setCreatedNpub(null);
|
||||
// Let the modal mount before focusing.
|
||||
setPairingQr(null);
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}, [open]);
|
||||
|
||||
// Let the local-form input take focus once that step mounts.
|
||||
useEffect(() => {
|
||||
if (phase === 'local') {
|
||||
const frame = requestAnimationFrame(() => inputRef.current?.focus());
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}
|
||||
return undefined;
|
||||
}, [open]);
|
||||
}, [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]);
|
||||
|
||||
const canSubmit = label.trim().length > 0 && phase !== 'creating';
|
||||
|
||||
|
|
@ -50,13 +125,157 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
|
|||
setCreatedNpub(summary.npub);
|
||||
setPhase('success');
|
||||
} catch (err) {
|
||||
setPhase('form');
|
||||
setPhase('local');
|
||||
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="Create a Nostr profile" onClose={onClose}>
|
||||
<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'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}
|
||||
>
|
||||
{phase === 'success' && createdNpub ? (
|
||||
<div className="create-success">
|
||||
<div className="create-success-icon" aria-hidden="true">
|
||||
|
|
@ -115,8 +334,12 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
|
|||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<Button variant="ghost" onClick={onClose} disabled={phase === 'creating'}>
|
||||
Cancel
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setPhase('choice')}
|
||||
disabled={phase === 'creating'}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
|
|
|
|||
|
|
@ -219,12 +219,20 @@ 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={shortenNpub(item.author_npub, shorten)} />
|
||||
<Avatar
|
||||
npub={item.author_npub}
|
||||
label={item.author_name ?? shortenNpub(item.author_npub, shorten)}
|
||||
picture={item.author_picture ?? null}
|
||||
/>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -15,9 +15,10 @@ import { useApp } from '../state/AppProvider';
|
|||
interface HomeScreenProps {
|
||||
onNavigate: (screen: Screen) => void;
|
||||
onCreateProfile: () => void;
|
||||
onImportProfile: () => void;
|
||||
}
|
||||
|
||||
export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
|
||||
export function HomeScreen({ onNavigate, onCreateProfile, onImportProfile }: HomeScreenProps) {
|
||||
const { state, selectProfile } = useApp();
|
||||
const { publications, fullyPublished, loading, error } = useProfilePublications();
|
||||
const [selecting, setSelecting] = useState<string | null>(null);
|
||||
|
|
@ -43,16 +44,27 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
|
|||
title="Welcome to Keynctr"
|
||||
description={
|
||||
<span>
|
||||
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.
|
||||
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’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.
|
||||
</span>
|
||||
}
|
||||
action={
|
||||
<div className="onboarding-actions">
|
||||
<Button variant="primary" onClick={onCreateProfile}>
|
||||
<Icon name="plus" size={18} />
|
||||
Create your first profile
|
||||
Create a new 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 />
|
||||
|
|
|
|||
|
|
@ -576,7 +576,9 @@ function RenameModal({
|
|||
npub: target.npub,
|
||||
perform: () => renameProfile(target.npub, trimmed),
|
||||
successMessage: (report) =>
|
||||
report.failed.length === 0
|
||||
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
|
||||
? `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,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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';
|
||||
|
|
@ -14,6 +15,7 @@ export function SignerModeScreen() {
|
|||
embeddedSignerStatus,
|
||||
nip46Status,
|
||||
nip46Connect,
|
||||
nip46PairStart,
|
||||
nip46Disconnect,
|
||||
nip46Approve,
|
||||
embeddedSignerApprove,
|
||||
|
|
@ -30,14 +32,15 @@ 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
|
||||
|
|
@ -53,14 +56,24 @@ 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) {
|
||||
|
|
@ -96,12 +109,18 @@ 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.');
|
||||
}
|
||||
|
|
@ -112,8 +131,12 @@ export function SignerModeScreen() {
|
|||
|
||||
const handleNip46Connect = useCallback(async () => {
|
||||
const trimmed = uri.trim();
|
||||
if (!trimmed.startsWith('nostrconnect://')) {
|
||||
setError('Paste a nostrconnect:// link from Amber, Nostr Connect, or your bunker.');
|
||||
// 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.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
|
|
@ -129,6 +152,58 @@ 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 {
|
||||
|
|
@ -153,10 +228,10 @@ export function SignerModeScreen() {
|
|||
);
|
||||
|
||||
const handleNip46Approve = useCallback(
|
||||
async (id: string, approved: boolean) => {
|
||||
async (id: string, approved: boolean, always = false) => {
|
||||
setError(null);
|
||||
try {
|
||||
const status = await nip46Approve(id, approved);
|
||||
const status = await nip46Approve(id, approved, always);
|
||||
setNip46StatusState(status);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
|
|
@ -183,12 +258,16 @@ 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 {
|
||||
|
|
@ -237,11 +316,15 @@ 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>
|
||||
|
|
@ -249,12 +332,20 @@ 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>
|
||||
)}
|
||||
|
|
@ -269,7 +360,9 @@ 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"
|
||||
|
|
@ -282,9 +375,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>
|
||||
|
|
@ -292,12 +385,16 @@ 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"
|
||||
|
|
@ -310,8 +407,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'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's encrypted vault; other clients connect via{' '}
|
||||
<code>nostrconnect://</code>.
|
||||
</p>
|
||||
<ul className="mode-features">
|
||||
|
|
@ -320,12 +417,16 @@ 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"
|
||||
|
|
@ -338,8 +439,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>
|
||||
|
|
@ -347,14 +448,18 @@ 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 && (
|
||||
|
|
@ -364,7 +469,8 @@ 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>}
|
||||
|
|
@ -379,12 +485,14 @@ 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)}>
|
||||
|
|
@ -404,16 +512,24 @@ 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>
|
||||
|
|
@ -427,10 +543,22 @@ 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="danger" onClick={() => void handleNip46Approve(r.id, false)}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void handleNip46Approve(r.id, true, true)}
|
||||
>
|
||||
Always allow
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => void handleNip46Approve(r.id, false)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -439,12 +567,59 @@ 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' ? 'nostrconnect://… (from Amber / Nostr Connect)' : 'nostrconnect://…'}
|
||||
placeholder={
|
||||
mode === 'nip46_client'
|
||||
? 'bunker://… or nostrconnect://… (from Amber / Nostr Connect)'
|
||||
: 'nostrconnect://…'
|
||||
}
|
||||
value={uri}
|
||||
onChange={(e) => setUri(e.target.value)}
|
||||
autoComplete="off"
|
||||
|
|
@ -452,17 +627,51 @@ export function SignerModeScreen() {
|
|||
/>
|
||||
<p className="hint">
|
||||
{mode === 'nip46_client'
|
||||
? 'In Amber / Nostr Connect, choose “Connect external app” and paste the nostrconnect:// link here.'
|
||||
? '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.'
|
||||
: '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">
|
||||
<Button variant="primary" loading={connecting} disabled={!uri.trim()} onClick={() => void handleNip46Connect()}>
|
||||
{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()}
|
||||
>
|
||||
<Icon name="key" size={16} /> Connect
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => void refreshStatus()} disabled={loading}>
|
||||
|
|
@ -482,15 +691,16 @@ 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's vault, you approve each
|
||||
remote request.
|
||||
<strong>NIP-46 Bunker (Moderate):</strong> Key in this app'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>
|
||||
|
|
|
|||
|
|
@ -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 { SignerStatus } from '../lib/types';
|
||||
import type { SignerGrant, SignerStatus } from '../lib/types';
|
||||
import { useApp } from '../state/AppProvider';
|
||||
|
||||
const EMPTY_STATUS: SignerStatus = {
|
||||
|
|
@ -18,8 +18,17 @@ const EMPTY_STATUS: SignerStatus = {
|
|||
};
|
||||
|
||||
export function SignerScreen() {
|
||||
const { state, signerConnect, signerDisconnect, signerStatus, signerApprove } = useApp();
|
||||
const {
|
||||
state,
|
||||
signerConnect,
|
||||
signerDisconnect,
|
||||
signerStatus,
|
||||
signerApprove,
|
||||
signerGrantsList,
|
||||
signerGrantRevoke,
|
||||
} = 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);
|
||||
|
|
@ -28,6 +37,7 @@ export function SignerScreen() {
|
|||
const refresh = async () => {
|
||||
try {
|
||||
setStatus(await signerStatus());
|
||||
setGrants(await signerGrantsList());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
|
|
@ -82,10 +92,21 @@ export function SignerScreen() {
|
|||
}
|
||||
};
|
||||
|
||||
const onApprove = async (id: string, approved: boolean) => {
|
||||
const onApprove = async (id: string, approved: boolean, always = false) => {
|
||||
setError(null);
|
||||
try {
|
||||
setStatus(await signerApprove(id, approved));
|
||||
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());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
|
@ -200,6 +221,13 @@ 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
|
||||
|
|
@ -211,6 +239,33 @@ 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>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import type {
|
|||
RelayTestResult,
|
||||
RevealedKey,
|
||||
Settings,
|
||||
SignerGrant,
|
||||
SignerMode,
|
||||
SignerStatus,
|
||||
Theme,
|
||||
|
|
@ -79,14 +80,17 @@ 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) => Promise<Nip46SignerStatus>;
|
||||
nip46Approve: (id: string, approved: boolean, always?: boolean) => Promise<Nip46SignerStatus>;
|
||||
// Legacy NIP-46 bunker (deprecated)
|
||||
signerConnect: (uri: string) => Promise<SignerStatus>;
|
||||
signerDisconnect: () => Promise<SignerStatus>;
|
||||
signerStatus: () => Promise<SignerStatus>;
|
||||
signerApprove: (id: string, approved: boolean) => Promise<SignerStatus>;
|
||||
signerApprove: (id: string, approved: boolean, always?: boolean) => Promise<SignerStatus>;
|
||||
signerGrantsList: () => Promise<SignerGrant[]>;
|
||||
signerGrantRevoke: (appPubkey: string, grantMethod: string) => Promise<{ removed: boolean }>;
|
||||
deleteProfile: (npub: string) => Promise<AppState>;
|
||||
undoDelete: () => Promise<AppState>;
|
||||
clearLastDeleted: () => void;
|
||||
|
|
@ -103,7 +107,17 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
const refresh = useCallback(async () => {
|
||||
const fresh = await api.getState();
|
||||
setState(fresh);
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -137,6 +151,18 @@ 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);
|
||||
|
|
@ -264,8 +290,15 @@ 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) => api.nip46Approve(id, approved),
|
||||
(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),
|
||||
[],
|
||||
);
|
||||
|
||||
|
|
@ -284,8 +317,7 @@ 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(), []);
|
||||
|
|
@ -351,6 +383,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
embeddedSignerStatus,
|
||||
embeddedSignerApprove,
|
||||
nip46Connect,
|
||||
nip46PairStart,
|
||||
nip46Disconnect,
|
||||
nip46Status,
|
||||
nip46Approve,
|
||||
|
|
@ -358,6 +391,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
signerDisconnect,
|
||||
signerStatus,
|
||||
signerApprove,
|
||||
signerGrantsList,
|
||||
signerGrantRevoke,
|
||||
deleteProfile,
|
||||
undoDelete,
|
||||
publishProfileMetadata,
|
||||
|
|
@ -408,6 +443,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
embeddedSignerStatus,
|
||||
embeddedSignerApprove,
|
||||
nip46Connect,
|
||||
nip46PairStart,
|
||||
nip46Disconnect,
|
||||
nip46Status,
|
||||
nip46Approve,
|
||||
|
|
@ -415,6 +451,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
signerDisconnect,
|
||||
signerStatus,
|
||||
signerApprove,
|
||||
signerGrantsList,
|
||||
signerGrantRevoke,
|
||||
copyText,
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1402,6 +1402,18 @@ 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
|
||||
------------------------------------------------------------------------- */
|
||||
|
|
|
|||
|
|
@ -16,13 +16,13 @@ describe('App', () => {
|
|||
render(<App />);
|
||||
|
||||
expect(await screen.findByText('Welcome to Keynctr')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Create your first profile/i })).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.getByText(/A Nostr profile is your identity/i)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Create your first profile/i }));
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: 'Create a Nostr profile' }),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /Create a new profile/i }));
|
||||
expect(await screen.findByRole('dialog', { name: 'Add a profile' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the main screen after loading with an existing profile', async () => {
|
||||
|
|
|
|||
|
|
@ -5,14 +5,39 @@ 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('creates a profile through the backend and shows a success confirmation', async () => {
|
||||
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 () => {
|
||||
const backend = createFakeBackend(makeEmptyState());
|
||||
installFakeBackend(backend);
|
||||
const onClose = vi.fn();
|
||||
renderWithApp(<CreateProfileModal open onClose={onClose} />);
|
||||
|
||||
await screen.findByRole('dialog', { name: 'Create a Nostr profile' });
|
||||
await screen.findByRole('dialog', { name: 'Add a profile' });
|
||||
await userEvent
|
||||
.setup()
|
||||
.click(screen.getByRole('button', { name: /Create a new key on this computer/ }));
|
||||
|
||||
const input = screen.getByLabelText('Profile name');
|
||||
await userEvent.setup().type(input, 'Sam');
|
||||
|
|
@ -36,20 +61,85 @@ describe('CreateProfileModal', () => {
|
|||
installFakeBackend(backend);
|
||||
renderWithApp(<CreateProfileModal open onClose={vi.fn()} />);
|
||||
|
||||
await screen.findByRole('dialog', { name: 'Create a Nostr profile' });
|
||||
await screen.findByRole('dialog', { name: 'Add a profile' });
|
||||
await userEvent
|
||||
.setup()
|
||||
.click(screen.getByRole('button', { name: /Create a new key on this computer/ }));
|
||||
expect(screen.getByRole('button', { name: 'Create profile' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('closes without creating when Cancel is clicked', async () => {
|
||||
it('closes without creating when Back then close is used', 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: 'Cancel' }));
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Back' }));
|
||||
// Back returns to the choice step; nothing was created yet.
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -146,7 +146,9 @@ 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 () => {
|
||||
|
|
@ -185,9 +187,7 @@ 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,9 +226,7 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,22 @@ 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,
|
||||
|
|
|
|||
|
|
@ -7,13 +7,18 @@ import { createFakeBackend, installFakeBackend } from './fakeBackend';
|
|||
|
||||
function renderHome(
|
||||
backend: ReturnType<typeof createFakeBackend>,
|
||||
overrides: { onNavigate?: () => void; onCreateProfile?: () => void } = {},
|
||||
overrides: {
|
||||
onNavigate?: () => void;
|
||||
onCreateProfile?: () => void;
|
||||
onImportProfile?: () => void;
|
||||
} = {},
|
||||
) {
|
||||
installFakeBackend(backend);
|
||||
return {
|
||||
user: userEvent.setup(),
|
||||
onNavigate: overrides.onNavigate ?? vi.fn(),
|
||||
onCreateProfile: overrides.onCreateProfile ?? vi.fn(),
|
||||
onImportProfile: overrides.onImportProfile ?? vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -21,7 +26,9 @@ 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()} />);
|
||||
renderWithApp(
|
||||
<HomeScreen onNavigate={onNavigate} onCreateProfile={vi.fn()} onImportProfile={vi.fn()} />,
|
||||
);
|
||||
|
||||
// The active profile appears in the profile list with its shortened npub.
|
||||
const profileList = await screen.findByRole('listbox');
|
||||
|
|
@ -36,7 +43,9 @@ 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()} />);
|
||||
renderWithApp(
|
||||
<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} onImportProfile={vi.fn()} />,
|
||||
);
|
||||
|
||||
// The active profile row carries the "Selected" badge; find Alice via the profile list.
|
||||
const profileList = await screen.findByRole('listbox');
|
||||
|
|
@ -51,20 +60,32 @@ describe('HomeScreen', () => {
|
|||
|
||||
it('shows the first-run state and guides the user to create a profile', async () => {
|
||||
const backend = createFakeBackend(makeEmptyState());
|
||||
const { onCreateProfile } = renderHome(backend);
|
||||
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={onCreateProfile} />);
|
||||
const { onCreateProfile, onImportProfile } = renderHome(backend);
|
||||
renderWithApp(
|
||||
<HomeScreen
|
||||
onNavigate={vi.fn()}
|
||||
onCreateProfile={onCreateProfile}
|
||||
onImportProfile={onImportProfile}
|
||||
/>,
|
||||
);
|
||||
|
||||
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: /Create your first profile/i }));
|
||||
expect(onCreateProfile).toHaveBeenCalled();
|
||||
.click(screen.getByRole('button', { name: /I already have an account/i }));
|
||||
expect(onImportProfile).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()} />);
|
||||
renderWithApp(
|
||||
<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} onImportProfile={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.
|
||||
|
|
@ -77,4 +98,31 @@ 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
72
frontend/src/test/SignerModeScreen.test.tsx
Normal file
72
frontend/src/test/SignerModeScreen.test.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,10 +2,12 @@ import type {
|
|||
AppState,
|
||||
BackendResponse,
|
||||
FeedItem,
|
||||
Nip46SignerStatus,
|
||||
ProfileSummary,
|
||||
PublishReport,
|
||||
RelayTestResult,
|
||||
Settings,
|
||||
SignerGrant,
|
||||
SignerStatus,
|
||||
UpdateApplyReport,
|
||||
UpdateCheckReport,
|
||||
|
|
@ -41,6 +43,11 @@ 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`. */
|
||||
|
|
@ -106,6 +113,17 @@ 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',
|
||||
|
|
@ -175,7 +193,41 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
|||
switch (method) {
|
||||
case 'init':
|
||||
case 'get_state':
|
||||
return 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;
|
||||
|
||||
case 'create_profile': {
|
||||
const label = String(params.label ?? '');
|
||||
|
|
@ -344,14 +396,36 @@ 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 = {
|
||||
|
|
@ -445,10 +519,9 @@ 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
|
||||
|
|
@ -461,24 +534,19 @@ 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)}` };
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ 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()} />);
|
||||
renderWithApp(
|
||||
<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} onImportProfile={vi.fn()} />,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForData() {
|
||||
|
|
|
|||
205
src/app.rs
205
src/app.rs
|
|
@ -9,9 +9,12 @@ 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;
|
||||
|
|
@ -22,8 +25,10 @@ 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.
|
||||
pub undo_history: Vec<ProfileSummary>,
|
||||
/// 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>,
|
||||
/// The most recent publish report, persisted across restarts.
|
||||
pub last_publish: Option<StoredPublishReport>,
|
||||
/// Active signer mode.
|
||||
|
|
@ -60,7 +65,8 @@ pub struct AppStateView {
|
|||
pub active_profile: Option<ProfileSummary>,
|
||||
pub profiles: Vec<ProfileSummary>,
|
||||
pub settings: Settings,
|
||||
/// Recently deleted profiles, newest last, for undo.
|
||||
/// Recently deleted profiles (safe summaries only — never secret material),
|
||||
/// newest last, for undo.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub undo_history: Vec<ProfileSummary>,
|
||||
/// The most recent publish report, persisted across restarts.
|
||||
|
|
@ -113,6 +119,56 @@ 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
|
||||
|
|
@ -215,36 +271,45 @@ impl App {
|
|||
Ok(revealed)
|
||||
}
|
||||
|
||||
/// Undo the last profile deletion, restoring the profile to the vault.
|
||||
/// Undo the last profile deletion, restoring the profile — with its real
|
||||
/// stored secret key — 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> {
|
||||
if self.undo_history.is_empty() {
|
||||
let Some(deleted) = self.undo_history.pop() else {
|
||||
return Err(AppError::config("No profile deletions to undo."));
|
||||
}
|
||||
let restored = self.undo_history.pop().unwrap();
|
||||
// Re-add the profile to the vault
|
||||
};
|
||||
let restored = deleted.stored.clone();
|
||||
// Re-add the profile to the vault unless it is somehow already there.
|
||||
if !self
|
||||
.vault
|
||||
.profiles
|
||||
.iter()
|
||||
.any(|p| p.public_key == restored.npub)
|
||||
.any(|p| p.public_key == restored.public_key)
|
||||
{
|
||||
let stored = StoredProfile {
|
||||
// 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 {
|
||||
label: restored.label.clone(),
|
||||
public_key: restored.npub.clone(),
|
||||
secret_key: "".to_string(),
|
||||
npub: restored.public_key.clone(),
|
||||
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.
|
||||
|
|
@ -361,7 +426,11 @@ impl App {
|
|||
active_profile: profiles::active_summary(&self.vault),
|
||||
profiles: profiles::summaries(&self.vault),
|
||||
settings: self.settings.clone(),
|
||||
undo_history: self.undo_history.clone(),
|
||||
undo_history: self
|
||||
.undo_history
|
||||
.iter()
|
||||
.map(|deleted| deleted.summary.clone())
|
||||
.collect(),
|
||||
last_publish: self.last_publish.clone(),
|
||||
signer_mode: self.signer_mode,
|
||||
}
|
||||
|
|
@ -435,6 +504,70 @@ 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();
|
||||
|
|
@ -622,4 +755,32 @@ 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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -750,10 +750,21 @@ 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;
|
||||
// everything else is answered immediately.
|
||||
// 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.
|
||||
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)
|
||||
};
|
||||
|
|
|
|||
162
src/feed.rs
162
src/feed.rs
|
|
@ -44,6 +44,11 @@ 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,
|
||||
|
|
@ -181,7 +186,96 @@ async fn aggregate_for(
|
|||
}
|
||||
|
||||
client.disconnect().await;
|
||||
Ok(feed.finish())
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// URLs of every enabled relay.
|
||||
|
|
@ -256,6 +350,8 @@ 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(),
|
||||
|
|
@ -300,6 +396,8 @@ mod tests {
|
|||
id,
|
||||
author: "a".into(),
|
||||
author_npub: "npub1a".into(),
|
||||
author_name: None,
|
||||
author_picture: None,
|
||||
content: "c".into(),
|
||||
created_at: created,
|
||||
relays: vec![],
|
||||
|
|
@ -335,6 +433,68 @@ 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);
|
||||
|
|
|
|||
238
src/ipc.rs
238
src/ipc.rs
|
|
@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
|
|||
use serde_json::json;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::app::{App, Nip46ClientSignerHandle};
|
||||
use crate::errors::AppError;
|
||||
use crate::feed;
|
||||
use crate::profiles;
|
||||
|
|
@ -165,14 +165,24 @@ 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.
|
||||
/// 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.
|
||||
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).
|
||||
|
|
@ -190,6 +200,18 @@ 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,
|
||||
|
|
@ -237,6 +259,31 @@ 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();
|
||||
|
|
@ -331,6 +378,46 @@ 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 {
|
||||
|
|
@ -396,44 +483,58 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
|
|||
|
||||
// NIP-46 client signer
|
||||
Request::Nip46Connect { uri, label } => {
|
||||
let guard = app.lock().await;
|
||||
if let Some(signer) = &guard.nip46_signer {
|
||||
// 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 status = signer.connect(&uri, label).await?;
|
||||
Ok(json!(status))
|
||||
} else {
|
||||
Err(AppError::config(
|
||||
"NIP-46 signer not initialized. Set signer mode to nip46 first.",
|
||||
))
|
||||
}
|
||||
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(
|
||||
"NIP-46 signer not initialized. Set signer mode to nip46 first.",
|
||||
));
|
||||
};
|
||||
let status = signer.start_pairing(label).await?;
|
||||
Ok(json!(status))
|
||||
}
|
||||
Request::Nip46Disconnect => {
|
||||
let guard = app.lock().await;
|
||||
if let Some(signer) = &guard.nip46_signer {
|
||||
let Some(signer) = ensure_nip46_signer(app).await else {
|
||||
return Err(AppError::config("NIP-46 signer not initialized"));
|
||||
};
|
||||
signer.disconnect().await?;
|
||||
let status = signer.status().await;
|
||||
Ok(json!(status))
|
||||
} else {
|
||||
Err(AppError::config("NIP-46 signer not initialized"))
|
||||
}
|
||||
}
|
||||
Request::Nip46Status => {
|
||||
let guard = app.lock().await;
|
||||
if let Some(signer) = &guard.nip46_signer {
|
||||
let Some(signer) = ensure_nip46_signer(app).await else {
|
||||
return Ok(json!({ "connected": false, "error": "Not initialized" }));
|
||||
};
|
||||
let status = signer.status().await;
|
||||
Ok(json!(status))
|
||||
} else {
|
||||
Ok(json!({ "connected": false, "error": "Not initialized" }))
|
||||
}
|
||||
}
|
||||
Request::Nip46Approve { id, approved } => {
|
||||
let guard = app.lock().await;
|
||||
if let Some(signer) = &guard.nip46_signer {
|
||||
signer.respond_to_approval(&id, approved).await?;
|
||||
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?;
|
||||
let status = signer.status().await;
|
||||
Ok(json!(status))
|
||||
} else {
|
||||
Err(AppError::config("NIP-46 signer not initialized"))
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy NIP-46 bunker (server mode)
|
||||
|
|
@ -442,7 +543,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(json!(status));
|
||||
return Ok(nip46_status_as_bunker_json(&status));
|
||||
}
|
||||
}
|
||||
Err(AppError::config(
|
||||
|
|
@ -455,7 +556,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(json!(status));
|
||||
return Ok(nip46_status_as_bunker_json(&status));
|
||||
}
|
||||
}
|
||||
Err(AppError::config("Not in NIP-46 client mode"))
|
||||
|
|
@ -465,22 +566,43 @@ 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(json!(status));
|
||||
return Ok(nip46_status_as_bunker_json(&status));
|
||||
}
|
||||
}
|
||||
Err(AppError::config("Not in NIP-46 client mode"))
|
||||
}
|
||||
Request::SignerApprove { id, approved } => {
|
||||
Request::SignerApprove {
|
||||
id,
|
||||
approved,
|
||||
always,
|
||||
} => {
|
||||
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(&id, approved).await?;
|
||||
signer
|
||||
.respond_to_approval_with_always(&id, approved, always)
|
||||
.await?;
|
||||
let status = signer.status().await;
|
||||
return Ok(json!(status));
|
||||
return Ok(nip46_status_as_bunker_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 } => {
|
||||
|
|
@ -605,9 +727,23 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
|
|||
}
|
||||
|
||||
Request::PublishProfileMetadata { npub } => {
|
||||
let key = app.vault_key().copied();
|
||||
let report =
|
||||
profiles::publish_profile_metadata(&app.vault, &npub, key.as_ref(), &app.settings)?;
|
||||
// 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?;
|
||||
Ok(json!(report))
|
||||
}
|
||||
|
||||
|
|
@ -646,9 +782,17 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
|
|||
}
|
||||
|
||||
Request::PublishNote { content } => {
|
||||
let report =
|
||||
publish::publish_active(&app.vault, &app.settings, &content, app.vault_key())
|
||||
.await?;
|
||||
// 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 stored = crate::vault::StoredPublishReport {
|
||||
event_id: report.event_id.clone(),
|
||||
succeeded: report.succeeded.clone(),
|
||||
|
|
@ -710,6 +854,12 @@ 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()))
|
||||
|
|
@ -765,13 +915,9 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
|
|||
}
|
||||
|
||||
Request::UploadAuth { url, http_method } => {
|
||||
let authorization = crate::uploads::nip98_authorization(
|
||||
&app.vault,
|
||||
&url,
|
||||
&http_method,
|
||||
app.vault_key(),
|
||||
)
|
||||
.await?;
|
||||
let signing = app.signing_active().await?;
|
||||
let authorization =
|
||||
crate::uploads::nip98_authorization(&url, &http_method, &signing).await?;
|
||||
Ok(json!({ "authorization": authorization }))
|
||||
}
|
||||
|
||||
|
|
@ -793,9 +939,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(&mut app.vault, &npub)?;
|
||||
let deleted = profiles::delete_profile_record(&mut app.vault, &npub)?;
|
||||
app.save_vault()?;
|
||||
app.undo_history.push(deleted.clone());
|
||||
app.undo_history.push(deleted);
|
||||
Ok(json!(app.state_view()))
|
||||
}
|
||||
Request::UndoDelete => {
|
||||
|
|
|
|||
62
src/main.rs
62
src/main.rs
|
|
@ -5,11 +5,11 @@ use keynectr::app::App;
|
|||
use keynectr::bunker::Signer;
|
||||
use keynectr::errors::{AppError, ErrorKind};
|
||||
use keynectr::ipc;
|
||||
use keynectr::profiles::{self, ProfileSummary};
|
||||
use keynectr::profiles;
|
||||
use keynectr::publish;
|
||||
use keynectr::relays;
|
||||
use keynectr::settings::Theme;
|
||||
use keynectr::vault::{self, StoredProfile, Vault};
|
||||
use keynectr::vault::{self, Vault};
|
||||
|
||||
const USAGE: &str = "\
|
||||
keynectr <command> [args...]
|
||||
|
|
@ -629,26 +629,13 @@ fn cli_info() -> Result<String, AppError> {
|
|||
}
|
||||
|
||||
/// Delete a profile by npub, moving it to the undo stack.
|
||||
/// 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,
|
||||
})
|
||||
/// 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)
|
||||
}
|
||||
|
||||
fn cli_delete_profile(args: &[String]) -> Result<String, AppError> {
|
||||
|
|
@ -657,37 +644,22 @@ 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
|
||||
app.undo_history.push(deleted.clone());
|
||||
// Add to undo history (full record: undo restores a working profile).
|
||||
app.undo_history.push(deleted);
|
||||
Ok(format!(
|
||||
"Profile '{}' deleted (npub: {}). Use 'undo-delete' to restore.",
|
||||
profiles::profile_label(&app.vault, &npub).unwrap_or(&npub),
|
||||
npub
|
||||
"Profile '{label}' deleted (npub: {npub}). Use 'undo-delete' to restore."
|
||||
))
|
||||
}
|
||||
|
||||
fn cli_undo_delete() -> Result<String, AppError> {
|
||||
let mut app = App::load()?;
|
||||
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());
|
||||
}
|
||||
let restored = app.undo_delete()?;
|
||||
app.save_vault()?;
|
||||
Ok(format!(
|
||||
"Profile '{}' restored from undo stack.",
|
||||
|
|
|
|||
210
src/profiles.rs
210
src/profiles.rs
|
|
@ -9,7 +9,7 @@ use crate::errors::AppError;
|
|||
use crate::publish::RelayFailure;
|
||||
use crate::relays;
|
||||
use crate::settings::Settings;
|
||||
use crate::vault::{unix_timestamp, StoredProfile, Vault};
|
||||
use crate::vault::{unix_timestamp, SignerMode, StoredProfile, Vault};
|
||||
|
||||
/// A safe view of a profile that contains no secret key material.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
|
|
@ -190,11 +190,68 @@ 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,
|
||||
|
|
@ -298,6 +355,39 @@ 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)?;
|
||||
|
|
@ -463,6 +553,58 @@ 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
|
||||
|
|
@ -509,7 +651,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.
|
||||
fn fetch_profile_metadata(public_key: &PublicKey, relay_urls: &[String]) -> Option<Metadata> {
|
||||
pub fn fetch_profile_metadata(public_key: &PublicKey, relay_urls: &[String]) -> Option<Metadata> {
|
||||
if relay_urls.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -745,9 +887,19 @@ pub fn parse_secret_key(hex_str: &str) -> Result<SecretKey, AppError> {
|
|||
SecretKey::from_slice(&bytes).map_err(|e| AppError::invalid_secret(format!("{e}")))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// 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> {
|
||||
let pos = vault
|
||||
.profiles
|
||||
.iter()
|
||||
|
|
@ -757,14 +909,22 @@ pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result<ProfileSummary, A
|
|||
if vault.active_profile.as_deref() == Some(npub) {
|
||||
vault.active_profile = None;
|
||||
}
|
||||
Ok(ProfileSummary {
|
||||
label: stored.label,
|
||||
npub: stored.public_key,
|
||||
let summary = ProfileSummary {
|
||||
label: stored.label.clone(),
|
||||
npub: stored.public_key.clone(),
|
||||
created_at: stored.created_at,
|
||||
is_active: false,
|
||||
picture: stored.picture,
|
||||
nip05: stored.nip05,
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -1160,6 +1320,34 @@ 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();
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ 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,
|
||||
|
|
@ -61,6 +64,17 @@ 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.
|
||||
|
|
@ -164,33 +178,19 @@ async fn publish_with_keys(
|
|||
return Err(AppError::no_enabled_relays());
|
||||
}
|
||||
|
||||
// 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",
|
||||
));
|
||||
}
|
||||
};
|
||||
// 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)?;
|
||||
|
||||
// Build the unsigned event.
|
||||
// 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.
|
||||
let builder = EventBuilder::new(Kind::TextNote, content.to_string()).tags(image_tags(content));
|
||||
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 unsigned = builder.finalize_unsigned(pubkey);
|
||||
let signed = signing
|
||||
.sign(unsigned.into())
|
||||
.sign(unsigned)
|
||||
.await
|
||||
.map_err(|e| AppError::sign_failed(format!("{e}")))?;
|
||||
|
||||
|
|
@ -199,7 +199,13 @@ async fn publish_with_keys(
|
|||
.to_bech32()
|
||||
.map_err(|e| AppError::internal(format!("Could not encode the event id: {e}")))?;
|
||||
|
||||
let client = relays::open_pool(keys.clone(), &relay_urls, None).await?;
|
||||
// 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 (succeeded, failed) = send_to_all_relays(&client, relay_urls, &signed, "note").await;
|
||||
|
||||
if succeeded.is_empty() {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,43 @@ 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('/');
|
||||
|
|
@ -78,12 +115,36 @@ 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 client = Client::builder()
|
||||
.authenticator(SignerAuthenticator::new(keys))
|
||||
.build();
|
||||
let builder = match keys {
|
||||
Some(keys) => Client::builder().authenticator(SignerAuthenticator::new(keys)),
|
||||
None => Client::builder(),
|
||||
};
|
||||
let client = builder.build();
|
||||
for url in relay_urls {
|
||||
client
|
||||
.add_relay(url.as_str())
|
||||
|
|
@ -156,6 +217,25 @@ 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());
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ pub trait Signer: Send + Sync {
|
|||
///
|
||||
/// Returns an owned value because the NIP-46 client must lock an async
|
||||
/// mutex internally.
|
||||
fn permissions(&self) -> Option<permissions::Nip46Permissions> {
|
||||
async 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.
|
||||
fn can_sign_event(&self, kind: u16) -> bool {
|
||||
match self.permissions() {
|
||||
async fn can_sign_event(&self, kind: u16) -> bool {
|
||||
match self.permissions().await {
|
||||
Some(ref perms) => perms.is_sign_event_kind_allowed(kind),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `nip44_encrypt` is permitted.
|
||||
fn can_encrypt(&self) -> bool {
|
||||
match self.permissions() {
|
||||
async fn can_encrypt(&self) -> bool {
|
||||
match self.permissions().await {
|
||||
Some(ref perms) => perms.is_encrypt_allowed(),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `nip44_decrypt` is permitted.
|
||||
fn can_decrypt(&self) -> bool {
|
||||
match self.permissions() {
|
||||
async fn can_decrypt(&self) -> bool {
|
||||
match self.permissions().await {
|
||||
Some(ref perms) => perms.is_decrypt_allowed(),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `get_public_key` is permitted.
|
||||
fn can_get_public_key(&self) -> bool {
|
||||
match self.permissions() {
|
||||
async fn can_get_public_key(&self) -> bool {
|
||||
match self.permissions().await {
|
||||
Some(ref perms) => perms.is_get_public_key_allowed(),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `get_relays` is permitted.
|
||||
fn can_get_relays(&self) -> bool {
|
||||
match self.permissions() {
|
||||
async fn can_get_relays(&self) -> bool {
|
||||
match self.permissions().await {
|
||||
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`.
|
||||
fn is_connection_valid(&self) -> bool {
|
||||
async fn is_connection_valid(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -93,6 +93,11 @@ 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.
|
||||
|
|
|
|||
108
src/uploads.rs
108
src/uploads.rs
|
|
@ -1,21 +1,22 @@
|
|||
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::profiles;
|
||||
use crate::signer::Signing;
|
||||
|
||||
/// Sign a NIP-98 HTTP auth event for `url` with the active profile's key and
|
||||
/// return the `Authorization` header value (`Nostr <base64>`).
|
||||
/// Sign a NIP-98 HTTP auth event for `url` with the given [`Signing`] source
|
||||
/// and return the `Authorization` header value (`Nostr <base64>`).
|
||||
///
|
||||
/// This is what image hosts like nostr.build require before accepting an
|
||||
/// upload. Like publishing, it needs an unlocked vault when the vault is
|
||||
/// password-protected.
|
||||
/// 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.
|
||||
pub async fn nip98_authorization(
|
||||
vault: &crate::vault::Vault,
|
||||
url: &str,
|
||||
method: &str,
|
||||
key: Option<&VaultKey>,
|
||||
signing: &Signing,
|
||||
) -> Result<String, AppError> {
|
||||
let http_method = match method.to_ascii_uppercase().as_str() {
|
||||
"GET" => HttpMethod::GET,
|
||||
|
|
@ -33,112 +34,57 @@ pub async fn nip98_authorization(
|
|||
let parsed_url = Url::parse(url)
|
||||
.map_err(|e| AppError::config(format!("The upload URL is not valid: {e}")))?;
|
||||
|
||||
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)
|
||||
// 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)
|
||||
.await
|
||||
.map_err(|e| AppError::sign_failed(format!("Could not sign the upload request: {e}")))?;
|
||||
|
||||
Ok(header)
|
||||
let encoded = B64.encode(event.as_json());
|
||||
Ok(format!("Nostr {encoded}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::settings::Settings;
|
||||
use crate::vault::Vault;
|
||||
|
||||
/// 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);
|
||||
fn local_signing() -> Signing {
|
||||
Signing::Local(Keys::generate())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_method_errors() {
|
||||
let vault = vault_with_profile();
|
||||
let signing = local_signing();
|
||||
let runtime = tokio::runtime::Runtime::new().unwrap();
|
||||
let err = runtime
|
||||
.block_on(nip98_authorization(
|
||||
&vault,
|
||||
"https://example.com/upload",
|
||||
"DELETE",
|
||||
None,
|
||||
&signing,
|
||||
))
|
||||
.expect_err("unsupported method must error");
|
||||
assert_eq!(err.kind(), ErrorKind::Config);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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();
|
||||
fn signs_a_nip98_auth_header() {
|
||||
let signing = local_signing();
|
||||
let runtime = tokio::runtime::Runtime::new().unwrap();
|
||||
let header = runtime
|
||||
.block_on(nip98_authorization(
|
||||
&vault,
|
||||
"https://nostr.build/api/v2/upload/files",
|
||||
"POST",
|
||||
None,
|
||||
&signing,
|
||||
))
|
||||
.expect("valid profile must sign");
|
||||
.expect("local signing must produce a header");
|
||||
|
||||
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");
|
||||
|
|
|
|||
314
src/vault.rs
314
src/vault.rs
|
|
@ -120,6 +120,37 @@ 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
|
||||
|
|
@ -141,6 +172,23 @@ 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 {
|
||||
|
|
@ -152,9 +200,42 @@ 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()
|
||||
}
|
||||
|
|
@ -344,6 +425,8 @@ 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(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -356,35 +439,28 @@ 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`.
|
||||
///
|
||||
/// Returns `true` if any profiles were migrated (i.e. the vault should
|
||||
/// be re-saved).
|
||||
/// 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).
|
||||
pub fn migrate_vault_signer_modes(vault: &mut Vault) -> bool {
|
||||
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.
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
changed
|
||||
if vault.version < VAULT_VERSION {
|
||||
vault.version = VAULT_VERSION;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Store (or replace) a NIP-46 connection secret in the vault, keyed by an
|
||||
|
|
@ -460,6 +536,76 @@ 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> {
|
||||
|
|
@ -647,6 +793,29 @@ 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 {
|
||||
|
|
@ -852,11 +1021,14 @@ mod tests {
|
|||
});
|
||||
|
||||
let changed1 = migrate_vault_signer_modes(&mut vault);
|
||||
assert!(changed1, "first migration should report change");
|
||||
// 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");
|
||||
|
||||
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.
|
||||
let changed2 = migrate_vault_signer_modes(&mut vault);
|
||||
assert!(!changed2, "re-running migration stays a no-op");
|
||||
assert_eq!(vault.profiles[0].signer_mode, SignerMode::Embedded);
|
||||
assert_eq!(vault.version, VAULT_VERSION);
|
||||
}
|
||||
|
|
@ -1007,4 +1179,90 @@ 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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1326
tests/nip46_e2e.rs
Normal file
1326
tests/nip46_e2e.rs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue