Compare commits

...

8 commits

Author SHA1 Message Date
Avi
c553dd5e17 feat(vault): KDF upgrade to 64 MiB / t=3 with transparent migration on unlock
New vaults derive their key with Argon2id m=64 MiB, t=3 (OWASP 2024)
instead of the RFC 9106 19 MiB / t=2 defaults. The vault header already
records per-vault KDF parameters, so unlocking an old vault keeps
working unchanged; when it carries exactly the legacy parameters the
unlock transparently re-wraps every secret (profile keys, NIP-46
connection secrets, client keys) under a fresh salt + stronger key and
reports the upgrade so the caller persists it.

Also fixes a real gap surfaced by the new tests: set_password previously
re-encrypted only profile keys — NIP-46 connection secrets and client
keys stayed plaintext in the JSON after 'encrypt my vault'. All three
secret stores now go through one rewrap_secrets() helper shared by
set_password and the KDF upgrade path.

Wrong passwords never touch the header; the upgrade runs only after the
old verifier accepts. Tests: legacy-params upgrade + no-op on current
params, wrong-password leaves header untouched, rewrap covers connection
secrets/client keys for both upgrade and set_password.
2026-09-30 10:50:48 -05:00
Avi
5e91914ad6 docs(checkpoint): archipelago v2 + desktop launcher 2026-09-30 09:35:48 -05:00
Avi
fac4ba3cde feat(desktop): launch from the applications list (folio-style)
- launch-keynctr.sh: builds renderer/electron main if missing, exports
  KEYNCTR_ENABLE_GPU=1 (software rendering dies 'GPU process isn't
  usable' on this Hyprland box), execs bundled electron with
  --class=keynectr for WM_CLASS grouping.
- keynctr.desktop installed at ~/.local/share/applications/ (validated,
  icon = public/icon.png, categories Utility, StartupWMClass keynectr).
- requestSingleInstanceLock: second launch focuses the existing window
  (app.exit(0) in the doomed instance) instead of a duplicate shell
  fighting the vault.
Verified via gtk-launch: 'keynectr | Keynctr' window maps; second
gtk-launch keeps exactly 1 window.
2026-09-30 09:35:25 -05:00
Avi
d792a72dc2 feat(theme): Archipelago uses the reference artwork as the canvas
First pass was abstract gradient washes; the reference mock is the
synthwave sun/tower illustration itself behind frosted-glass panels.
Now: real artwork (public/archipelago-bg.jpg from the user's reference)
fills .main cover/fixed under a dark scrim, cards + sidebar are
translucent blurred glass with light hairline borders, coral accent and
is-active nav pill kept. Verified computed background stack in browser;
139 frontend tests + build green.
2026-09-30 09:35:10 -05:00
Avi
003f98a58e docs(checkpoint): Archipelago theme 4d0df31 2026-09-30 09:12:35 -05:00
Avi
4d0df31726 feat(theme): Archipelago — synthwave sunset sea from reference art
Teal-navy dusk, coral scanline sun (subtle repeating-linear horizon
stripes + low-center sun glow + pink cloud banks, fixed attachment),
glassmorphic cards/sidebar (blur, translucent surfaces), coral primary
accent, white logo filter on the dark canvas. Parsed as 'archipelago'
in settings; Settings -> Appearance option 'Archipelago - Sunset Sea'.
Verified tokens + all 6 rules compute in the built bundle; 221+6 Rust,
139 frontend tests, clippy 0, electron:build green.
2026-09-30 09:11:55 -05:00
Avi
f905cbcdad docs(checkpoint): Sep 28 evening — pairing resolved (live vault evidence), backfill + stdin fix verified green 2026-09-28 20:13:07 -05:00
Avi
5b60ae3736 feat(profiles): persistent identity backfill for generic pairing labels
Pairing-time kind-0 enrichment gives up after ~4 attempts/2 minutes; a
fresh Amber account that has not published metadata yet (or publishes
later) then keeps the generic 'Amber'/'Remote Signer' label forever.
serve() now spawns a slow-cadence backfill: every pass it re-scans for
Nip46Client rows still wearing a GENERIC_PAIRING_LABELS placeholder and
re-fetches kind-0, upgrading label/picture/nip05 when metadata appears.
User renames are never overwritten (placeholder check gates the write);
locked encrypted vaults are skipped; network runs off-lock with a 90s
budget per identity.
2026-09-28 12:03:35 -05:00
13 changed files with 584 additions and 26 deletions

View file

@ -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)
## Where things are

View file

@ -1002,6 +1002,24 @@ if (process.platform === 'linux') {
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(() => {
protocol.handle('app', (request) => {
const { pathname } = new URL(request.url);

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View file

@ -7,7 +7,8 @@ export type Theme =
| 'impeccable-dark'
| 'cosmic'
| 'workshop'
| 'workshop-dark';
| 'workshop-dark'
| 'archipelago';
/** Active signer mode. */
export type SignerMode = 'embedded' | 'nip46_bunker' | 'nip46_client';

View file

@ -148,6 +148,7 @@ export function SettingsScreen() {
<option value="cosmic">Cosmic — Stardust</option>
<option value="workshop">Workshop — Cybernetic</option>
<option value="workshop-dark">Workshop — Dark</option>
<option value="archipelago">Archipelago — Sunset Sea</option>
</select>
<div
aria-hidden="true"

View file

@ -706,6 +706,83 @@ html[data-theme='workshop'] .card {
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;
}

24
launch-keynctr.sh Executable file
View 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" "$@"

View file

@ -169,8 +169,16 @@ impl App {
self.signing_for(&npub).await
}
/// Verify a password and keep the derived key in memory for the session.
pub fn unlock(&mut self, password: &str) -> Result<(), AppError> {
/// Verify a password, keep the derived key in memory for the session,
/// 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
.vault
.crypto
@ -183,8 +191,41 @@ impl App {
key.zeroize();
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);
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.
@ -348,25 +389,14 @@ impl App {
crypto::KDF_P_COST,
)?;
let mut encrypted = Vec::with_capacity(self.vault.profiles.len());
for profile in &self.vault.profiles {
// Decrypted plaintexts are Zeroizing: each wipes itself once the
// 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.
// Re-wrap every secret store (profiles, connection secrets, client
// keys) so no credential keeps its old wrapping.
rewrap_secrets(&mut self.vault, previous_key.as_ref(), &new_key)?;
// Wipe the old vault key now that everything is re-encrypted.
if let Some(mut key) = previous_key {
key.zeroize();
}
self.vault.profiles = encrypted;
self.vault.crypto = Some(VaultCrypto {
kdf: KdfParams {
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)]
mod tests {
use super::*;
@ -607,6 +678,138 @@ mod tests {
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]
fn unlock_roundtrip_with_wrong_then_right_password() {
let mut app = sample_app();

View file

@ -22,13 +22,22 @@ pub const SALT_LEN: usize = 16;
/// AES-GCM nonce length in bytes.
pub const NONCE_LEN: usize = 12;
/// Argon2id memory cost in KiB (RFC 9106 recommendation).
pub const KDF_M_COST: u32 = 19 * 1024;
/// Argon2id memory cost in KiB (64 MiB — OWASP 2024 recommendation).
pub const KDF_M_COST: u32 = 64 * 1024;
/// Argon2id time cost (iterations).
pub const KDF_T_COST: u32 = 2;
pub const KDF_T_COST: u32 = 3;
/// Argon2id parallelism.
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.
pub type VaultKey = [u8; KEY_LEN];

View file

@ -5,6 +5,8 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::sync::Mutex;
use nostr_sdk::prelude::PublicKey;
use crate::app::{App, Nip46ClientSignerHandle};
use crate::errors::AppError;
use crate::feed;
@ -331,6 +333,107 @@ pub async fn serve() -> Result<(), AppError> {
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 {
if line.trim().is_empty() {
continue;
@ -921,7 +1024,12 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
}
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
if app.signer_mode == SignerMode::Embedded {
if let Some(signer) = &app.embedded_signer {

View file

@ -483,7 +483,10 @@ fn load_app_with_unlock() -> Result<App, AppError> {
let mut app = App::load()?;
if app.is_locked() {
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)
}
@ -531,7 +534,12 @@ fn cli_unlock() -> Result<String, AppError> {
return Err(AppError::config("Your vault is not encrypted."));
}
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())
}

View file

@ -553,6 +553,19 @@ pub fn find_stored_profile<'a>(
.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.
///
/// 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)]
mod tests {
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};
fn populated_vault() -> Vault {

View file

@ -35,6 +35,10 @@ pub enum Theme {
/// personality change") — near-black paper, warm light ink, pale pine.
#[serde(rename = "workshop-dark")]
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 {
@ -50,6 +54,7 @@ impl Theme {
"cosmic" => Some(Self::Cosmic),
"workshop" => Some(Self::Workshop),
"workshop-dark" => Some(Self::WorkshopDark),
"archipelago" => Some(Self::Archipelago),
_ => None,
}
}