Compare commits

..

No commits in common. "2e4005d24dbb6e566270fe454d29636639b3ea86" and "a6329d564020c128667f24ef1a4acb572d98d449" have entirely different histories.

20 changed files with 444 additions and 229 deletions

View file

@ -1,122 +1,182 @@
# Checkpoint — Profile metadata publishing + profile pictures (2026-08-22) # Checkpoint — All audit items closed (2026-08-21)
**App renamed to Keynectr (2026-08-23).** A stopping point you can return to if this session is closed. Everything below was A stopping point you can return to if this session is closed. Everything below was
verified green at the moment this file was written. verified green at the moment this file was written.
## Where things are ## Where things are
- Project: `/home/avi/Projects/Nostr_Keynctr` (renamed from `0_Nostr` after commit `1116dfd`) - Project: `/home/avi/Projects/0_Nostr`
- Git repo: `master` @ `a6329d5` ("Publish profile metadata (name + picture) so external - Git repo: `master` @ `f7db29e` ("Add SSRF guard, signer queue cap, secret echo, request
clients show it"). Before it: `db81f8d` (profile deletion with undo), `9a8f334` timeout"). Before it: `130d7e2` (zeroization), `4461307` (owner-only writes), `4bd7660`
(audit checkpoint refresh), and the 2026-08-21 audit-fix commits (`f7db29e`, `d90b6e5`, (legacy vault perms), `4d4dfde` (CSP + navigation guards), `6e627a3` (upload tokens),
`130d7e2`). `4bde395` (IPC allowlist).
- Working tree is **clean** apart from this checkpoint update, which is committed right after. - Working tree is **clean** apart from this checkpoint update, which is committed right after.
## What was completed ## What was completed: the full 2026-08-21 security audit remediation
**Problem:** profiles created in the app never published a Nostr kind 0 metadata event, All ten findings from the 2026-08-21 security audit are fixed.
so other clients showed generated petnames ("evil iguana", "homeless leech") or a
truncated npub instead of the user's chosen name.
1. **Automatic metadata on creation**`create_profile` now publishes a kind 0 event **#1 IPC method allowlist (`4bde395`, `main.ts` only):**
with the label as `name`/`display_name` to all enabled relays (best-effort; relay - `RENDERER_METHODS` set in the Electron main process: the three native methods
failures never block creation). (`pick_image`, `link_preview`, `upload_image`) plus every Rust-backend method used by
2. **"Publish name" action for existing profiles** — new button on every Profiles-screen `frontend/src/lib/api.ts`.
card plus CLI `publish-name <npub>`. Returns a per-relay report shown in the UI. - `isAllowedMethod()` gate at the top of the `backend:request` handler; unknown methods get
3. **Profile pictures end-to-end** `{status:'error', code:'unknown_method'}`, are logged, and never reach the backend.
- Vault: optional `picture: Option<String>` per profile (serde default → old vaults - No behaviour change for the real UI (only `api.ts` calls `window.backend.request`).
load unchanged).
- Backend: `set_profile_picture` validates http(s) URLs only, stores the URL, and
publishes kind 0 including `picture`; clearing supported (`None`).
- GUI: "Picture" button opens a modal — paste a URL, upload a file via the existing
nostr.build pipeline, or remove; avatar shows the picture everywhere in-app.
- CLI: `set-picture <npub> <url>`.
4. **Nested-runtime safety** — metadata publishing runs on a dedicated OS thread with its
own tokio runtime, so both sync (CLI) and async (IPC server) callers are safe.
5. **Finished prior session's delete/undo work** — exposed `undo_history` in
`AppStateView`, fixed invalid Button variants / missing icon / null-safety errors so
the frontend typechecks again.
6. **Test hygiene fix**`Settings::default()` points at real relays and tests were
silently publishing events to them (one got rate-limited by damus.io). All test suites
now use offline settings; test time dropped from ~126 s to ~3 s.
## Commits added in this session **#2 Upload pick tokens (`6e627a3`):**
The renderer used to send raw filesystem paths back to main for upload, so a compromised
page could read+publish arbitrary local files to nostr.build. Now:
- `pickImage()` (`main.ts`) mints a random 32-hex-char token per picked file via
`crypto.randomBytes`; tokens map to the file record in `pickedTokens` (main-process memory
only). The renderer receives `{token, name, mime}` — it never sees any path.
- `upload_image` accepts only a valid `token`; the token is consumed (deleted) before the
upload starts, so each pick authorises exactly one upload. Unknown/used/expired tokens
return `{status:'error', code:'unknown_token', message:'That image selection has expired.
Please attach it again.'}` — the user just re-picks the image.
- Renderer updates: `PickedImage.path``PickedImage.token` (`types.ts`), `api.uploadImage(token)`
(`api.ts`), context type + callback (`AppProvider.tsx`), `ComposeScreen.onAttach` uses
`image.token`.
- Fake backend mirrors the contract: `upload_image` without a non-empty `token` throws an
`unknown_token` error, so tests exercise the same protocol rule.
- `7c6a085` Rename the app to Keynectr **#3 Header-based CSP + navigation/window guards (`4d4dfde`):**
- `ae5ddda` Allow new profile methods through the Electron IPC allowlist - The static CSP meta tag was **removed** from `frontend/index.html` and replaced with
- `a6329d5` Publish profile metadata (name + picture) so external clients show it response headers stamped by `main.ts` (`onHeadersReceived`, mainFrame only):
- `CSP_PROD` for `app://` pages: `script-src 'self'`**no `'unsafe-inline'`**, so an
injected `<script>` cannot execute. `connect-src 'self'` (all real network goes through
the backend/main process); img-src keeps `https:` for remote feed/preview images.
- `CSP_DEV` when `NOSTR_GUI_DEV_URL` matches: keeps `'unsafe-inline'` (Vite's React-refresh
preamble is an inline script) but pins `connect-src` to localhost instead of the old
wide-open `ws:` — HMR still works.
- The meta had to go because it would also have blocked the dev preamble; headers let us
ship strict-prod / workable-dev from one HTML file.
- `will-navigate`: any navigation away from app:// or the dev origin is blocked; http(s)
targets open in the system browser via `shell.openExternal`.
- `setWindowOpenHandler`: all `target="_blank"` popups are denied; external links go to the
system browser. Unknown schemes are denied without opening anything.
**Gotcha for future work:** any new backend request type must also be added to **#4 Legacy vault permissions (`4bd7660`):**
`RENDERER_METHODS` in `frontend/electron/main.ts`, or the main process rejects it with - The original CLI left `profiles_vault.json` files with default (often group-readable)
"rejected renderer method" before it reaches the Rust backend. permissions containing plaintext keys. Now:
- On **every** startup, `load_vault()` calls the new `harden_stray_legacy_vaults()`
(`src/vault.rs`): each known legacy location is checked, and any file that parses as a
populated vault is chmod'd to 0600 — whether or not migration ever runs.
- During migration itself, the legacy file is tightened before the backup copy is made.
- Hardening is best-effort (`let _ =`) so odd filesystems can never break vault loading;
unparsable and keyless files are deliberately not touched.
- One-time local cleanup done by hand in this session: repo-root `profiles_vault.json`
was chmod'd from 0664 → 0600.
- New tests: recognised legacy vault tightened to 0600; unparsable file ignored; empty /
encrypted-but-populated vaults classified correctly.
## Verification commands run (all green) **#5 Owner-only-from-first-byte writes (`4461307`):**
- `write_restricted` (`src/vault.rs`): the tmp file is now created with `.mode(0o600)`
(OpenOptionsExt), so it never exists under default umask permissions. The explicit
`set_permissions` stays, covering a tmp file left over by a crashed earlier run (where
`mode` would not apply to an existing file).
- `backup_file`: replaced `fs::copy` — which gives the new file the *source's* permission
bits, so backing up a group-readable legacy vault briefly produced a fully readable copy —
with a manual open-at-0600 + `io::copy`, making backups owner-only from their first byte.
- New test: backup of a 0664 source comes out 0600 with identical content.
Rust (repo root): **#6 Key material zeroization (`130d7e2`, adds `zeroize = "1"`):**
Decrypted secrets and derived keys no longer linger in unscrubbed heap memory:
- `crypto.rs`: `decrypt_secret` returns `Zeroizing<String>` (self-shredding on drop); the
UTF-8 error path wipes the raw bytes too; a failed Argon2 derivation wipes its key buffer.
- `profiles.rs`: `resolve_secret_key` / `resolve_active_secret_key` now return
`Zeroizing<String>`, so every transient plaintext key flowing to publish/upload/signer is
wiped when its scope ends. Newly generated keys in `create_profile` are wrapped the same
way. `reveal_secret_key` still hands a display copy to the UI by design.
- `app.rs`: `lock()` and a new `Drop for App` zeroize the session vault key; wrong-password
derivations wipe their throwaway keys; password change wipes the previous vault key and
the old unlock key; `remove_password` moves decrypted values into storage via
`std::mem::take` without extra copies (plaintext-at-rest is that feature's purpose).
- Callers in `publish.rs`, `uploads.rs`, `signer.rs` needed no changes (deref coercion).
**Smaller items (`f7db29e`, main.ts + signer.rs):**
- **SSRF guard**: `fetchLinkPreview` resolves the URL's host and refuses loopback/private/
link-local targets — literal IPs, DNS answers, and localhost/.local names all checked
(`ipv4IsPrivate`/`ipv6IsPrivate`/`resolvesToPrivateAddress`). Crafted note links can no
longer make the app probe localhost or the LAN.
- **Signer queue cap** (`MAX_PENDING_APPROVALS = 20`): a relay flooding `sign_event`
requests cannot grow the approval queue unboundedly or bury a genuine prompt; overflow
requests get the standard "no decision" error.
- **NIP-46 secret echo**: when the nostrconnect:// link carried a secret, the client's
`connect` request must echo it back or it is refused ("did not include the expected
secret"), proving the link arrived unmodified.
- **Backend request timeout**: `backendRequest` reaps any round-trip after 120s (generous
enough for multi-relay publishes), deleting its pending entry so a hung backend cannot
leak promises.
## Commits
- `f7db29e` "Add SSRF guard, signer queue cap, secret echo, request timeout" — main.ts +
signer.rs (+204/3).
- `130d7e2` "Zeroize transient secret key material in memory" — Cargo.toml, crypto.rs,
profiles.rs, app.rs (+77/23).
- `4461307` "Create vault files owner-only from the first byte" — src/vault.rs (+48/2).
- `4bd7660` "Tighten permissions on leftover legacy vault files" — src/vault.rs (+79/3).
- `4d4dfde` "Enforce header-based CSP and block window open/navigation" — main.ts + index.html.
- `6e627a3` "Replace upload file paths with single-use pick tokens" — main.ts, api.ts,
types.ts, AppProvider.tsx, ComposeScreen.tsx, fakeBackend.ts (+62/27).
- `4bde395` "Restrict renderer IPC to an explicit method allowlist" — main.ts (+49 lines).
- `dafed33` "Refresh checkpoint with IPC allowlist hardening".
## How it was verified (all green)
``` ```
cargo test # 96 passed; 0 failed cargo test # 84 passed
cargo clippy --all-targets # 0 errors/warnings from session code cargo clippy --all-targets # clean
cargo fmt --check # clean cargo fmt --check # clean
cargo build --release # success cargo build --release # ok
``` npm run typecheck # clean (frontend/)
npm run lint # clean (pre-existing module-type warning only)
Frontend (`frontend/`):
```
npm test # 14 files, 78 tests passed
npm run typecheck # clean
npm run lint # 0 errors
npm run format:check # clean npm run format:check # clean
npm run build # vite build success npm test # 78 passed (14 files)
npm run electron:build # tsc electron main success npm run electron:build # compiles the Electron main process
npm run build # rebuilds the React bundle (dist/)
``` ```
## How to use / reproduce Manual protocol checks worth doing once:
- DevTools console: `window.backend.request('not_a_method')``unknown_method` envelope (#1).
- DevTools console: `window.backend.request('upload_image', {path:'/etc/passwd'})`
`unknown_token` error; no file is read (#2). Real attachment flow works unchanged.
- DevTools console: `document.cookie` / injected `<script>` does not run; Application tab
shows the CSP header on the app:// document (#3). Link previews open in the system browser.
GUI: ## How to resume
```bash 1. Open the repo: `cd /home/avi/Projects/0_Nostr`
cd ~/Projects/Nostr_Keynctr/frontend && npm start 2. State is committed: `git status` should be clean; `git log --oneline -4` shows `6e627a3`
``` on top.
3. Launch as usual: `cd frontend && npm start` (backend already built in `target/release/`).
4. Re-run verification with the commands above.
- New profiles publish their name automatically on creation. ## Outstanding / next steps (if you continue)
- Existing profiles: **Profiles → Publish name** button, or **Picture** button to set a
photo (URL paste or file upload) which publishes immediately.
CLI: Nothing outstanding from the audit — all ten findings are closed:
```bash | # | Finding | Fix commit |
B=~/Projects/Nostr_Keynctr/target/release/keynectr |---|---------|-----------|
$B list # show profiles | 1 | Unrestricted renderer→backend IPC | `4bde395` |
$B relays enable wss://nos.lol # enable at least one relay first | 2 | Arbitrary file upload paths | `6e627a3` |
$B publish-name <npub> # republish stored name | 3 | CSP `unsafe-inline`, no nav guards | `4d4dfde` |
$B set-picture <npub> https://…/img.png # set + publish picture | 4 | Legacy vault world-readable | `4bd7660` |
``` | 5 | Permission race windows on write | `4461307` |
| 6 | Key material not zeroized | `130d7e2` |
| 7 | Link-preview SSRF | `f7db29e` |
| 8 | Signer queue flooding | `f7db29e` |
| 9 | NIP-46 secret not verified | `f7db29e` |
| 10 | No backend request timeout | `f7db29e` |
Verified live during the session: kind 0 events confirmed present on nos.lol, **Profile deletion with undo functionality** (2026-08-22):
relay.primal.net, relay.damus.io and the user's own wss://nostr.l484.com via direct - Users can delete a profile via `nostr-manager-backend delete-profile <npub>`, which moves the profile to an in-memory undo stack rather than permanently removing it
websocket queries; Iris shows the stored names after publish. - Profiles can be restored with `nostr-manager-backend undo-delete`, which pops the last deleted profile from the undo stack and re-adds it to the vault (becoming active if no other profile exists)
- The undo stack is in-memory only (lost on process exit); a persistent implementation would require vault metadata changes
- Vault must be unlocked or unencrypted for deletion to be permitted
- Existing profile data and vault integrity are preserved
- CLI commands: `delete-profile <npub>` and `undo-delete`
- Rust changes verified: `cargo test` (91 tests), `cargo clippy --all-targets` clean, `cargo fmt --check` clean, `cargo build --release` successful
## Notes & next steps Possible future work (not audit items): DNS-rebinding TOCTOU in the SSRF guard, OS keyring integration for the vault password, an automated dependency-audit CI job (`cargo audit`, `npm audit`), GUI integration for profile deletion/undo in the ProfilesScreen.
- Nostr has no relay-to-relay sync: names/pictures are only visible on relays they were
published to. Keep major relays enabled so clients that don't read your own relay can
see the profile.
- Clients cache profiles; hard-refresh (Ctrl+Shift+R) after republishing.
- **Next feature (agreed 2026-08-22): NIP-05 identifiers.** Live testing showed Iris
renders names+pictures perfectly from plain kind 0 metadata, but Yakihonne only shows
a proper username handle when `nip05` is present, and some newer clients
(phoenix.social) are picky about relay coverage. Plan: add an optional NIP-05
identifier field per profile (GUI + CLI), include it in published metadata, plus docs
or a helper for serving `.well-known/nostr.json` on the user's domain (the HTTP side
cannot be done by the app alone).
- Other candidates: rename profiles (edit label + republish), banner/about fields in the
edit UI.
- **Rename details:** binary is now `keynectr`; data lives in `~/.local/share/keynectr`
(auto-migrated from `nost-feed-manager` on first run — verified live). The KDF verifier
string in crypto.rs was deliberately NOT renamed so old encrypted backups stay readable.
If a GitHub remote is ever added, rename the repo to `Nostr_Keynctr` there too.
- Relay config at time of writing (all enabled): nos.lol, relay.primal.net, l484.com
(user's own), damus.io, snort.social, soloco.nl; relay.nostr.band added but was
unreachable (handshake timeout) — retry enabling later.

38
Cargo.lock generated
View file

@ -683,25 +683,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "keynectr"
version = "0.1.0"
dependencies = [
"aes-gcm",
"argon2",
"base64",
"getrandom 0.2.17",
"hex",
"nostr",
"nostr-sdk",
"rpassword",
"serde",
"serde_json",
"tokio",
"uuid",
"zeroize",
]
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.189" version = "0.2.189"
@ -800,6 +781,25 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "nostr-manager-backend"
version = "0.1.0"
dependencies = [
"aes-gcm",
"argon2",
"base64",
"getrandom 0.2.17",
"hex",
"nostr",
"nostr-sdk",
"rpassword",
"serde",
"serde_json",
"tokio",
"uuid",
"zeroize",
]
[[package]] [[package]]
name = "nostr-relay-pool" name = "nostr-relay-pool"
version = "0.40.1" version = "0.40.1"

View file

@ -1,5 +1,5 @@
[package] [package]
name = "keynectr" name = "nostr-manager-backend"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"

View file

@ -76,10 +76,10 @@ let nextId = 1;
function resolveBackendPath(): string { function resolveBackendPath(): string {
if (app.isPackaged) { if (app.isPackaged) {
return path.join(process.resourcesPath, 'keynectr'); return path.join(process.resourcesPath, 'nostr-manager-backend');
} }
// Development: the crate builds to <project>/target/release. // Development: the crate builds to <project>/target/release.
return path.join(app.getAppPath(), '..', 'target', 'release', 'keynectr'); return path.join(app.getAppPath(), '..', 'target', 'release', 'nostr-manager-backend');
} }
function startBackend(): void { function startBackend(): void {
@ -183,10 +183,6 @@ const RENDERER_METHODS: ReadonlySet<string> = new Set([
'get_state', 'get_state',
'create_profile', 'create_profile',
'select_profile', 'select_profile',
'publish_profile_metadata',
'set_profile_picture',
'delete_profile',
'undo_delete',
'publish_note', 'publish_note',
'feed_get', 'feed_get',
'relay_add', 'relay_add',
@ -494,7 +490,7 @@ function createWindow(): void {
height: 760, height: 760,
minWidth: 920, minWidth: 920,
minHeight: 640, minHeight: 640,
title: 'Keynectr', title: 'Nostr Feed Manager',
backgroundColor: '#f6f4f0', backgroundColor: '#f6f4f0',
autoHideMenuBar: true, autoHideMenuBar: true,
webPreferences: { webPreferences: {

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Keynectr</title> <title>Nostr Feed Manager</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -1,11 +1,11 @@
{ {
"name": "keynectr", "name": "nost-feed-manager",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "keynectr", "name": "nost-feed-manager",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"react": "^18.3.1", "react": "^18.3.1",

View file

@ -1,6 +1,6 @@
{ {
"name": "keynectr", "name": "nost-feed-manager",
"productName": "Keynectr", "productName": "Nostr Feed Manager",
"version": "0.1.0", "version": "0.1.0",
"description": "A friendly Linux desktop app for managing Nostr profiles and publishing text notes.", "description": "A friendly Linux desktop app for managing Nostr profiles and publishing text notes.",
"private": true, "private": true,
@ -46,6 +46,7 @@
}, },
"build": { "build": {
"appId": "dev.nostfeedmanager.desktop", "appId": "dev.nostfeedmanager.desktop",
"productName": "Nostr Feed Manager",
"directories": { "directories": {
"output": "release", "output": "release",
"buildResources": "build" "buildResources": "build"
@ -57,8 +58,8 @@
], ],
"extraResources": [ "extraResources": [
{ {
"from": "../target/release/keynectr", "from": "../target/release/nostr-manager-backend",
"to": "keynectr" "to": "nostr-manager-backend"
} }
], ],
"linux": { "linux": {
@ -66,7 +67,7 @@
"dir" "dir"
], ],
"category": "Network", "category": "Network",
"executableName": "keynectr" "executableName": "nost-feed-manager"
} }
} }
} }

View file

@ -32,7 +32,8 @@ export function Sidebar({ screen, onNavigate }: SidebarProps) {
<Icon name="shield" size={22} /> <Icon name="shield" size={22} />
</span> </span>
<div> <div>
<strong>Keynectr</strong> <strong>Nostr Feed</strong>
<span className="sidebar-subtitle">Manager</span>
</div> </div>
</div> </div>

View file

@ -77,7 +77,7 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
<div className="screen-inner"> <div className="screen-inner">
<EmptyState <EmptyState
icon={<Icon name="users" size={30} />} icon={<Icon name="users" size={30} />}
title="Welcome to Keynectr" title="Welcome to Nostr Feed Manager"
description={ description={
<span> <span>
You haven't created a profile yet. A Nostr profile is your identity on the public You haven't created a profile yet. A Nostr profile is your identity on the public

View file

@ -161,7 +161,7 @@ export function SettingsScreen() {
<dl className="info-list"> <dl className="info-list">
<div> <div>
<dt>Application</dt> <dt>Application</dt>
<dd>Keynectr v{state?.version ?? '?'}</dd> <dd>Nostr Feed Manager v{state?.version ?? '?'}</dd>
</div> </div>
<div> <div>
<dt>Backend</dt> <dt>Backend</dt>

View file

@ -1,5 +1,5 @@
/* ========================================================================= /* =========================================================================
Keynectr design system Nostr Feed Manager design system
Light/dark/system themes via `data-theme` on <html>. Light/dark/system themes via `data-theme` on <html>.
========================================================================= */ ========================================================================= */

View file

@ -15,7 +15,7 @@ describe('App', () => {
const { user } = renderApp(backend); const { user } = renderApp(backend);
render(<App />); render(<App />);
expect(await screen.findByText('Welcome to Keynectr')).toBeInTheDocument(); expect(await screen.findByText('Welcome to Nostr Feed Manager')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Create your first profile/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Create your first profile/i })).toBeInTheDocument();
expect(screen.getByText(/A Nostr profile is your identity/i)).toBeInTheDocument(); expect(screen.getByText(/A Nostr profile is your identity/i)).toBeInTheDocument();

View file

@ -48,7 +48,7 @@ describe('HomeScreen', () => {
const { onCreateProfile } = renderHome(backend); const { onCreateProfile } = renderHome(backend);
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={onCreateProfile} />); renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={onCreateProfile} />);
expect(await screen.findByText('Welcome to Keynectr')).toBeInTheDocument(); expect(await screen.findByText('Welcome to Nostr Feed Manager')).toBeInTheDocument();
await userEvent await userEvent
.setup() .setup()
.click(screen.getByRole('button', { name: /Create your first profile/i })); .click(screen.getByRole('button', { name: /Create your first profile/i }));

View file

@ -11,7 +11,7 @@ describe('SettingsScreen', () => {
renderWithApp(<SettingsScreen />); renderWithApp(<SettingsScreen />);
expect( expect(
await screen.findByText('/home/user/.local/share/keynectr/profiles_vault.json'), await screen.findByText('/home/user/.local/share/nost-feed-manager/profiles_vault.json'),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText('Storage is not encrypted')).toBeInTheDocument(); expect(screen.getByText('Storage is not encrypted')).toBeInTheDocument();
}); });
@ -61,7 +61,7 @@ describe('SettingsScreen', () => {
installFakeBackend(backend); installFakeBackend(backend);
renderWithApp(<SettingsScreen />); renderWithApp(<SettingsScreen />);
expect(await screen.findByText(/Keynectr v/)).toBeInTheDocument(); expect(await screen.findByText(/Nostr Feed Manager v/)).toBeInTheDocument();
expect(screen.getByText('Rust (nostr-sdk)')).toBeInTheDocument(); expect(screen.getByText('Rust (nostr-sdk)')).toBeInTheDocument();
}); });
}); });

View file

@ -35,8 +35,8 @@ export function makeState(overrides?: Partial<AppState>): AppState {
}; };
return { return {
version: '0.1.0', version: '0.1.0',
vault_path: '/home/user/.local/share/keynectr/profiles_vault.json', vault_path: '/home/user/.local/share/nost-feed-manager/profiles_vault.json',
settings_path: '/home/user/.local/share/keynectr/settings.json', settings_path: '/home/user/.local/share/nost-feed-manager/settings.json',
encrypted_storage: false, encrypted_storage: false,
vault_locked: false, vault_locked: false,
migrated_from: null, migrated_from: null,
@ -188,7 +188,7 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
}, },
), ),
backupNow: vi.fn(async () => ({ backupNow: vi.fn(async () => ({
backup_path: '/home/user/.local/share/keynectr/profiles_vault.json.backup-1', backup_path: '/home/user/.local/share/nost-feed-manager/profiles_vault.json.backup-1',
})), })),
setVaultPassword: vi.fn(async () => ({ setVaultPassword: vi.fn(async () => ({
...state, ...state,

View file

@ -151,7 +151,7 @@ pub struct ReplyEnvelope {
/// Run the JSON-lines IPC server on stdin/stdout. /// Run the JSON-lines IPC server on stdin/stdout.
/// ///
/// The Electron main process spawns `keynectr serve` and /// The Electron main process spawns `nostr-manager-backend serve` and
/// exchanges one JSON object per line. Requests are processed sequentially so /// exchanges one JSON object per line. Requests are processed sequentially so
/// the shared state never sees concurrent mutations. /// the shared state never sees concurrent mutations.
pub async fn serve() -> Result<(), AppError> { pub async fn serve() -> Result<(), AppError> {

View file

@ -14,7 +14,4 @@ pub mod vault;
pub use errors::AppError; pub use errors::AppError;
/// Stable application-data directory name. /// Stable application-data directory name.
pub const APP_DIR_NAME: &str = "keynectr"; pub const APP_DIR_NAME: &str = "nost-feed-manager";
/// Previous application-data directory name, migrated automatically on first use.
pub const LEGACY_APP_DIR_NAME: &str = "nost-feed-manager";

View file

@ -1,18 +1,18 @@
use std::process::ExitCode; use std::process::ExitCode;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use keynectr::app::App; use nostr_manager_backend::app::App;
use keynectr::errors::{AppError, ErrorKind}; use nostr_manager_backend::errors::{AppError, ErrorKind};
use keynectr::ipc; use nostr_manager_backend::ipc;
use keynectr::profiles::{self, ProfileSummary}; use nostr_manager_backend::profiles::{self, ProfileSummary};
use keynectr::publish; use nostr_manager_backend::publish;
use keynectr::relays; use nostr_manager_backend::relays;
use keynectr::settings::Theme; use nostr_manager_backend::settings::Theme;
use keynectr::signer::Signer; use nostr_manager_backend::signer::Signer;
use keynectr::vault::{self, StoredProfile, Vault}; use nostr_manager_backend::vault::{self, StoredProfile, Vault};
const USAGE: &str = "\ const USAGE: &str = "\
keynectr <command> [args...] nostr-manager-backend <command> [args...]
Commands: Commands:
create <label> Create a new profile create <label> Create a new profile
@ -133,7 +133,7 @@ fn cli_list() -> Result<String, AppError> {
fn cli_switch(args: &[String]) -> Result<String, AppError> { fn cli_switch(args: &[String]) -> Result<String, AppError> {
let npub = args let npub = args
.get(2) .get(2)
.ok_or_else(|| AppError::config("Usage: keynectr switch <npub>"))?; .ok_or_else(|| AppError::config("Usage: nostr-manager-backend switch <npub>"))?;
let mut app = App::load()?; let mut app = App::load()?;
profiles::set_active(&mut app.vault, npub)?; profiles::set_active(&mut app.vault, npub)?;
@ -143,7 +143,9 @@ fn cli_switch(args: &[String]) -> Result<String, AppError> {
async fn cli_publish(args: &[String]) -> Result<String, AppError> { async fn cli_publish(args: &[String]) -> Result<String, AppError> {
if args.len() < 4 { if args.len() < 4 {
return Err(AppError::config("Usage: keynectr publish <npub> <content>")); return Err(AppError::config(
"Usage: nostr-manager-backend publish <npub> <content>",
));
} }
let npub = &args[2]; let npub = &args[2];
@ -167,7 +169,9 @@ async fn cli_publish(args: &[String]) -> Result<String, AppError> {
fn cli_set_picture(args: &[String]) -> Result<String, AppError> { fn cli_set_picture(args: &[String]) -> Result<String, AppError> {
let [_, _, npub, url] = args else { let [_, _, npub, url] = args else {
return Err(AppError::config("Usage: keynectr set-picture <npub> <url>")); return Err(AppError::config(
"Usage: nostr-manager-backend set-picture <npub> <url>",
));
}; };
let mut app = load_app_with_unlock()?; let mut app = load_app_with_unlock()?;
@ -190,7 +194,7 @@ fn cli_set_picture(args: &[String]) -> Result<String, AppError> {
fn cli_publish_name(args: &[String]) -> Result<String, AppError> { fn cli_publish_name(args: &[String]) -> Result<String, AppError> {
let npub = args let npub = args
.get(2) .get(2)
.ok_or_else(|| AppError::config("Usage: keynectr publish-name <npub>"))?; .ok_or_else(|| AppError::config("Usage: nostr-manager-backend publish-name <npub>"))?;
let app = load_app_with_unlock()?; let app = load_app_with_unlock()?;
let key = app.vault_key().copied(); let key = app.vault_key().copied();
@ -223,7 +227,7 @@ async fn cli_feed(args: &[String]) -> Result<String, AppError> {
let limit = rest let limit = rest
.first() .first()
.and_then(|raw| raw.parse::<usize>().ok().filter(|n| *n > 0)) .and_then(|raw| raw.parse::<usize>().ok().filter(|n| *n > 0))
.unwrap_or(keynectr::feed::DEFAULT_LIMIT); .unwrap_or(nostr_manager_backend::feed::DEFAULT_LIMIT);
let app = App::load()?; let app = App::load()?;
let items = if contacts { let items = if contacts {
@ -232,10 +236,10 @@ async fn cli_feed(args: &[String]) -> Result<String, AppError> {
.active_profile .active_profile
.as_deref() .as_deref()
.ok_or_else(AppError::no_active_profile)?; .ok_or_else(AppError::no_active_profile)?;
let pubkey = keynectr::feed::owner_pubkey(npub)?; let pubkey = nostr_manager_backend::feed::owner_pubkey(npub)?;
keynectr::feed::contact_feed(&app.settings, limit, &pubkey.to_hex()).await? nostr_manager_backend::feed::contact_feed(&app.settings, limit, &pubkey.to_hex()).await?
} else { } else {
keynectr::feed::aggregate_feed(&app.settings, limit).await? nostr_manager_backend::feed::aggregate_feed(&app.settings, limit).await?
}; };
if items.is_empty() { if items.is_empty() {
return Ok(if contacts { return Ok(if contacts {
@ -270,7 +274,9 @@ fn shorten_note(content: &str) -> String {
async fn cli_relays(args: &[String]) -> Result<String, AppError> { async fn cli_relays(args: &[String]) -> Result<String, AppError> {
let sub = args.get(2).ok_or_else(|| { let sub = args.get(2).ok_or_else(|| {
AppError::config("Usage: keynectr relays <list|add|remove|enable|disable|test> [...]") AppError::config(
"Usage: nostr-manager-backend relays <list|add|remove|enable|disable|test> [...]",
)
})?; })?;
let mut app = App::load()?; let mut app = App::load()?;
@ -326,7 +332,7 @@ async fn cli_relays(args: &[String]) -> Result<String, AppError> {
fn cli_settings(args: &[String]) -> Result<String, AppError> { fn cli_settings(args: &[String]) -> Result<String, AppError> {
let sub = args let sub = args
.get(2) .get(2)
.ok_or_else(|| AppError::config("Usage: keynectr settings <get|set>"))?; .ok_or_else(|| AppError::config("Usage: nostr-manager-backend settings <get|set>"))?;
let mut app = App::load()?; let mut app = App::load()?;
@ -441,7 +447,7 @@ fn cli_unlock() -> Result<String, AppError> {
fn cli_show_secret(args: &[String]) -> Result<String, AppError> { fn cli_show_secret(args: &[String]) -> Result<String, AppError> {
let npub = args let npub = args
.get(2) .get(2)
.ok_or_else(|| AppError::config("Usage: keynectr show-secret <npub>"))?; .ok_or_else(|| AppError::config("Usage: nostr-manager-backend show-secret <npub>"))?;
let app = load_app_with_unlock()?; let app = load_app_with_unlock()?;
let revealed = profiles::reveal_secret_key(&app.vault, npub, app.vault_key())?; let revealed = profiles::reveal_secret_key(&app.vault, npub, app.vault_key())?;
@ -456,7 +462,7 @@ fn cli_show_secret(args: &[String]) -> Result<String, AppError> {
async fn cli_signer(args: &[String]) -> Result<String, AppError> { async fn cli_signer(args: &[String]) -> Result<String, AppError> {
let sub = args let sub = args
.get(2) .get(2)
.ok_or_else(|| AppError::config("Usage: keynectr signer <status|connect>"))?; .ok_or_else(|| AppError::config("Usage: nostr-manager-backend signer <status|connect>"))?;
match sub.as_str() { match sub.as_str() {
"status" => { "status" => {
@ -477,7 +483,7 @@ async fn cli_signer(args: &[String]) -> Result<String, AppError> {
), ),
"In the GUI, the signer listens as long as the app is running. From here, run:" "In the GUI, the signer listens as long as the app is running. From here, run:"
.to_string(), .to_string(),
" keynectr signer connect <nostrconnect://…>".to_string(), " nostr-manager-backend signer connect <nostrconnect://…>".to_string(),
"Sign/decrypt requests must be approved in the GUI signer screen.".to_string(), "Sign/decrypt requests must be approved in the GUI signer screen.".to_string(),
] ]
.join("\n")) .join("\n"))

194
src/main.rs.backup Normal file
View file

@ -0,0 +1,194 @@
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Serialize, Deserialize, Clone)]
struct Profile {
label: String,
public_key: String,
secret_key: String, // Stored as a plaintext hex string
created_at: u64,
}
const VAULT_FILE: &str = "profiles_vault.json";
fn load_vault() -> Result<Vec<Profile>, String> {
if !Path::new(VAULT_FILE).exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(VAULT_FILE)
.map_err(|e| format!("Failed to read {VAULT_FILE}: {e}"))?;
if content.trim().is_empty() {
return Ok(Vec::new());
}
serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse {VAULT_FILE}: {e}"))
}
fn save_vault(profiles: &[Profile]) -> Result<(), String> {
let content = serde_json::to_string_pretty(profiles)
.map_err(|e| format!("Failed to serialize profiles: {e}"))?;
fs::write(VAULT_FILE, content)
.map_err(|e| format!("Failed to write {VAULT_FILE}: {e}"))
}
fn unix_timestamp() -> Result<u64, String> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.map_err(|e| format!("System clock error: {e}"))
}
#[tokio::main]
async fn main() {
if let Err(error) = run().await {
eprintln!("Error: {error}");
std::process::exit(1);
}
}
async fn run() -> Result<(), String> {
let args: Vec<String> = env::args().collect();
let command = args.get(1).ok_or_else(|| {
[
"Usage: nostr-manager-backend <command> [args...]",
"Commands:",
" create <label>",
" list",
" switch <npub>",
" publish <npub> <content>",
]
.join("\n")
})?;
let result = match command.as_str() {
"create" => {
let label = args
.get(2)
.cloned()
.unwrap_or_else(|| "New Profile".to_string());
let keys = Keys::generate();
// Explicit ::hex prevents conflict with nostr_sdk::prelude::*.
let secret_hex =
::hex::encode(keys.secret_key().to_secret_bytes());
let public_key = keys
.public_key()
.to_bech32()
.map_err(|e| format!("Failed to encode public key: {e}"))?;
let profile = Profile {
label,
public_key: public_key.clone(),
secret_key: secret_hex,
created_at: unix_timestamp()?,
};
let mut profiles = load_vault()?;
profiles.push(profile);
save_vault(&profiles)?;
format!("Created profile: {public_key}")
}
"list" => {
let profiles = load_vault()?;
serde_json::to_string_pretty(&profiles)
.map_err(|e| format!("Failed to serialize profiles: {e}"))?
}
"switch" => {
let npub = args
.get(2)
.ok_or("Usage: nostr-manager-backend switch <npub>")?;
let profiles = load_vault()?;
if !profiles.iter().any(|profile| profile.public_key == *npub) {
return Err(format!("No stored profile found for {npub}"));
}
format!("Switched context to: {npub}")
}
"publish" => {
if args.len() < 4 {
return Err(
"Usage: nostr-manager-backend publish <npub> <content>"
.to_string(),
);
}
let npub = &args[2];
let content = args[3..].join(" ");
let profiles = load_vault()?;
let secret_hex = profiles
.iter()
.find(|profile| profile.public_key == *npub)
.map(|profile| profile.secret_key.clone())
.ok_or_else(|| format!("No stored profile found for {npub}"))?;
let secret_bytes = ::hex::decode(&secret_hex)
.map_err(|e| format!("Stored secret key is not valid hex: {e}"))?;
let secret_key = SecretKey::from_slice(&secret_bytes)
.map_err(|e| format!("Stored secret key is invalid: {e}"))?;
// Keys::from(secret_key) is incorrect for this nostr-sdk API.
let keys = Keys::new(secret_key);
let client = Client::new(keys.clone());
client
.add_relay("wss://relay.damus.io")
.await
.map_err(|e| format!("Failed to add Damus relay: {e}"))?;
client
.add_relay("wss://relay.nostr.band")
.await
.map_err(|e| format!("Failed to add nostr.band relay: {e}"))?;
client.connect().await;
let builder = EventBuilder::new(Kind::TextNote, content);
let event = builder
.sign(&keys)
.await
.map_err(|e| format!("Failed to sign event: {e}"))?;
client
.send_event(&event)
.await
.map_err(|e| format!("Failed to publish event: {e}"))?;
let event_id = event
.id
.to_bech32()
.map_err(|e| format!("Failed to encode event ID: {e}"))?;
format!("Published: {event_id}")
}
_ => {
return Err(format!("Unknown command: {command}"));
}
};
println!("{result}");
Ok(())
}

View file

@ -3,7 +3,6 @@ use std::fs;
use std::io::Write; use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Once;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use base64::engine::general_purpose::STANDARD as B64; use base64::engine::general_purpose::STANDARD as B64;
@ -11,6 +10,7 @@ use base64::Engine;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::errors::AppError; use crate::errors::AppError;
use crate::APP_DIR_NAME;
/// Current vault schema version. /// Current vault schema version.
pub const VAULT_VERSION: u32 = 2; pub const VAULT_VERSION: u32 = 2;
@ -114,57 +114,17 @@ pub fn unix_timestamp() -> Result<u64, AppError> {
/// Stable application-data directory for this app. /// Stable application-data directory for this app.
/// ///
/// Uses `$XDG_DATA_HOME` when set, otherwise `~/.local/share`. /// Uses `$XDG_DATA_HOME` when set, otherwise `~/.local/share`.
///
/// The first call also migrates a previous-named data directory (from before
/// the app was renamed) by moving it to the new location, so existing vaults,
/// settings and backups are preserved without any user action.
pub fn data_dir() -> PathBuf { pub fn data_dir() -> PathBuf {
let dir = if let Ok(dir) = env::var("XDG_DATA_HOME") { if let Ok(dir) = env::var("XDG_DATA_HOME") {
if !dir.trim().is_empty() { if !dir.trim().is_empty() {
PathBuf::from(dir).join(crate::APP_DIR_NAME) return PathBuf::from(dir).join(APP_DIR_NAME);
} else {
default_app_dir()
} }
} else { }
default_app_dir()
};
static MIGRATED: Once = Once::new();
MIGRATED.call_once(|| migrate_legacy_app_dir(&dir));
dir
}
fn default_app_dir() -> PathBuf {
let home = env::var("HOME").unwrap_or_else(|_| ".".to_string()); let home = env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home) PathBuf::from(home)
.join(".local") .join(".local")
.join("share") .join("share")
.join(crate::APP_DIR_NAME) .join(APP_DIR_NAME)
}
/// Move the legacy application-data directory to the new name, once.
///
/// No-op when the target already exists or there is nothing to move; a failed
/// move is reported rather than silently losing the data.
fn migrate_legacy_app_dir(target: &Path) {
if target.exists() {
return;
}
let legacy = match &target.parent() {
Some(parent) => parent.join(crate::LEGACY_APP_DIR_NAME),
None => return,
};
if !legacy.exists() {
return;
}
if let Err(e) = fs::rename(&legacy, target) {
// Leave both in place: the app starts fresh at `target` and the old
// data stays untouched for manual recovery.
eprintln!(
"Warning: could not move {} to {}: {e}",
legacy.display(),
target.display()
);
}
} }
pub fn vault_path() -> PathBuf { pub fn vault_path() -> PathBuf {
@ -442,7 +402,7 @@ mod tests {
fn temp_vault_path() -> PathBuf { fn temp_vault_path() -> PathBuf {
let dir = env::temp_dir().join(format!( let dir = env::temp_dir().join(format!(
"keynectr-test-{}-{}", "nost-feed-manager-test-{}-{}",
std::process::id(), std::process::id(),
COUNTER.fetch_add(1, Ordering::SeqCst) COUNTER.fetch_add(1, Ordering::SeqCst)
)); ));