Compare commits
No commits in common. "c553dd5e177561cf20f541cc50678791eb30dfb8" and "a6182e3cb227a36d1fd5800f64303118760890fd" have entirely different histories.
c553dd5e17
...
a6182e3cb2
13 changed files with 26 additions and 584 deletions
|
|
@ -1,80 +1,3 @@
|
||||||
# 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,24 +1002,6 @@ 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);
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 107 KiB |
|
|
@ -7,8 +7,7 @@ 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,7 +148,6 @@ 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,83 +706,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
#!/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" "$@"
|
|
||||||
241
src/app.rs
241
src/app.rs
|
|
@ -169,16 +169,8 @@ impl App {
|
||||||
self.signing_for(&npub).await
|
self.signing_for(&npub).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify a password, keep the derived key in memory for the session,
|
/// Verify a password and keep the derived key in memory for the session.
|
||||||
/// and transparently upgrade the vault's KDF if it is still encrypted
|
pub fn unlock(&mut self, password: &str) -> Result<(), AppError> {
|
||||||
/// 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
|
||||||
|
|
@ -191,41 +183,8 @@ impl App {
|
||||||
key.zeroize();
|
key.zeroize();
|
||||||
return Err(AppError::wrong_password());
|
return Err(AppError::wrong_password());
|
||||||
}
|
}
|
||||||
|
self.unlock_key = Some(key);
|
||||||
let is_legacy = crypto.kdf.m_cost == crypto::LEGACY_KDF_M_COST
|
Ok(())
|
||||||
&& crypto.kdf.t_cost == crypto::LEGACY_KDF_T_COST;
|
|
||||||
if !is_legacy {
|
|
||||||
self.unlock_key = Some(key);
|
|
||||||
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.
|
||||||
|
|
@ -389,14 +348,25 @@ impl App {
|
||||||
crypto::KDF_P_COST,
|
crypto::KDF_P_COST,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Re-wrap every secret store (profiles, connection secrets, client
|
let mut encrypted = Vec::with_capacity(self.vault.profiles.len());
|
||||||
// keys) so no credential keeps its old wrapping.
|
for profile in &self.vault.profiles {
|
||||||
rewrap_secrets(&mut self.vault, previous_key.as_ref(), &new_key)?;
|
// Decrypted plaintexts are Zeroizing: each wipes itself once the
|
||||||
// Wipe the old vault key now that everything is re-encrypted.
|
// re-encrypted replacement has been produced.
|
||||||
|
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(),
|
||||||
|
|
@ -497,47 +467,6 @@ 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::*;
|
||||||
|
|
@ -678,138 +607,6 @@ 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,22 +22,13 @@ 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 (64 MiB — OWASP 2024 recommendation).
|
/// Argon2id memory cost in KiB (RFC 9106 recommendation).
|
||||||
pub const KDF_M_COST: u32 = 64 * 1024;
|
pub const KDF_M_COST: u32 = 19 * 1024;
|
||||||
/// Argon2id time cost (iterations).
|
/// Argon2id time cost (iterations).
|
||||||
pub const KDF_T_COST: u32 = 3;
|
pub const KDF_T_COST: u32 = 2;
|
||||||
/// 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,8 +5,6 @@ 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;
|
||||||
|
|
@ -333,107 +331,6 @@ 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;
|
||||||
|
|
@ -1024,12 +921,7 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
|
||||||
}
|
}
|
||||||
|
|
||||||
Request::UnlockVault { password } => {
|
Request::UnlockVault { password } => {
|
||||||
let upgraded = app.unlock(&password)?;
|
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,10 +483,7 @@ 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: ")?;
|
||||||
if app.unlock(&password)? {
|
app.unlock(&password)?;
|
||||||
// Transparent KDF upgrade: persist the re-wrapped vault.
|
|
||||||
app.save_vault()?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(app)
|
Ok(app)
|
||||||
}
|
}
|
||||||
|
|
@ -534,12 +531,7 @@ 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: ")?;
|
||||||
if app.unlock(&password)? {
|
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,19 +553,6 @@ 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
|
||||||
|
|
@ -943,20 +930,6 @@ 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,10 +35,6 @@ 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 {
|
||||||
|
|
@ -54,7 +50,6 @@ 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