diff --git a/CHECKPOINT-encryption.md b/CHECKPOINT-encryption.md
index 2271d5c..f957c83 100644
--- a/CHECKPOINT-encryption.md
+++ b/CHECKPOINT-encryption.md
@@ -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
diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts
index 9af5e94..589dc87 100644
--- a/frontend/electron/main.ts
+++ b/frontend/electron/main.ts
@@ -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);
diff --git a/frontend/public/archipelago-bg.jpg b/frontend/public/archipelago-bg.jpg
new file mode 100644
index 0000000..6051daf
Binary files /dev/null and b/frontend/public/archipelago-bg.jpg differ
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 1b2db24..9d71707 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -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';
diff --git a/frontend/src/screens/SettingsScreen.tsx b/frontend/src/screens/SettingsScreen.tsx
index fdfd396..428c2ee 100644
--- a/frontend/src/screens/SettingsScreen.tsx
+++ b/frontend/src/screens/SettingsScreen.tsx
@@ -148,6 +148,7 @@ export function SettingsScreen() {
+
/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" "$@"
diff --git a/src/app.rs b/src/app.rs
index 8ee1134..5460e89 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -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 {
let crypto = self
.vault
.crypto
@@ -183,8 +191,41 @@ impl App {
key.zeroize();
return Err(AppError::wrong_password());
}
- self.unlock_key = Some(key);
- Ok(())
+
+ 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);
+ 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,
+ new_key: &VaultKey,
+) -> Result<(), AppError> {
+ let decrypt_one = |blob: &str| -> Result, 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();
diff --git a/src/crypto.rs b/src/crypto.rs
index e58bed4..21f1181 100644
--- a/src/crypto.rs
+++ b/src/crypto.rs
@@ -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];
diff --git a/src/ipc.rs b/src/ipc.rs
index dcb07a1..43de978 100644
--- a/src/ipc.rs
+++ b/src/ipc.rs
@@ -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 {
- 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 {
diff --git a/src/main.rs b/src/main.rs
index 525b731..4bb8530 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -483,7 +483,10 @@ fn load_app_with_unlock() -> Result {
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 {
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())
}
diff --git a/src/profiles.rs b/src/profiles.rs
index d5b8dfc..2da0bf1 100644
--- a/src/profiles.rs
+++ b/src/profiles.rs
@@ -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 Vault {
diff --git a/src/settings.rs b/src/settings.rs
index 9f96f99..bf6176c 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -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,
}
}