Compare commits
8 commits
a6182e3cb2
...
c553dd5e17
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c553dd5e17 | ||
|
|
5e91914ad6 | ||
|
|
fac4ba3cde | ||
|
|
d792a72dc2 | ||
|
|
003f98a58e | ||
|
|
4d0df31726 | ||
|
|
f905cbcdad | ||
|
|
5b60ae3736 |
13 changed files with 584 additions and 26 deletions
|
|
@ -1,3 +1,80 @@
|
||||||
|
# Checkpoint — stdin EAGAIN fix, accent theme, identity backfill (2026-09-28 evening)
|
||||||
|
|
||||||
|
## Where things are
|
||||||
|
- Project: `/home/avi/Projects/Keynctr`, branch `master` @ **`fac4ba3`**
|
||||||
|
("feat(desktop): launch from the applications list (folio-style)").
|
||||||
|
Previous: `d792a72` (Archipelago uses the reference artwork as canvas),
|
||||||
|
`4d0df31` (Archipelago theme tokens, first pass). `5b60ae3` (persistent identity backfill for generic pairing
|
||||||
|
labels), `a6182e3` (iOS blue accent for Workshop Dark + custom accent
|
||||||
|
setting), `9770f46` (stdin blocking-thread reader — EAGAIN crash fix),
|
||||||
|
`6bff188` (docs), `dcc701f` (no-restart self-update).
|
||||||
|
- Working tree: clean for tracked files. Untracked intentionally NOT
|
||||||
|
committed: `COSMIC_THEME.md`, `KeynectrAppIconPossibility02.jpeg`,
|
||||||
|
`deferred/SignerConnectionPanel.tsx.wip/`.
|
||||||
|
- Release binary rebuilt 2026-09-28 ~12:40 (from the 4d0df31 tree).
|
||||||
|
- origin/master is at `a6182e3`; local is 1 commit ahead — push still blocked
|
||||||
|
(no Forgejo token stored for this session; HTTPS asks for credentials).
|
||||||
|
|
||||||
|
## Pairing problem status — RESOLVED (live evidence)
|
||||||
|
- `~/.local/share/keynectr/profiles_vault.json` now carries **3 persisted
|
||||||
|
`nip46_connections`** rows with matching `signer_mode: nip46_client` profile
|
||||||
|
rows (created Sep 25, Sep 27, and **Sep 28 10:23**). The Sep 28 pairing
|
||||||
|
happened on the current build: Amber's connect event parses, the handshake
|
||||||
|
completes, and the profile row persists — the original
|
||||||
|
"Amber says connected but no row appears" failure no longer reproduces.
|
||||||
|
- The Sep 28 row is the **active profile**. Its label is still the generic
|
||||||
|
"Amber" because the paired account has not published kind-0 yet; `5b60ae3`
|
||||||
|
backfill re-checks every ~2-5 min while the backend runs and will rename it
|
||||||
|
when metadata appears. No further parse-side work outstanding.
|
||||||
|
|
||||||
|
## What was completed (this checkpoint's commits)
|
||||||
|
1. **`9770f46` stdin EAGAIN crash fix** — `keynectr serve` treated a transient
|
||||||
|
EAGAIN on the Electron pipe as fatal ("Rust backend exited unexpectedly
|
||||||
|
code 1"). Dedicated blocking-thread reader + mpsc; verified live with a
|
||||||
|
200-line request stream.
|
||||||
|
2. **`a6182e3` custom accent color** — iOS blue accent for Workshop Dark plus
|
||||||
|
a user-settable accent color.
|
||||||
|
3. **`5b60ae3` persistent identity backfill** — slow-cadence loop in `serve()`
|
||||||
|
re-fetches kind-0 for Nip46Client rows still wearing generic
|
||||||
|
"Amber"/"Remote Signer" placeholders; user renames never overwritten;
|
||||||
|
locked vaults skipped; network off-lock, 90 s budget per identity.
|
||||||
|
4. **`4d0df31` Archipelago theme** — new `archipelago` theme from the user's
|
||||||
|
synthwave reference art: teal-navy dusk canvas, coral (#f0685c) scanline
|
||||||
|
sun glow + pink cloud banks + faint horizontal scanlines on `.main`
|
||||||
|
(fixed attachment), glassmorphic blur on cards/sidebar, white logo filter.
|
||||||
|
Settings -> Appearance -> "Archipelago - Sunset Sea".
|
||||||
|
5. **`d792a72` Archipelago artwork canvas (v2)** — first pass was abstract
|
||||||
|
washes and did not match the reference; replaced with the actual
|
||||||
|
illustration (`frontend/public/archipelago-bg.jpg`) cover/fixed under a
|
||||||
|
dark scrim on `.main`, translucent blur-glass cards + sidebar, light
|
||||||
|
hairline borders, coral accent. This is what the reference mock shows.
|
||||||
|
6. **`fac4ba3` applications-list launcher** — `launch-keynctr.sh` +
|
||||||
|
`~/.local/share/applications/keynctr.desktop` (validated); GPU env set
|
||||||
|
for Hyprland; single-instance lock focuses the existing window.
|
||||||
|
Verified by gtk-launch twice: one window, correct WM class.
|
||||||
|
|
||||||
|
## Verification (2026-09-28 evening cron pass)
|
||||||
|
- `cargo test` → 221 unit + 6 e2e passed, 0 failed.
|
||||||
|
- `cargo clippy --all-targets` → 0 warnings. `cargo fmt --check` → clean.
|
||||||
|
- `frontend`: `npm test` → 139 passed (19 files); `typecheck`, `lint`,
|
||||||
|
`format:check` all clean.
|
||||||
|
|
||||||
|
## How to resume
|
||||||
|
- GUI: `cd ~/Projects/Keynctr/frontend && npx vite --port 5173` then
|
||||||
|
`NOSTR_GUI_DEV_URL=http://localhost:5173 KEYNCTR_ENABLE_GPU=1 npx electron .`
|
||||||
|
- CLI backend: `target/release/keynectr serve` (spawned by Electron).
|
||||||
|
- Push pending commit when credentials are available: `git push origin master`.
|
||||||
|
|
||||||
|
## Outstanding / next steps
|
||||||
|
- Live confirmation of the backfill rename: pair/keep a fresh Amber account
|
||||||
|
with no kind-0, publish kind-0 from Amber, confirm the Keynctr row renames
|
||||||
|
itself within ~5 min without restarting the app.
|
||||||
|
- Step 4 permissions UI follow-ups, Step 5 KDF, Step 7 rename/hygiene
|
||||||
|
(unchanged from previous checkpoint).
|
||||||
|
- `package.json` `homepage` still points at github.com/avi/Keynctr (Step 7).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Checkpoint — permissions UI + updater fix + Workshop theme (2026-09-27 night)
|
# Checkpoint — permissions UI + updater fix + Workshop theme (2026-09-27 night)
|
||||||
|
|
||||||
## Where things are
|
## Where things are
|
||||||
|
|
|
||||||
|
|
@ -1002,6 +1002,24 @@ if (process.platform === 'linux') {
|
||||||
app.setDesktopName('keynectr.desktop');
|
app.setDesktopName('keynectr.desktop');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Launched from the applications list a second time? Focus the existing
|
||||||
|
// window instead of starting a duplicate app (two Electron shells would
|
||||||
|
// each spawn their own backend and fight over the vault file).
|
||||||
|
const gotInstanceLock = app.requestSingleInstanceLock();
|
||||||
|
if (!gotInstanceLock) {
|
||||||
|
// Hard exit: app.quit() is async and would let the rest of this module
|
||||||
|
// (whenReady → backend spawn) run in the doomed second instance.
|
||||||
|
app.exit(0);
|
||||||
|
} else {
|
||||||
|
app.on('second-instance', () => {
|
||||||
|
const [window] = BrowserWindow.getAllWindows();
|
||||||
|
if (window) {
|
||||||
|
if (window.isMinimized()) window.restore();
|
||||||
|
window.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
protocol.handle('app', (request) => {
|
protocol.handle('app', (request) => {
|
||||||
const { pathname } = new URL(request.url);
|
const { pathname } = new URL(request.url);
|
||||||
|
|
|
||||||
BIN
frontend/public/archipelago-bg.jpg
Normal file
BIN
frontend/public/archipelago-bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
|
|
@ -7,7 +7,8 @@ export type Theme =
|
||||||
| 'impeccable-dark'
|
| 'impeccable-dark'
|
||||||
| 'cosmic'
|
| 'cosmic'
|
||||||
| 'workshop'
|
| 'workshop'
|
||||||
| 'workshop-dark';
|
| 'workshop-dark'
|
||||||
|
| 'archipelago';
|
||||||
|
|
||||||
/** Active signer mode. */
|
/** Active signer mode. */
|
||||||
export type SignerMode = 'embedded' | 'nip46_bunker' | 'nip46_client';
|
export type SignerMode = 'embedded' | 'nip46_bunker' | 'nip46_client';
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,7 @@ export function SettingsScreen() {
|
||||||
<option value="cosmic">Cosmic — Stardust</option>
|
<option value="cosmic">Cosmic — Stardust</option>
|
||||||
<option value="workshop">Workshop — Cybernetic</option>
|
<option value="workshop">Workshop — Cybernetic</option>
|
||||||
<option value="workshop-dark">Workshop — Dark</option>
|
<option value="workshop-dark">Workshop — Dark</option>
|
||||||
|
<option value="archipelago">Archipelago — Sunset Sea</option>
|
||||||
</select>
|
</select>
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|
|
||||||
|
|
@ -706,6 +706,83 @@ html[data-theme='workshop'] .card {
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------
|
||||||
|
Archipelago — the user's reference artwork IS the canvas: the
|
||||||
|
synthwave sun-and-tower vista behind frosted-glass panels, with a
|
||||||
|
dark scrim so text stays legible, exactly like the reference mock.
|
||||||
|
------------------------------------------------------------------ */
|
||||||
|
:root[data-theme='archipelago'] {
|
||||||
|
--bg: #0c1418;
|
||||||
|
--surface: rgba(13, 22, 27, 0.58);
|
||||||
|
--surface-2: rgba(20, 34, 41, 0.72);
|
||||||
|
--surface-hover: rgba(26, 44, 52, 0.78);
|
||||||
|
--border: rgba(140, 190, 200, 0.22);
|
||||||
|
--border-strong: rgba(170, 215, 225, 0.35);
|
||||||
|
--text: #f2f7f6;
|
||||||
|
--text-muted: #b7ccd0;
|
||||||
|
--primary: #f0685c;
|
||||||
|
--primary-hover: #ff8578;
|
||||||
|
--primary-soft: rgba(240, 104, 92, 0.18);
|
||||||
|
--on-primary: #ffffff;
|
||||||
|
--danger: #e2554f;
|
||||||
|
--danger-soft: rgba(226, 85, 79, 0.16);
|
||||||
|
--warning: #e8b26a;
|
||||||
|
--warning-soft: rgba(232, 178, 106, 0.14);
|
||||||
|
--success: #4ed0a5;
|
||||||
|
--success-soft: rgba(78, 208, 165, 0.14);
|
||||||
|
--info: #6fa8d8;
|
||||||
|
--info-soft: rgba(111, 168, 216, 0.14);
|
||||||
|
--focus: #f0685c;
|
||||||
|
--shadow: 0 2px 10px rgba(0, 0, 0, 0.35), 0 12px 32px rgba(0, 0, 0, 0.35);
|
||||||
|
--shadow-modal: 0 14px 44px rgba(0, 0, 0, 0.65);
|
||||||
|
--radius: 16px;
|
||||||
|
--radius-sm: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The vista fills the whole content surface: scrim first (legibility),
|
||||||
|
then the artwork, sized to cover. Fixed so it stays put while
|
||||||
|
content scrolls behind the glass. */
|
||||||
|
html[data-theme='archipelago'] .main {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(8, 13, 17, 0.55), rgba(8, 13, 17, 0.72)), url('/archipelago-bg.jpg');
|
||||||
|
background-size: cover, cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Frosted glass panels — the artwork shows through, blurred, like the
|
||||||
|
reference dashboard cards. */
|
||||||
|
html[data-theme='archipelago'] .card,
|
||||||
|
html[data-theme='archipelago'] .profile-card,
|
||||||
|
html[data-theme='archipelago'] .relay-row {
|
||||||
|
backdrop-filter: blur(14px) saturate(1.15);
|
||||||
|
-webkit-backdrop-filter: blur(14px) saturate(1.15);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='archipelago'] .sidebar {
|
||||||
|
background: rgba(10, 17, 21, 0.62);
|
||||||
|
backdrop-filter: blur(16px) saturate(1.1);
|
||||||
|
-webkit-backdrop-filter: blur(16px) saturate(1.1);
|
||||||
|
border-right-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active nav pill picks up the coral sun accent. */
|
||||||
|
html[data-theme='archipelago'] .nav-item.is-active {
|
||||||
|
background: var(--primary-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='archipelago'] .sidebar-logo img {
|
||||||
|
filter: invert(1) grayscale(1) brightness(1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='archipelago'] code,
|
||||||
|
html[data-theme='archipelago'] .mono {
|
||||||
|
background: rgba(20, 34, 41, 0.8);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
launch-keynctr.sh
Executable file
24
launch-keynctr.sh
Executable file
|
|
@ -0,0 +1,24 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Launch Keynctr desktop (Electron shell + Rust backend spawned on demand).
|
||||||
|
# Production mode: loads the built renderer (frontend/dist) — no Vite needed.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_DIR="/home/avi/Projects/Keynctr/frontend"
|
||||||
|
cd "$APP_DIR"
|
||||||
|
|
||||||
|
# The Electron shell spawns target/release/keynectr; make sure it exists and
|
||||||
|
# is newer-checked lazily by the app itself. We only guarantee the renderer
|
||||||
|
# bundle is present, else build it (first run or after a clean checkout).
|
||||||
|
if [[ ! -f "$APP_DIR/dist/index.html" || ! -f "$APP_DIR/dist-electron/main.js" ]]; then
|
||||||
|
npm run build >/dev/null 2>&1 || npm run build
|
||||||
|
npm run electron:build >/dev/null 2>&1 || npm run electron:build
|
||||||
|
fi
|
||||||
|
|
||||||
|
# On this Hyprland machine native-Wayland rendering needs hardware GL:
|
||||||
|
# software rendering dies with "GPU process isn't usable" (verified
|
||||||
|
# 2026-09-30). Same setting the dev workflow uses.
|
||||||
|
export KEYNCTR_ENABLE_GPU=1
|
||||||
|
|
||||||
|
# --class pins WM_CLASS so the launcher icon groups with the window
|
||||||
|
# (StartupWMClass=keynectr in the .desktop file).
|
||||||
|
exec "$APP_DIR/node_modules/.bin/electron" --class=keynectr "$APP_DIR" "$@"
|
||||||
239
src/app.rs
239
src/app.rs
|
|
@ -169,8 +169,16 @@ impl App {
|
||||||
self.signing_for(&npub).await
|
self.signing_for(&npub).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify a password and keep the derived key in memory for the session.
|
/// Verify a password, keep the derived key in memory for the session,
|
||||||
pub fn unlock(&mut self, password: &str) -> Result<(), AppError> {
|
/// and transparently upgrade the vault's KDF if it is still encrypted
|
||||||
|
/// under the legacy parameters (19 MiB / t=2).
|
||||||
|
///
|
||||||
|
/// Returns `true` when the vault was re-wrapped under the current
|
||||||
|
/// [`crate::crypto::KDF_M_COST`]/[`crate::crypto::KDF_T_COST`] (the caller
|
||||||
|
/// should persist the vault), `false` for a plain unlock. A wrong password
|
||||||
|
/// never touches the vault header: the upgrade only runs after the old
|
||||||
|
/// verifier has accepted the supplied password.
|
||||||
|
pub fn unlock(&mut self, password: &str) -> Result<bool, AppError> {
|
||||||
let crypto = self
|
let crypto = self
|
||||||
.vault
|
.vault
|
||||||
.crypto
|
.crypto
|
||||||
|
|
@ -183,8 +191,41 @@ impl App {
|
||||||
key.zeroize();
|
key.zeroize();
|
||||||
return Err(AppError::wrong_password());
|
return Err(AppError::wrong_password());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let is_legacy = crypto.kdf.m_cost == crypto::LEGACY_KDF_M_COST
|
||||||
|
&& crypto.kdf.t_cost == crypto::LEGACY_KDF_T_COST;
|
||||||
|
if !is_legacy {
|
||||||
self.unlock_key = Some(key);
|
self.unlock_key = Some(key);
|
||||||
Ok(())
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password accepted under the legacy parameters: re-derive with the
|
||||||
|
// current ones and re-wrap every stored secret under the new key.
|
||||||
|
let upgraded = {
|
||||||
|
let salt = crypto::generate_salt()?;
|
||||||
|
let new_key = crypto::derive_key(
|
||||||
|
password,
|
||||||
|
&salt,
|
||||||
|
crypto::KDF_M_COST,
|
||||||
|
crypto::KDF_T_COST,
|
||||||
|
crypto::KDF_P_COST,
|
||||||
|
)?;
|
||||||
|
rewrap_secrets(&mut self.vault, Some(&key), &new_key)?;
|
||||||
|
key.zeroize();
|
||||||
|
self.vault.crypto = Some(VaultCrypto {
|
||||||
|
kdf: KdfParams {
|
||||||
|
algorithm: "argon2id".to_string(),
|
||||||
|
salt: B64.encode(salt),
|
||||||
|
m_cost: crypto::KDF_M_COST,
|
||||||
|
t_cost: crypto::KDF_T_COST,
|
||||||
|
p_cost: crypto::KDF_P_COST,
|
||||||
|
},
|
||||||
|
verifier: crypto::make_verifier(&new_key)?,
|
||||||
|
});
|
||||||
|
new_key
|
||||||
|
};
|
||||||
|
self.unlock_key = Some(upgraded);
|
||||||
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop the derived key, re-locking the vault for the session.
|
/// Drop the derived key, re-locking the vault for the session.
|
||||||
|
|
@ -348,25 +389,14 @@ impl App {
|
||||||
crypto::KDF_P_COST,
|
crypto::KDF_P_COST,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let mut encrypted = Vec::with_capacity(self.vault.profiles.len());
|
// Re-wrap every secret store (profiles, connection secrets, client
|
||||||
for profile in &self.vault.profiles {
|
// keys) so no credential keeps its old wrapping.
|
||||||
// Decrypted plaintexts are Zeroizing: each wipes itself once the
|
rewrap_secrets(&mut self.vault, previous_key.as_ref(), &new_key)?;
|
||||||
// re-encrypted replacement has been produced.
|
// Wipe the old vault key now that everything is re-encrypted.
|
||||||
let plaintext = match &previous_key {
|
|
||||||
Some(key) => crypto::decrypt_secret(key, &profile.secret_key)?,
|
|
||||||
None => Zeroizing::new(profile.secret_key.clone()),
|
|
||||||
};
|
|
||||||
encrypted.push(StoredProfile {
|
|
||||||
secret_key: crypto::encrypt_secret(&new_key, &plaintext)?,
|
|
||||||
..profile.clone()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Wipe the old vault key now that every profile is re-encrypted.
|
|
||||||
if let Some(mut key) = previous_key {
|
if let Some(mut key) = previous_key {
|
||||||
key.zeroize();
|
key.zeroize();
|
||||||
}
|
}
|
||||||
|
|
||||||
self.vault.profiles = encrypted;
|
|
||||||
self.vault.crypto = Some(VaultCrypto {
|
self.vault.crypto = Some(VaultCrypto {
|
||||||
kdf: KdfParams {
|
kdf: KdfParams {
|
||||||
algorithm: "argon2id".to_string(),
|
algorithm: "argon2id".to_string(),
|
||||||
|
|
@ -467,6 +497,47 @@ fn derive_with(crypto: &VaultCrypto, password: &str) -> Result<VaultKey, AppErro
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-encrypt every stored secret in the vault from `old_key` to `new_key`.
|
||||||
|
///
|
||||||
|
/// Covers all three secret stores — profile keys, NIP-46 connection secrets,
|
||||||
|
/// and NIP-46 client keys — so a password change or KDF upgrade can never
|
||||||
|
/// leave one collection wrapped under the previous key. `old_key` is `None`
|
||||||
|
/// only when the vault was unencrypted (secrets are plaintext on the way in).
|
||||||
|
/// Each plaintext is [`Zeroizing`]: shredded as soon as its replacement
|
||||||
|
/// ciphertext exists.
|
||||||
|
fn rewrap_secrets(
|
||||||
|
vault: &mut Vault,
|
||||||
|
old_key: Option<&VaultKey>,
|
||||||
|
new_key: &VaultKey,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let decrypt_one = |blob: &str| -> Result<Zeroizing<String>, AppError> {
|
||||||
|
match old_key {
|
||||||
|
Some(key) => crypto::decrypt_secret(key, blob),
|
||||||
|
None => Ok(Zeroizing::new(blob.to_string())),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut profiles = Vec::with_capacity(vault.profiles.len());
|
||||||
|
for profile in &vault.profiles {
|
||||||
|
let plaintext = decrypt_one(&profile.secret_key)?;
|
||||||
|
profiles.push(StoredProfile {
|
||||||
|
secret_key: crypto::encrypt_secret(new_key, &plaintext)?,
|
||||||
|
..profile.clone()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
vault.profiles = profiles;
|
||||||
|
|
||||||
|
for entry in &mut vault.connection_secrets {
|
||||||
|
let plaintext = decrypt_one(&entry.secret)?;
|
||||||
|
entry.secret = crypto::encrypt_secret(new_key, &plaintext)?;
|
||||||
|
}
|
||||||
|
for entry in &mut vault.connection_client_keys {
|
||||||
|
let plaintext = decrypt_one(&entry.secret_hex)?;
|
||||||
|
entry.secret_hex = crypto::encrypt_secret(new_key, &plaintext)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -607,6 +678,138 @@ mod tests {
|
||||||
assert_eq!(err.kind(), ErrorKind::Config);
|
assert_eq!(err.kind(), ErrorKind::Config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a vault encrypted under explicit (legacy) KDF parameters,
|
||||||
|
/// exactly as an older app version would have written it, and return the
|
||||||
|
/// matching password. Also stores one connection secret and one client
|
||||||
|
/// key encrypted under the same legacy key.
|
||||||
|
fn legacy_kdf_app(m_cost: u32, t_cost: u32) -> App {
|
||||||
|
use crate::crypto;
|
||||||
|
let password = "correct horse battery staple";
|
||||||
|
let salt = crypto::generate_salt().unwrap();
|
||||||
|
let key = crypto::derive_key(password, &salt, m_cost, t_cost, crypto::KDF_P_COST).unwrap();
|
||||||
|
let mut app = sample_app();
|
||||||
|
let ref_ = crate::signer::VaultRef::new(Some("npub1test".to_string()), "aabb".to_string());
|
||||||
|
app.vault.crypto = Some(VaultCrypto {
|
||||||
|
kdf: KdfParams {
|
||||||
|
algorithm: "argon2id".to_string(),
|
||||||
|
salt: B64.encode(salt),
|
||||||
|
m_cost,
|
||||||
|
t_cost,
|
||||||
|
p_cost: crypto::KDF_P_COST,
|
||||||
|
},
|
||||||
|
verifier: crypto::make_verifier(&key).unwrap(),
|
||||||
|
});
|
||||||
|
// crypto is set first so these store ENCRYPTED under the legacy key,
|
||||||
|
// exactly as a running pre-upgrade app would have written them.
|
||||||
|
for profile in &mut app.vault.profiles {
|
||||||
|
profile.secret_key = crypto::encrypt_secret(&key, &profile.secret_key).unwrap();
|
||||||
|
}
|
||||||
|
vault::store_connection_secret(&mut app.vault, Some(&key), &ref_, "pairing-secret")
|
||||||
|
.unwrap();
|
||||||
|
vault::store_connection_client_key(
|
||||||
|
&mut app.vault,
|
||||||
|
Some(&key),
|
||||||
|
&ref_,
|
||||||
|
"cd".repeat(32).as_str(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unlock_upgrades_legacy_kdf_params() {
|
||||||
|
let mut app = legacy_kdf_app(crypto::LEGACY_KDF_M_COST, crypto::LEGACY_KDF_T_COST);
|
||||||
|
let legacy_m = app.vault.crypto.as_ref().unwrap().kdf.m_cost;
|
||||||
|
assert_ne!(legacy_m, crypto::KDF_M_COST, "test vault must be legacy");
|
||||||
|
|
||||||
|
let upgraded = app
|
||||||
|
.unlock("correct horse battery staple")
|
||||||
|
.expect("legacy vault must unlock");
|
||||||
|
assert!(upgraded, "unlock must report a KDF upgrade happened");
|
||||||
|
|
||||||
|
let kdf = &app.vault.crypto.as_ref().unwrap().kdf;
|
||||||
|
assert_eq!(kdf.m_cost, crypto::KDF_M_COST);
|
||||||
|
assert_eq!(kdf.t_cost, crypto::KDF_T_COST);
|
||||||
|
|
||||||
|
// The new key must decrypt every profile secret re-wrapped under it,
|
||||||
|
// and the new verifier must accept it.
|
||||||
|
let key = app.vault_key().expect("unlocked");
|
||||||
|
assert!(crypto::verify(
|
||||||
|
key,
|
||||||
|
&app.vault.crypto.as_ref().unwrap().verifier
|
||||||
|
));
|
||||||
|
let secret = profiles::resolve_secret_key(
|
||||||
|
&app.vault,
|
||||||
|
&app.vault.profiles[0].public_key.clone(),
|
||||||
|
app.vault_key(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!secret.is_empty());
|
||||||
|
|
||||||
|
// Unlocking again is a no-op: already at current parameters.
|
||||||
|
app.lock();
|
||||||
|
assert!(
|
||||||
|
!app.unlock("correct horse battery staple").unwrap(),
|
||||||
|
"current-params vault must not re-upgrade"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kdf_upgrade_rewraps_connection_secrets_and_client_keys() {
|
||||||
|
let mut app = legacy_kdf_app(crypto::LEGACY_KDF_M_COST, crypto::LEGACY_KDF_T_COST);
|
||||||
|
let ref_ = crate::signer::VaultRef::new(Some("npub1test".to_string()), "aabb".to_string());
|
||||||
|
app.unlock("correct horse battery staple").unwrap();
|
||||||
|
|
||||||
|
let secret = vault::resolve_connection_secret(&app.vault, app.vault_key(), &ref_)
|
||||||
|
.unwrap()
|
||||||
|
.expect("connection secret survives the upgrade");
|
||||||
|
assert_eq!(secret.as_str(), "pairing-secret");
|
||||||
|
let client_key = vault::resolve_connection_client_key(&app.vault, app.vault_key(), &ref_)
|
||||||
|
.unwrap()
|
||||||
|
.expect("client key survives the upgrade");
|
||||||
|
assert_eq!(client_key.as_str(), "cd".repeat(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_password_does_not_upgrade_kdf() {
|
||||||
|
let mut app = legacy_kdf_app(crypto::LEGACY_KDF_M_COST, crypto::LEGACY_KDF_T_COST);
|
||||||
|
let err = app
|
||||||
|
.unlock("not the password")
|
||||||
|
.expect_err("wrong password must fail");
|
||||||
|
assert_eq!(err.kind(), ErrorKind::WrongPassword);
|
||||||
|
let kdf = &app.vault.crypto.as_ref().unwrap().kdf;
|
||||||
|
assert_eq!(kdf.m_cost, crypto::LEGACY_KDF_M_COST, "header untouched");
|
||||||
|
assert!(app.is_locked());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_password_rewraps_connection_secrets_and_client_keys() {
|
||||||
|
// Plaintext vault with connection credentials: setting a password
|
||||||
|
// must encrypt EVERY secret, not just profile keys.
|
||||||
|
let mut app = sample_app();
|
||||||
|
let ref_ = crate::signer::VaultRef::new(Some("npub1test".to_string()), "aabb".to_string());
|
||||||
|
vault::store_connection_secret(&mut app.vault, None, &ref_, "pairing-secret").unwrap();
|
||||||
|
vault::store_connection_client_key(&mut app.vault, None, &ref_, "ef".repeat(32).as_str())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
app.set_password(None, "correct horse battery staple")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&app.vault).unwrap();
|
||||||
|
assert!(
|
||||||
|
!serialized.contains("pairing-secret"),
|
||||||
|
"pairing secret must not survive in plaintext"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!serialized.contains(&"ef".repeat(32)),
|
||||||
|
"client key must not survive in plaintext"
|
||||||
|
);
|
||||||
|
let secret = vault::resolve_connection_secret(&app.vault, app.vault_key(), &ref_)
|
||||||
|
.unwrap()
|
||||||
|
.expect("secret still resolvable after encryption");
|
||||||
|
assert_eq!(secret.as_str(), "pairing-secret");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unlock_roundtrip_with_wrong_then_right_password() {
|
fn unlock_roundtrip_with_wrong_then_right_password() {
|
||||||
let mut app = sample_app();
|
let mut app = sample_app();
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,22 @@ pub const SALT_LEN: usize = 16;
|
||||||
/// AES-GCM nonce length in bytes.
|
/// AES-GCM nonce length in bytes.
|
||||||
pub const NONCE_LEN: usize = 12;
|
pub const NONCE_LEN: usize = 12;
|
||||||
|
|
||||||
/// Argon2id memory cost in KiB (RFC 9106 recommendation).
|
/// Argon2id memory cost in KiB (64 MiB — OWASP 2024 recommendation).
|
||||||
pub const KDF_M_COST: u32 = 19 * 1024;
|
pub const KDF_M_COST: u32 = 64 * 1024;
|
||||||
/// Argon2id time cost (iterations).
|
/// Argon2id time cost (iterations).
|
||||||
pub const KDF_T_COST: u32 = 2;
|
pub const KDF_T_COST: u32 = 3;
|
||||||
/// Argon2id parallelism.
|
/// Argon2id parallelism.
|
||||||
pub const KDF_P_COST: u32 = 1;
|
pub const KDF_P_COST: u32 = 1;
|
||||||
|
|
||||||
|
/// Memory cost used by app versions before the Step 5 upgrade (RFC 9106's
|
||||||
|
/// 19 MiB / t=2). Vaults carrying exactly these parameters are transparently
|
||||||
|
/// re-wrapped under [`KDF_M_COST`]/[`KDF_T_COST`] on next unlock; vaults with
|
||||||
|
/// any OTHER parameters keep them — the header is authoritative, and these
|
||||||
|
/// constants are only the definition of "legacy".
|
||||||
|
pub const LEGACY_KDF_M_COST: u32 = 19 * 1024;
|
||||||
|
/// Time cost used by pre-upgrade versions (see [`LEGACY_KDF_M_COST`]).
|
||||||
|
pub const LEGACY_KDF_T_COST: u32 = 2;
|
||||||
|
|
||||||
/// The in-memory key that unlocks an encrypted vault.
|
/// The in-memory key that unlocks an encrypted vault.
|
||||||
pub type VaultKey = [u8; KEY_LEN];
|
pub type VaultKey = [u8; KEY_LEN];
|
||||||
|
|
||||||
|
|
|
||||||
110
src/ipc.rs
110
src/ipc.rs
|
|
@ -5,6 +5,8 @@ use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use nostr_sdk::prelude::PublicKey;
|
||||||
|
|
||||||
use crate::app::{App, Nip46ClientSignerHandle};
|
use crate::app::{App, Nip46ClientSignerHandle};
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
use crate::feed;
|
use crate::feed;
|
||||||
|
|
@ -331,6 +333,107 @@ pub async fn serve() -> Result<(), AppError> {
|
||||||
|
|
||||||
let mut tasks = JoinSet::new();
|
let mut tasks = JoinSet::new();
|
||||||
|
|
||||||
|
// Persistent identity backfill. Pairing-time enrichment tries for ~2
|
||||||
|
// minutes and stops; if the account's kind-0 was never published (or was
|
||||||
|
// published later), the row would keep its generic "Amber"/"Remote
|
||||||
|
// Signer" placeholder forever — which is what a new user with a fresh
|
||||||
|
// Amber account sees, with no way to know the app resolved nothing.
|
||||||
|
// This loop retries on a slow cadence while the backend runs: every pass
|
||||||
|
// collects remote rows still wearing a generic placeholder (only once
|
||||||
|
// the vault is unlocked — an encrypted vault hides them otherwise),
|
||||||
|
// fetches kind-0 from the enabled relays, and upgrades the row. A user
|
||||||
|
// rename always wins: is_generic_pairing_label is the sole gate on the
|
||||||
|
// label write. Cheap when nothing is pending (one vault scan per pass).
|
||||||
|
{
|
||||||
|
let app = app.clone();
|
||||||
|
tasks.spawn(async move {
|
||||||
|
loop {
|
||||||
|
// Collect pending identities without holding the lock
|
||||||
|
// across network work.
|
||||||
|
let pending: Vec<(String, PublicKey)> = {
|
||||||
|
let guard = app.lock().await;
|
||||||
|
if guard.is_locked() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
guard
|
||||||
|
.vault
|
||||||
|
.profiles
|
||||||
|
.iter()
|
||||||
|
.filter(|p| {
|
||||||
|
p.signer_mode == SignerMode::Nip46Client
|
||||||
|
&& profiles::is_generic_pairing_label(&p.label)
|
||||||
|
})
|
||||||
|
.filter_map(|p| {
|
||||||
|
PublicKey::parse(&p.public_key)
|
||||||
|
.ok()
|
||||||
|
.map(|key| (p.public_key.clone(), key))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if pending.is_empty() {
|
||||||
|
tokio::time::sleep(Duration::from_secs(300)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let relay_urls = {
|
||||||
|
let guard = app.lock().await;
|
||||||
|
relays::enabled_urls(&guard.settings)
|
||||||
|
};
|
||||||
|
for (npub, identity) in pending {
|
||||||
|
let found = tokio::time::timeout(
|
||||||
|
Duration::from_secs(90),
|
||||||
|
tokio::task::spawn_blocking({
|
||||||
|
let relay_urls = relay_urls.clone();
|
||||||
|
move || profiles::fetch_profile_metadata(&identity, &relay_urls)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|join| join.ok())
|
||||||
|
.flatten();
|
||||||
|
let Some(meta) = found else { continue };
|
||||||
|
let mut guard = app.lock().await;
|
||||||
|
let mut changed = false;
|
||||||
|
// Re-resolve by pubkey string: another request (rename,
|
||||||
|
// a pairing-time enrichment) may have edited the row
|
||||||
|
// while this fetch was in flight.
|
||||||
|
if let Some(row) = guard
|
||||||
|
.vault
|
||||||
|
.profiles
|
||||||
|
.iter_mut()
|
||||||
|
.find(|p| p.public_key == npub)
|
||||||
|
{
|
||||||
|
if row.picture.is_none() && meta.picture.is_some() {
|
||||||
|
row.picture = meta.picture.clone();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if row.nip05.is_none() && meta.nip05.is_some() {
|
||||||
|
row.nip05 = meta.nip05.clone();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
let real_name = meta
|
||||||
|
.display_name
|
||||||
|
.as_deref()
|
||||||
|
.or(meta.name.as_deref())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|name| !name.is_empty());
|
||||||
|
if let Some(name) = real_name {
|
||||||
|
if profiles::is_generic_pairing_label(&row.label) {
|
||||||
|
row.label = name.to_string();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
let _ = guard.save_vault();
|
||||||
|
eprintln!("[backfill] resolved profile identity via kind-0: {npub}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(120)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
while let Some(line) = line_rx.recv().await {
|
while let Some(line) = line_rx.recv().await {
|
||||||
if line.trim().is_empty() {
|
if line.trim().is_empty() {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -921,7 +1024,12 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
|
||||||
}
|
}
|
||||||
|
|
||||||
Request::UnlockVault { password } => {
|
Request::UnlockVault { password } => {
|
||||||
app.unlock(&password)?;
|
let upgraded = app.unlock(&password)?;
|
||||||
|
if upgraded {
|
||||||
|
// The vault was transparently re-wrapped under stronger KDF
|
||||||
|
// parameters: persist immediately so the upgrade sticks.
|
||||||
|
app.save_vault()?;
|
||||||
|
}
|
||||||
// Re-initialize signers with unlocked vault
|
// Re-initialize signers with unlocked vault
|
||||||
if app.signer_mode == SignerMode::Embedded {
|
if app.signer_mode == SignerMode::Embedded {
|
||||||
if let Some(signer) = &app.embedded_signer {
|
if let Some(signer) = &app.embedded_signer {
|
||||||
|
|
|
||||||
12
src/main.rs
12
src/main.rs
|
|
@ -483,7 +483,10 @@ fn load_app_with_unlock() -> Result<App, AppError> {
|
||||||
let mut app = App::load()?;
|
let mut app = App::load()?;
|
||||||
if app.is_locked() {
|
if app.is_locked() {
|
||||||
let password = prompt_password("Vault password: ")?;
|
let password = prompt_password("Vault password: ")?;
|
||||||
app.unlock(&password)?;
|
if app.unlock(&password)? {
|
||||||
|
// Transparent KDF upgrade: persist the re-wrapped vault.
|
||||||
|
app.save_vault()?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(app)
|
Ok(app)
|
||||||
}
|
}
|
||||||
|
|
@ -531,7 +534,12 @@ fn cli_unlock() -> Result<String, AppError> {
|
||||||
return Err(AppError::config("Your vault is not encrypted."));
|
return Err(AppError::config("Your vault is not encrypted."));
|
||||||
}
|
}
|
||||||
let password = prompt_password("Vault password: ")?;
|
let password = prompt_password("Vault password: ")?;
|
||||||
app.unlock(&password)?;
|
if app.unlock(&password)? {
|
||||||
|
app.save_vault()?;
|
||||||
|
return Ok(
|
||||||
|
"Vault unlocked and upgraded to stronger key-derivation parameters.".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok("Vault unlocked.".to_string())
|
Ok("Vault unlocked.".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -553,6 +553,19 @@ pub fn find_stored_profile<'a>(
|
||||||
.ok_or_else(|| AppError::profile_not_found(npub))
|
.ok_or_else(|| AppError::profile_not_found(npub))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Labels the UI assigns when pairing without a user-supplied name
|
||||||
|
/// (CreateProfileModal defaults to "Amber"; Signer Mode to "Remote
|
||||||
|
/// Signer"). A remote-signer profile row still carrying one of these has
|
||||||
|
/// never resolved its real identity from the network, so it stays a
|
||||||
|
/// candidate for automatic name backfill on every launch. A user rename
|
||||||
|
/// always falls outside this list and is therefore never overwritten.
|
||||||
|
pub const GENERIC_PAIRING_LABELS: &[&str] = &["Amber", "Remote Signer"];
|
||||||
|
|
||||||
|
/// True when `label` is one of the generic pairing placeholders.
|
||||||
|
pub fn is_generic_pairing_label(label: &str) -> bool {
|
||||||
|
GENERIC_PAIRING_LABELS.contains(&label)
|
||||||
|
}
|
||||||
|
|
||||||
/// Create or refresh the vault profile for a remote (NIP-46) identity.
|
/// 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
|
/// When a NIP-46 client connection is established the identity lives on the
|
||||||
|
|
@ -930,6 +943,20 @@ pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result<ProfileSummary, A
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generic_pairing_labels_gate_the_backfill() {
|
||||||
|
// The background backfill upgrades a row's label ONLY while it still
|
||||||
|
// wears one of the placeholders the UI assigns at pairing time.
|
||||||
|
assert!(is_generic_pairing_label("Amber"));
|
||||||
|
assert!(is_generic_pairing_label("Remote Signer"));
|
||||||
|
// Any real name — fetched or user-typed — is never a candidate, so
|
||||||
|
// automatic enrichment can never overwrite it.
|
||||||
|
assert!(!is_generic_pairing_label("satoshi"));
|
||||||
|
assert!(!is_generic_pairing_label("My Profile"));
|
||||||
|
assert!(!is_generic_pairing_label("amber"));
|
||||||
|
assert!(!is_generic_pairing_label(""));
|
||||||
|
}
|
||||||
use crate::vault::{KdfParams, Vault, VaultCrypto};
|
use crate::vault::{KdfParams, Vault, VaultCrypto};
|
||||||
|
|
||||||
fn populated_vault() -> Vault {
|
fn populated_vault() -> Vault {
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,10 @@ pub enum Theme {
|
||||||
/// personality change") — near-black paper, warm light ink, pale pine.
|
/// personality change") — near-black paper, warm light ink, pale pine.
|
||||||
#[serde(rename = "workshop-dark")]
|
#[serde(rename = "workshop-dark")]
|
||||||
WorkshopDark,
|
WorkshopDark,
|
||||||
|
/// "Archipelago": synthwave sunset sea from the user's reference art —
|
||||||
|
/// teal-navy dusk, coral scanline sun, glass-island cards.
|
||||||
|
#[serde(rename = "archipelago")]
|
||||||
|
Archipelago,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Theme {
|
impl Theme {
|
||||||
|
|
@ -50,6 +54,7 @@ impl Theme {
|
||||||
"cosmic" => Some(Self::Cosmic),
|
"cosmic" => Some(Self::Cosmic),
|
||||||
"workshop" => Some(Self::Workshop),
|
"workshop" => Some(Self::Workshop),
|
||||||
"workshop-dark" => Some(Self::WorkshopDark),
|
"workshop-dark" => Some(Self::WorkshopDark),
|
||||||
|
"archipelago" => Some(Self::Archipelago),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue