Add NIP-49 encrypted local key backup: Rust layer + egress guard

Local-only ncryptsec backup of the identity key (plan:
PLANS/NIP49_LOCAL_BACKUP_PLAN.md Rev 3, approved 9/10 by Wren).

- key_backup.rs: NIP-49 codec (nostr nip49, log_n 18), one-artifact-per-
  action create with decrypt-verify against the live pubkey, atomic 0o600
  write + reread/byte-compare, EFF-wordlist passphrase generation,
  ncryptsec import recovery, stale-backup cleanup on identity change.
- commands/identity.rs: create_ncryptsec_backup (whole body under
  identity_mutation; webview can never supply canonical blob bytes),
  save_ncryptsec_copy (dialog selection only + secret-file write, never
  mutates app state), generate_backup_passphrase, import_identity now
  accepts ncryptsec1 with a passphrase (raw-nsec path byte-for-byte
  unchanged).
- egress_guard.rs: the backup must NEVER be transmitted to a relay —
  fail-closed runtime guard rejecting ncryptsec1 at all 8 relay egress
  boundaries (submit funnel, 3x relay.rs, huddle STT, both engram
  submitters, native websocket send loop). Scope is ncryptsec1 only:
  pairing intentionally carries the raw nsec inside its encrypted
  NIP-AB session.
- Tests: injection test per boundary (8), /events inventory-completeness
  tripwire, ncryptsec source-allowlist scan, spec vector, NFKC cross-form,
  0600/atomicity/lifecycle, recovery-mode gating, concurrent identity
  swap vs backup, boot-reset wipes the backup.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-07-25 22:44:03 -04:00
co-authored by Tyler Longwell
parent ab7aa8b120
commit e5cff3865d
18 changed files with 2483 additions and 20 deletions
+1
View File
@@ -1031,6 +1031,7 @@ dependencies = [
"ed25519-dalek",
"flate2",
"futures-util",
"getrandom 0.2.17",
"hex",
"image",
"infer",
+4 -1
View File
@@ -80,7 +80,10 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
toml = "0.8"
nostr = { version = "0.44", features = ["nip44"] }
nostr = { version = "0.44", features = ["nip44", "nip49"] }
# OS-entropy source for backup passphrase generation (already in the tree as a
# transitive dependency; pinned here for direct use).
getrandom = "0.2"
zeroize = "1"
reqwest = { version = "0.13", features = ["json", "query", "stream", "blocking"] }
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] }
File diff suppressed because it is too large Load Diff
+27 -7
View File
@@ -1,16 +1,14 @@
use tauri::AppHandle;
use tauri_plugin_dialog::DialogExt;
/// Show a save-file dialog with a custom filter and write `data` to the chosen
/// path. Returns `Ok(true)` when the file was written, `Ok(false)` when the
/// user cancelled the dialog.
pub async fn save_bytes_with_dialog(
/// Show a save-file dialog with a custom filter and return the chosen path,
/// or `None` when the user cancelled. Selection only — no write.
pub async fn pick_save_path(
app: &AppHandle,
suggested_filename: &str,
filter_name: &str,
extensions: &[&str],
data: &[u8],
) -> Result<bool, String> {
) -> Result<Option<std::path::PathBuf>, String> {
let (tx, rx) = tokio::sync::oneshot::channel();
app.dialog()
.file()
@@ -23,12 +21,34 @@ pub async fn save_bytes_with_dialog(
let selected = rx.await.map_err(|_| "dialog cancelled".to_string())?;
let file_path = match selected {
Some(p) => p,
None => return Ok(false),
None => return Ok(None),
};
let dest = file_path
.as_path()
.ok_or_else(|| "Save dialog returned an invalid path".to_string())?;
Ok(Some(dest.to_path_buf()))
}
/// Show a save-file dialog with a custom filter and write `data` to the chosen
/// path. Returns `Ok(true)` when the file was written, `Ok(false)` when the
/// user cancelled the dialog.
///
/// NOT for secrets: the write is plain `std::fs::write` (no atomic commit, no
/// 0o600). Secret exports go through `pick_save_path` +
/// `key_backup::write_backup_file`.
pub async fn save_bytes_with_dialog(
app: &AppHandle,
suggested_filename: &str,
filter_name: &str,
extensions: &[&str],
data: &[u8],
) -> Result<bool, String> {
let dest = match pick_save_path(app, suggested_filename, filter_name, extensions).await? {
Some(p) => p,
None => return Ok(false),
};
std::fs::write(dest, data).map_err(|e| format!("Failed to write file: {e}"))?;
Ok(true)
+255 -2
View File
@@ -188,14 +188,130 @@ pub fn get_nsec(state: State<'_, AppState>) -> Result<String, String> {
.map_err(|error| format!("encode nsec: {error}"))
}
/// Generate a 6-word passphrase for a new encrypted backup (EFF short
/// wordlist, OS entropy, ≈62 bits before the scrypt work factor).
#[tauri::command]
pub fn generate_backup_passphrase() -> Result<String, String> {
crate::key_backup::generate_passphrase()
}
/// Core of [`create_ncryptsec_backup`], factored so tests can drive it with a
/// bare `AppState` + temp dir (and a fast scrypt tier) without an `AppHandle`.
pub(crate) fn create_and_persist_backup_with_log_n(
state: &AppState,
data_dir: &std::path::Path,
password: &str,
log_n: u8,
) -> Result<String, String> {
if password.chars().count() < crate::key_backup::MIN_PASSPHRASE_LEN {
return Err(format!(
"passphrase must be at least {} characters",
crate::key_backup::MIN_PASSPHRASE_LEN
));
}
// Serialize against import_identity/persist_current_identity: the blob
// must be derived from — and persisted for — one stable identity. Also
// caps KDF concurrency at one.
let _mutation_guard = state.identity_mutation.lock().map_err(|e| e.to_string())?;
// Recovery mode (lost/locked) → Err, same gate as signing.
let keys = state.signing_keys()?;
let ncryptsec = crate::key_backup::create_backup_blob(&keys, password, log_n)?;
std::fs::create_dir_all(data_dir).map_err(|e| format!("create app data dir: {e}"))?;
let path = crate::key_backup::backup_file_path(data_dir);
crate::key_backup::write_backup_file(&path, &ncryptsec)?;
Ok(ncryptsec)
}
/// Create the canonical app-managed NIP-49 backup.
///
/// Encrypts the live identity under `password`, decrypt-verifies the fresh
/// blob against the live pubkey, atomically persists it to
/// `{app_data_dir}/identity.ncryptsec` (0o600), rereads and byte-compares,
/// and returns the exact persisted `ncryptsec1…` string. The entire body runs
/// under `identity_mutation`, so it serializes against imports and caps KDF
/// concurrency at one. The webview can never supply canonical blob bytes —
/// the trust boundary is the password.
#[tauri::command]
pub async fn create_ncryptsec_backup(
password: String,
app_handle: tauri::AppHandle,
) -> Result<String, String> {
tokio::task::spawn_blocking(move || {
let password = zeroize::Zeroizing::new(password);
let state = app_handle.state::<AppState>();
let data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("app data dir: {e}"))?;
create_and_persist_backup_with_log_n(
&state,
&data_dir,
&password,
crate::key_backup::BACKUP_LOG_N,
)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
/// Save a portable copy of an `ncryptsec1…` backup to a user-chosen path.
///
/// The input must parse as a structurally valid NIP-49 payload. The dialog is
/// selection-only; the write uses secret-file semantics (atomic + 0o600).
/// Never mutates canonical app state. Returns the chosen path, or `None` when
/// the user cancelled.
#[tauri::command]
pub async fn save_ncryptsec_copy(
ncryptsec: String,
app_handle: tauri::AppHandle,
) -> Result<Option<String>, String> {
// Reject anything that is not a valid encrypted-key blob — this command
// must not become a generic file writer.
crate::key_backup::parse_ncryptsec(&ncryptsec)?;
let normalized = ncryptsec.trim().to_string();
let dest = match crate::commands::export_util::pick_save_path(
&app_handle,
crate::key_backup::BACKUP_FILE_NAME,
"Encrypted key backup",
&["ncryptsec"],
)
.await?
{
Some(p) => p,
None => return Ok(None),
};
let dest_for_write = dest.clone();
tokio::task::spawn_blocking(move || {
crate::key_backup::write_backup_file(&dest_for_write, &normalized)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))??;
Ok(Some(dest.display().to_string()))
}
#[tauri::command]
pub async fn import_identity(
nsec: String,
password: Option<String>,
app_handle: tauri::AppHandle,
) -> Result<IdentityInfo, String> {
tokio::task::spawn_blocking(move || {
let trimmed = nsec.trim();
let keys = Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))?;
// `ncryptsec1…` = NIP-49 encrypted backup: requires the passphrase and
// decrypts in Rust. Everything after key recovery is byte-for-byte the
// raw-nsec path.
let password = password.map(zeroize::Zeroizing::new);
let keys = crate::key_backup::recover_keys_from_input(
&nsec,
password.as_ref().map(|p| p.as_str()),
)?;
// Serialize against persist_current_identity: hold this guard for the
// full function body so a concurrent stale persist can't overwrite
@@ -210,6 +326,11 @@ pub async fn import_identity(
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?;
let key_path = data_dir.join("identity.key");
// Importing a different identity invalidates the app-managed backup:
// it encrypts the previous key and must not linger mislabeled.
let previous_pubkey = state.keys.lock().map_err(|e| e.to_string())?.public_key();
crate::key_backup::cleanup_stale_backup(&previous_pubkey, &keys.public_key(), &data_dir)?;
// Persist into the OS keyring first (store → read-back verify → marker →
// delete file). Falls back to the 0o600 file when the keyring is
// unavailable; returns Err only when both backends fail.
@@ -583,3 +704,135 @@ mod nostr_identity_binding_tests {
assert_eq!(error, "expires_at is expired");
}
}
#[cfg(test)]
mod key_backup_command_tests {
use super::create_and_persist_backup_with_log_n;
use crate::app_state::build_app_state;
use nostr::Keys;
/// Fast scrypt tier for tests; production uses BACKUP_LOG_N (18), covered
/// once in key_backup_tests::round_trip_at_production_cost.
const FAST_LOG_N: u8 = 16;
const PASSWORD: &str = "correct horse battery";
#[test]
fn returned_bytes_equal_on_disk_bytes() {
let state = build_app_state();
let dir = tempfile::tempdir().unwrap();
let returned =
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap();
let path = crate::key_backup::backup_file_path(dir.path());
let on_disk = std::fs::read_to_string(&path).unwrap();
assert_eq!(
returned, on_disk,
"webview must receive the exact persisted bytes"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
// And the persisted blob provably recovers the live identity.
let keys = state.keys.lock().unwrap().clone();
let recovered = crate::key_backup::decrypt_ncryptsec(&on_disk, PASSWORD).unwrap();
assert_eq!(recovered.public_key(), keys.public_key());
}
#[test]
fn overwrite_replaces_atomically() {
let state = build_app_state();
let dir = tempfile::tempdir().unwrap();
let first =
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap();
let second = create_and_persist_backup_with_log_n(
&state,
dir.path(),
"another passphrase",
FAST_LOG_N,
)
.unwrap();
assert_ne!(first, second, "fresh salt/nonce per action");
let path = crate::key_backup::backup_file_path(dir.path());
assert_eq!(std::fs::read_to_string(&path).unwrap(), second);
}
#[test]
fn rejects_short_passphrase() {
let state = build_app_state();
let dir = tempfile::tempdir().unwrap();
let err = create_and_persist_backup_with_log_n(&state, dir.path(), "short", FAST_LOG_N)
.unwrap_err();
assert!(err.contains("at least"), "{err}");
assert!(!crate::key_backup::backup_file_path(dir.path()).exists());
}
#[test]
fn recovery_mode_blocks_backup_creation() {
let state = build_app_state();
let dir = tempfile::tempdir().unwrap();
state
.identity_lost
.store(true, std::sync::atomic::Ordering::Release);
assert!(
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).is_err(),
"lost identity must not be backed up"
);
state
.identity_lost
.store(false, std::sync::atomic::Ordering::Release);
state
.keyring_locked
.store(true, std::sync::atomic::Ordering::Release);
assert!(
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).is_err(),
"locked keyring must not be backed up"
);
assert!(!crate::key_backup::backup_file_path(dir.path()).exists());
}
/// Concurrent identity swap vs backup creation: `identity_mutation`
/// serializes both, so every persisted blob decrypts to the identity that
/// was live for the whole of its create operation — never a torn state.
#[test]
fn concurrent_identity_swap_vs_backup_is_serialized() {
let state = std::sync::Arc::new(build_app_state());
let dir = tempfile::tempdir().unwrap();
let key_a = state.keys.lock().unwrap().clone();
let key_b = Keys::generate();
let swapper = {
let state = state.clone();
let key_b = key_b.clone();
std::thread::spawn(move || {
// Mirrors import_identity's locking: mutation guard held
// across the key swap.
let _guard = state.identity_mutation.lock().unwrap();
*state.keys.lock().unwrap() = key_b;
})
};
let backup =
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap();
swapper.join().unwrap();
let recovered = crate::key_backup::decrypt_ncryptsec(&backup, PASSWORD)
.unwrap()
.public_key();
assert!(
recovered == key_a.public_key() || recovered == key_b.public_key(),
"backup must match one coherent identity"
);
// Whichever won, the persisted file equals the returned blob.
let on_disk =
std::fs::read_to_string(crate::key_backup::backup_file_path(dir.path())).unwrap();
assert_eq!(on_disk, backup);
}
}
@@ -630,7 +630,7 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent
/// POST a pre-built signed engram event to the relay, authenticating as the
/// new agent.
async fn submit_engram_event(
pub(crate) async fn submit_engram_event(
state: &AppState,
agent_keys: &nostr::Keys,
event_json: &[u8],
@@ -640,6 +640,8 @@ async fn submit_engram_event(
use crate::relay::build_nip98_auth_header_for_keys;
use reqwest::Method;
crate::egress_guard::assert_no_key_backup_bytes(event_json, "persona snapshot engram submit")?;
// Wait before signing: the relay enforces NIP-98 freshness (±60s) and the
// gate may hold for up to MAX_HINT_SECONDS (300s). Building auth before the
// wait produces a stale `created_at` that the relay will reject.
@@ -683,3 +685,32 @@ async fn submit_engram_event(
}
Ok(())
}
// ── NIP-49 egress guard: boundary 7 (persona snapshot engram submit) ─────────
#[cfg(test)]
mod egress_guard_tests {
use super::submit_engram_event;
const NCRYPTSEC: &str = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
/// An engram body carrying an ncryptsec must be rejected by the guard
/// before any network I/O (the target port is a discard address; a guard
/// error — not a connection error — proves the abort ordering).
#[tokio::test]
async fn blocks_ncryptsec_before_network() {
let state = crate::app_state::build_app_state();
let keys = nostr::Keys::generate();
let body = format!("{{\"content\":\"{NCRYPTSEC}\"}}");
let err = submit_engram_event(
&state,
&keys,
body.as_bytes(),
"http://127.0.0.1:9/events",
None,
)
.await
.unwrap_err();
assert!(err.contains("key-backup material"), "{err}");
}
}
@@ -891,7 +891,7 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent
/// POST a pre-built signed engram event to the relay, authenticating as the
/// new agent. Mirrors the same helper in `snapshot::import`.
async fn submit_engram_event(
pub(crate) async fn submit_engram_event(
state: &AppState,
agent_keys: &nostr::Keys,
event_json: &[u8],
@@ -901,6 +901,8 @@ async fn submit_engram_event(
use crate::relay::build_nip98_auth_header_for_keys;
use reqwest::Method;
crate::egress_guard::assert_no_key_backup_bytes(event_json, "team snapshot engram submit")?;
// Wait before signing: the relay enforces NIP-98 freshness (±60s) and the
// gate may hold for up to MAX_HINT_SECONDS (300s). Building auth before the
// wait produces a stale `created_at` that the relay will reject.
@@ -724,3 +724,31 @@ fn full_rollback_at_teams_boundary_absent_agents_store() {
assert!(!teams_path.exists());
assert_eq!(errors.len(), 1, "only the teams-write error");
}
// ── NIP-49 egress guard: boundary 6 (team snapshot engram submit) ────────────
mod egress_guard_boundary {
use super::super::submit_engram_event;
const NCRYPTSEC: &str = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
/// An engram body carrying an ncryptsec must be rejected by the guard
/// before any network I/O (the target port is a discard address; a guard
/// error — not a connection error — proves the abort ordering).
#[tokio::test]
async fn blocks_ncryptsec_before_network() {
let state = crate::app_state::build_app_state();
let keys = nostr::Keys::generate();
let body = format!("{{\"content\":\"{NCRYPTSEC}\"}}");
let err = submit_engram_event(
&state,
&keys,
body.as_bytes(),
"http://127.0.0.1:9/events",
None,
)
.await
.unwrap_err();
assert!(err.contains("key-backup material"), "{err}");
}
}
+52
View File
@@ -0,0 +1,52 @@
//! Relay egress guard for NIP-49 key-backup material.
//!
//! The local `ncryptsec` backup (see [`crate::key_backup`]) must NEVER be
//! transmitted to a relay. This module enforces that contract at runtime,
//! fail-closed, at every relay-bound egress boundary:
//!
//! | # | Boundary | Site |
//! |---|----------|------|
//! | 1 | `submit_event_at_with_keys` (funnel for `submit_event`) | `relay/submit.rs` |
//! | 2 | `sync_managed_agent_profile` | `relay.rs` |
//! | 3 | `submit_signed_event` | `relay.rs` |
//! | 4 | `submit_signed_event_with_keys` | `relay.rs` |
//! | 5 | huddle STT publisher | `huddle/pipeline.rs` |
//! | 6 | `submit_engram_event` (team snapshot) | `commands/team_snapshot.rs` |
//! | 7 | `submit_engram_event` (persona import) | `commands/personas/snapshot/import.rs` |
//! | 8 | native websocket send loop (all webview relay WS) | `native_websocket.rs` |
//!
//! The inventory-completeness test in `egress_guard_tests.rs` asserts that
//! every `/events` URL-construction site in the tree calls this guard, so a
//! new submission path fails the build until it is wired.
//!
//! Scope: `ncryptsec1` only. The raw `nsec` intentionally transits the
//! NIP-44-encrypted pairing session (NIP-AB payload_type "nsec"); guarding it
//! here would break pairing. Raw-key DLP is separate policy work.
/// Bech32 HRP of NIP-49 encrypted secret keys.
const NCRYPTSEC_PREFIX: &str = "ncryptsec1";
/// Reject `text` if it contains NIP-49 key-backup material.
///
/// Returns `Err` when an `ncryptsec1…` substring is present. Callers MUST
/// abort the network operation on `Err` — this is a fail-closed guard, not a
/// warning.
pub fn assert_no_key_backup(text: &str, context: &'static str) -> Result<(), String> {
if text.contains(NCRYPTSEC_PREFIX) {
return Err(format!(
"blocked {context}: payload contains NIP-49 key-backup material \
(ncryptsec); the local key backup must never be transmitted to a relay"
));
}
Ok(())
}
/// Byte-slice variant for callers that hold serialized bodies.
pub fn assert_no_key_backup_bytes(body: &[u8], context: &'static str) -> Result<(), String> {
// ncryptsec is ASCII bech32; a UTF-8-lossy view preserves any occurrence.
assert_no_key_backup(&String::from_utf8_lossy(body), context)
}
#[cfg(test)]
#[path = "egress_guard_tests.rs"]
mod tests;
+304
View File
@@ -0,0 +1,304 @@
use super::*;
/// NIP-49 spec vector — a real ncryptsec blob for injection payloads.
const NCRYPTSEC: &str = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
fn assert_guard_error(err: &str) {
assert!(
err.contains("key-backup material"),
"expected the egress-guard error, got: {err}"
);
}
// ── Guard unit behavior ───────────────────────────────────────────────────────
#[test]
fn rejects_ncryptsec_anywhere_in_text() {
assert_guard_error(&assert_no_key_backup(NCRYPTSEC, "test").unwrap_err());
assert_guard_error(
&assert_no_key_backup(
&format!("{{\"content\":\"my backup: {NCRYPTSEC}\"}}"),
"test",
)
.unwrap_err(),
);
}
#[test]
fn passes_clean_payloads_including_raw_nsec() {
assert!(assert_no_key_backup("hello world", "test").is_ok());
assert!(assert_no_key_backup("", "test").is_ok());
// Scope is ncryptsec1 ONLY: raw nsec intentionally transits the encrypted
// pairing session and must NOT be blocked (plan D4 / pairing.rs).
let nsec = nostr::ToBech32::to_bech32(nostr::Keys::generate().secret_key()).unwrap();
assert!(assert_no_key_backup(&nsec, "test").is_ok());
// Near-miss prefixes are not blocked.
assert!(assert_no_key_backup("ncryptsec", "test").is_ok());
}
#[test]
fn byte_variant_matches_text_variant() {
assert_guard_error(&assert_no_key_backup_bytes(NCRYPTSEC.as_bytes(), "test").unwrap_err());
assert!(assert_no_key_backup_bytes(b"clean body", "test").is_ok());
// Invalid UTF-8 around an intact ncryptsec substring must still trip the
// guard (from_utf8_lossy preserves the ASCII run).
let mut body = vec![0xff, 0xfe];
body.extend_from_slice(NCRYPTSEC.as_bytes());
body.push(0xff);
assert_guard_error(&assert_no_key_backup_bytes(&body, "test").unwrap_err());
}
#[test]
fn error_names_the_boundary_context() {
let err = assert_no_key_backup(NCRYPTSEC, "huddle STT publish").unwrap_err();
assert!(err.contains("huddle STT publish"), "{err}");
}
// ── Runtime injection per boundary ────────────────────────────────────────────
//
// Each test drives the real production function with an ncryptsec-bearing
// payload and asserts the guard aborts the operation before any network I/O
// (no listener exists at the target address; a distinctive guard error — not
// a connection error — proves the abort happened first).
//
// Boundaries 6 and 7 (`submit_engram_event` twins) are module-private inside
// `commands`; their injection tests live next to them:
// - commands/team_snapshot/tests.rs::egress_guard_boundary
// - commands/personas/snapshot/import.rs::egress_guard_tests
/// Boundary 1: `relay/submit.rs` `submit_event_at_with_keys` (the funnel for
/// all `submit_event*` variants).
#[tokio::test]
async fn boundary_submit_event_at_with_keys_blocks_ncryptsec() {
let state = crate::app_state::build_app_state();
let keys = nostr::Keys::generate();
let builder = nostr::EventBuilder::new(nostr::Kind::Custom(9), NCRYPTSEC);
let err = crate::relay::submit_event_at_with_keys(
builder,
&state,
"http://127.0.0.1:9", // discard port — must never be reached
&keys,
)
.await
.unwrap_err();
assert_guard_error(&err);
}
/// Boundary 2: `relay.rs` `sync_managed_agent_profile` (agent kind:0 profile).
#[tokio::test]
async fn boundary_sync_managed_agent_profile_blocks_ncryptsec() {
let state = crate::app_state::build_app_state();
let keys = nostr::Keys::generate();
let err = crate::relay::sync_managed_agent_profile(
&state,
"ws://127.0.0.1:9",
&keys,
&format!("agent {NCRYPTSEC}"),
None,
None,
)
.await
.unwrap_err();
assert_guard_error(&err);
}
/// Boundary 3: `relay.rs` `submit_signed_event`.
#[tokio::test]
async fn boundary_submit_signed_event_blocks_ncryptsec() {
let state = crate::app_state::build_app_state();
*state.relay_url_override.lock().unwrap() = Some("ws://127.0.0.1:9".to_string());
let keys = state.signing_keys().unwrap();
let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), NCRYPTSEC)
.sign_with_keys(&keys)
.unwrap();
let err = crate::relay::submit_signed_event(&event, &state)
.await
.unwrap_err();
assert_guard_error(&err);
}
/// Boundary 4: `relay.rs` `submit_signed_event_with_keys`.
#[tokio::test]
async fn boundary_submit_signed_event_with_keys_blocks_ncryptsec() {
let state = crate::app_state::build_app_state();
*state.relay_url_override.lock().unwrap() = Some("ws://127.0.0.1:9".to_string());
let keys = nostr::Keys::generate();
let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), NCRYPTSEC)
.sign_with_keys(&keys)
.unwrap();
let err = crate::relay::submit_signed_event_with_keys(&event, &state, &keys, None)
.await
.unwrap_err();
assert_guard_error(&err);
}
/// Boundary 5: huddle STT publisher (`huddle/pipeline.rs`).
#[test]
fn boundary_huddle_stt_blocks_ncryptsec() {
let keys = nostr::Keys::generate();
let channel = uuid::Uuid::new_v4();
let builder =
crate::events::build_message(channel, NCRYPTSEC, None, &[], &[], &[], &[]).unwrap();
let err = crate::huddle::pipeline::sign_and_guard_stt_body(builder, &keys).unwrap_err();
assert_guard_error(&err);
// Clean transcripts pass through the same seam.
let builder =
crate::events::build_message(channel, "hello huddle", None, &[], &[], &[], &[]).unwrap();
assert!(crate::huddle::pipeline::sign_and_guard_stt_body(builder, &keys).is_ok());
}
/// Boundary 8: native websocket send loop — the single choke point for all
/// webview-originated relay websocket frames.
#[tokio::test]
async fn boundary_native_websocket_blocks_ncryptsec() {
let manager = crate::native_websocket::WebSocketManager::default();
// Text frame: guard fires before the connection lookup, so no connection
// is needed — and the error must be the guard's, not "not found".
let err = crate::native_websocket::send_message(
&manager,
1,
crate::native_websocket::WebSocketMessage::Text(format!(
"[\"EVENT\",{{\"content\":\"{NCRYPTSEC}\"}}]"
)),
)
.await
.unwrap_err();
assert_guard_error(&err);
// Binary frame variant.
let err = crate::native_websocket::send_message(
&manager,
1,
crate::native_websocket::WebSocketMessage::Binary(NCRYPTSEC.as_bytes().to_vec()),
)
.await
.unwrap_err();
assert_guard_error(&err);
// Clean frames fall through to normal handling ("connection not found"
// here — the guard did not reject them).
let err = crate::native_websocket::send_message(
&manager,
1,
crate::native_websocket::WebSocketMessage::Text("[\"REQ\",\"sub\",{}]".to_string()),
)
.await
.unwrap_err();
assert!(err.contains("not found"), "{err}");
}
// ── Structural tripwires ──────────────────────────────────────────────────────
fn src_rust_files() -> Vec<std::path::PathBuf> {
fn walk(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
walk(&path, out);
} else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
out.push(path);
}
}
}
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut out = Vec::new();
walk(&root, &mut out);
out
}
/// Inventory completeness: every `/events` URL-construction site in
/// `desktop/src-tauri/src` must be in the guarded set. A future ninth
/// submission path fails this test until its guard is wired and it is added
/// to the allowlist below (with its egress_guard.rs table row + injection
/// test).
#[test]
fn events_url_inventory_is_fully_guarded() {
// (file suffix, guarded construction sites expected in that file)
let allowlist: &[&str] = &[
"src/relay.rs", // boundaries 2, 3, 4
"src/relay/submit.rs", // boundary 1
"src/huddle/pipeline.rs", // boundary 5
"src/commands/team_snapshot.rs", // boundary 6
"src/commands/personas/snapshot/import.rs", // boundary 7
// test-only relay stubs / fixtures (no production egress):
"src/relay_admission.rs",
"src/archive/mod_tests.rs",
"src/managed_agents/persona_events/tests.rs",
"src/commands/team_snapshot/tests.rs",
"src/egress_guard_tests.rs",
];
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let mut violations = Vec::new();
for path in src_rust_files() {
let rel = path
.strip_prefix(root)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
let content = std::fs::read_to_string(&path).unwrap();
for (i, line) in content.lines().enumerate() {
let trimmed = line.trim_start();
if trimmed.starts_with("//") {
continue; // doc/comment mentions
}
if line.contains("/events") && !allowlist.iter().any(|a| rel.ends_with(a)) {
violations.push(format!("{rel}:{}: {}", i + 1, line.trim()));
}
}
}
assert!(
violations.is_empty(),
"new `/events` egress site(s) outside the guarded inventory — wire \
crate::egress_guard and add an injection test before allowlisting:\n{}",
violations.join("\n")
);
}
/// Source allowlist: NIP-49 material handling is confined to the identity /
/// backup / import / guard files. Anything else touching ncryptsec or the
/// nip49 codec is structural drift.
#[test]
fn ncryptsec_handling_is_confined_to_allowlisted_files() {
let allowlist: &[&str] = &[
"src/key_backup.rs",
"src/key_backup_tests.rs",
"src/egress_guard.rs",
"src/egress_guard_tests.rs",
"src/commands/identity.rs",
"src/lib.rs", // module registration + invoke handler
// boundary wiring (guard call sites name the module, not the codec):
"src/relay.rs",
"src/relay/submit.rs",
"src/huddle/pipeline.rs",
"src/commands/team_snapshot.rs",
"src/commands/team_snapshot/tests.rs",
"src/commands/personas/snapshot/import.rs",
"src/native_websocket.rs",
];
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let mut violations = Vec::new();
for path in src_rust_files() {
let rel = path
.strip_prefix(root)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
if allowlist.iter().any(|a| rel.ends_with(a)) {
continue;
}
let content = std::fs::read_to_string(&path).unwrap();
for needle in ["ncryptsec", "EncryptedSecretKey", "nip49"] {
if content.contains(needle) {
violations.push(format!("{rel}: contains {needle:?}"));
}
}
}
assert!(
violations.is_empty(),
"NIP-49 material outside allowlisted files:\n{}",
violations.join("\n")
);
}
+20 -4
View File
@@ -251,6 +251,23 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result<bool, S
Ok(true)
}
/// Sign an STT transcript event and produce the guarded POST body.
///
/// Factored out of the transcription loop so egress boundary 5 (huddle STT)
/// has a directly testable seam: the NIP-49 egress guard runs here, before
/// any bytes can reach the network.
pub(crate) fn sign_and_guard_stt_body(
builder: nostr::EventBuilder,
keys: &nostr::Keys,
) -> Result<Vec<u8>, String> {
let event = builder
.sign_with_keys(keys)
.map_err(|e| format!("sign event: {e}"))?;
let body_bytes = event.as_json().into_bytes();
crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "huddle STT publish")?;
Ok(body_bytes)
}
/// Spawn a tokio task that reads text_rx and posts kind:9 events.
///
/// Fix 1: `agent_pubkeys_arc` is an `Arc<Mutex<Vec<String>>>` cloned from
@@ -310,14 +327,13 @@ pub(crate) fn spawn_transcription_task(
// the kind event and build NIP-98 auth after the wait so both
// timestamps are fresh — single clean order: wait → sign → auth → send.
crate::relay_admission::wait_for_rate_limit().await;
let event = match builder.sign_with_keys(&keys) {
Ok(e) => e,
let body_bytes = match sign_and_guard_stt_body(builder, &keys) {
Ok(b) => b,
Err(e) => {
eprintln!("buzz-desktop: STT sign event: {e}");
eprintln!("buzz-desktop: STT publish: {e}");
continue;
}
};
let body_bytes = event.as_json().into_bytes();
let url = format!("{relay_base_url}/events");
let auth_header = match crate::relay::build_nip98_auth_header_for_keys(
&keys,
+213
View File
@@ -0,0 +1,213 @@
//! NIP-49 encrypted local key backup.
//!
//! Creates a password-encrypted `ncryptsec` backup of the user's identity key
//! and persists it locally. The blob is **local-only by contract**: it must
//! never be transmitted to a relay on any path. That contract is enforced at
//! runtime by [`crate::egress_guard`] (wired into every relay event-body
//! constructor and the native websocket send loop) and structurally by the
//! source-allowlist scan in this module's tests.
//!
//! Design (PLANS/NIP49_LOCAL_BACKUP_PLAN.md Rev 3, reviewed by Wren):
//!
//! - **One artifact per action.** [`create_backup_blob`] encrypts once, then
//! decrypt-verifies the fresh blob against the live identity pubkey before
//! anything is persisted or returned. Two sequential scrypt invocations
//! (encrypt + integrity check), one artifact, ~256 MiB peak.
//! - **Canonical file is trusted-path only.** The app-managed backup at
//! `{app_data_dir}/identity.ncryptsec` is written only by
//! `create_ncryptsec_backup` (commands/identity.rs), which derives the blob
//! from the live identity under `identity_mutation`. The webview can never
//! supply canonical blob bytes — the trust boundary is the password.
//! - **Portable copies are user-owned.** `save_ncryptsec_copy` writes a
//! user-selected file with secret-file semantics (atomic + 0o600) and never
//! mutates canonical app state.
use nostr::nips::nip49::{EncryptedSecretKey, KeySecurity};
use nostr::{FromBech32, Keys, ToBech32};
/// Bech32 HRP of NIP-49 encrypted secret keys (used by `import_identity` to
/// route encrypted backups to the decrypt path).
pub const NCRYPTSEC_HRP: &str = "ncryptsec1";
/// scrypt cost for new backups (2^18 — Gossip's desktop default, ~256 MiB).
/// The blob self-describes its cost, so this can be raised later without
/// breaking existing backups.
pub const BACKUP_LOG_N: u8 = 18;
/// Filename of the app-managed canonical backup inside the app data dir.
pub const BACKUP_FILE_NAME: &str = "identity.ncryptsec";
/// Number of words in a generated backup passphrase. Six words from a
/// 1296-word list ≈ 62 bits of entropy before the scrypt work factor.
const PASSPHRASE_WORDS: usize = 6;
/// EFF short wordlist 2.0 (1296 words, one per line).
const WORDLIST: &str = include_str!("assets/eff_short_wordlist_2_0.txt");
/// Minimum length for a user-chosen passphrase.
pub const MIN_PASSPHRASE_LEN: usize = 12;
/// Encrypt the identity secret key under `password` and verify the result.
///
/// Returns the bech32 `ncryptsec1…` string. The fresh blob is decrypted and
/// its derived pubkey compared to the live identity **before** returning, so
/// a returned blob is always provably recoverable with the same password.
pub fn create_backup_blob(keys: &Keys, password: &str, log_n: u8) -> Result<String, String> {
let secret_key = keys.secret_key();
let encrypted = EncryptedSecretKey::new(secret_key, password, log_n, KeySecurity::Unknown)
.map_err(|e| format!("encrypt key backup: {e}"))?;
let ncryptsec = encrypted
.to_bech32()
.map_err(|e| format!("encode ncryptsec: {e}"))?;
// Integrity check: decrypt the fresh blob and confirm it recovers the
// exact live identity. A corrupted or mis-encrypted blob must never be
// shown to the user as a "backup". This is the second, deliberate KDF
// invocation of the one-artifact-per-action contract.
verify_backup_blob(&ncryptsec, password, &keys.public_key())?;
Ok(ncryptsec)
}
/// Decrypt `ncryptsec` with `password` and assert it recovers a key whose
/// public key equals `expected_pubkey`.
pub fn verify_backup_blob(
ncryptsec: &str,
password: &str,
expected_pubkey: &nostr::PublicKey,
) -> Result<(), String> {
let encrypted = parse_ncryptsec(ncryptsec)?;
let recovered = encrypted
.decrypt(password)
.map_err(|e| format!("verify key backup (decrypt): {e}"))?;
let recovered_keys = Keys::new(recovered);
if recovered_keys.public_key() != *expected_pubkey {
return Err("verify key backup: decrypted key does not match identity".to_string());
}
Ok(())
}
/// Parse a bech32 `ncryptsec1…` string, rejecting anything that is not a
/// structurally valid NIP-49 payload.
pub fn parse_ncryptsec(input: &str) -> Result<EncryptedSecretKey, String> {
EncryptedSecretKey::from_bech32(input.trim()).map_err(|e| format!("invalid ncryptsec: {e}"))
}
/// Decrypt an `ncryptsec1…` string with `password` into identity keys.
pub fn decrypt_ncryptsec(input: &str, password: &str) -> Result<Keys, String> {
let encrypted = parse_ncryptsec(input)?;
let secret_key = encrypted
.decrypt(password)
.map_err(|_| "wrong passphrase or corrupted backup".to_string())?;
Ok(Keys::new(secret_key))
}
/// Recover identity keys from an import input: `ncryptsec1…` (requires the
/// passphrase, decrypted in Rust) or anything `Keys::parse` accepts (raw
/// `nsec1…`/hex — byte-for-byte the pre-NIP-49 path).
pub fn recover_keys_from_input(input: &str, password: Option<&str>) -> Result<Keys, String> {
let trimmed = input.trim();
if trimmed.starts_with(NCRYPTSEC_HRP) {
let password =
password.ok_or_else(|| "encrypted backup requires a passphrase".to_string())?;
decrypt_ncryptsec(trimmed, password)
} else {
Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))
}
}
/// Path of the canonical app-managed backup file.
pub fn backup_file_path(data_dir: &std::path::Path) -> std::path::PathBuf {
data_dir.join(BACKUP_FILE_NAME)
}
/// Atomically write `ncryptsec` to `path` with owner-only permissions, then
/// reread and byte-compare. Same crash-safety pattern as
/// `app_state::save_key_file`.
pub fn write_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), String> {
use atomic_write_file::AtomicWriteFile;
use std::io::Write;
let mut file = AtomicWriteFile::open(path)
.map_err(|e| format!("open backup file for atomic write: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o600))
.map_err(|e| format!("set backup file permissions: {e}"))?;
}
file.write_all(ncryptsec.as_bytes())
.map_err(|e| format!("write backup file: {e}"))?;
file.commit()
.map_err(|e| format!("commit backup file: {e}"))?;
// Reread and byte-compare: only report success for bytes that are
// actually on disk.
let on_disk = std::fs::read_to_string(path).map_err(|e| format!("reread backup file: {e}"))?;
if on_disk != ncryptsec {
return Err("backup file verification failed: on-disk bytes differ".to_string());
}
Ok(())
}
/// Delete the canonical app-managed backup if present. Used when a different
/// identity is imported — the old blob backs the previous key and must not
/// linger mislabeled. Missing file is not an error.
pub fn delete_backup_file(data_dir: &std::path::Path) -> Result<(), String> {
let path = backup_file_path(data_dir);
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("delete stale backup file: {e}")),
}
}
/// Remove the app-managed backup when the identity changes: the existing blob
/// encrypts `previous` and must not linger mislabeled once `new` is live.
/// No-op when the identity is unchanged.
pub fn cleanup_stale_backup(
previous: &nostr::PublicKey,
new: &nostr::PublicKey,
data_dir: &std::path::Path,
) -> Result<(), String> {
if previous != new {
delete_backup_file(data_dir)?;
}
Ok(())
}
/// Generate a 6-word passphrase from the EFF short wordlist using OS entropy.
///
/// Uses rejection sampling for a uniform distribution over the 1296 words.
pub fn generate_passphrase() -> Result<String, String> {
let words: Vec<&str> = WORDLIST.lines().filter(|l| !l.is_empty()).collect();
if words.len() != 1296 {
return Err(format!(
"wordlist corrupted: expected 1296 words, found {}",
words.len()
));
}
let mut chosen: Vec<&str> = Vec::with_capacity(PASSPHRASE_WORDS);
while chosen.len() < PASSPHRASE_WORDS {
let mut buf = [0u8; 2];
getrandom::getrandom(&mut buf).map_err(|e| format!("entropy source: {e}"))?;
let value = u16::from_le_bytes(buf);
// Rejection sampling: accept only values below the largest multiple
// of 1296 that fits in u16 (65536 - 65536 % 1296 = 64800).
if value < 64800 {
chosen.push(words[(value as usize) % 1296]);
}
}
Ok(chosen.join(" "))
}
#[cfg(test)]
#[path = "key_backup_tests.rs"]
mod tests;
+203
View File
@@ -0,0 +1,203 @@
use super::*;
/// NIP-49 spec vector (same as rust-nostr's upstream test): decrypts with
/// password "nostr" at our call sites.
const SPEC_NCRYPTSEC: &str = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
const SPEC_SECRET_HEX: &str = "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683";
/// Fast scrypt tier for tests. log_n 18 is exercised once in
/// `round_trip_at_production_cost`.
const FAST_LOG_N: u8 = 16;
// ── Codec ─────────────────────────────────────────────────────────────────────
#[test]
fn spec_vector_decrypts_at_our_call_site() {
let keys = decrypt_ncryptsec(SPEC_NCRYPTSEC, "nostr").unwrap();
assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX);
}
#[test]
fn round_trip_fast_tier() {
let keys = Keys::generate();
let blob = create_backup_blob(&keys, "correct horse battery", FAST_LOG_N).unwrap();
assert!(blob.starts_with(NCRYPTSEC_HRP));
let recovered = decrypt_ncryptsec(&blob, "correct horse battery").unwrap();
assert_eq!(recovered.public_key(), keys.public_key());
}
#[test]
fn round_trip_at_production_cost() {
// One log_n 18 round trip: proves the production constant works end to
// end (slow — several seconds — but deliberate; see plan D5).
let keys = Keys::generate();
let blob = create_backup_blob(&keys, "production cost tier check", BACKUP_LOG_N).unwrap();
let recovered = decrypt_ncryptsec(&blob, "production cost tier check").unwrap();
assert_eq!(recovered.public_key(), keys.public_key());
}
#[test]
fn wrong_password_is_a_friendly_error() {
let keys = Keys::generate();
let blob = create_backup_blob(&keys, "right password", FAST_LOG_N).unwrap();
let err = decrypt_ncryptsec(&blob, "wrong password").unwrap_err();
assert_eq!(err, "wrong passphrase or corrupted backup");
}
#[test]
fn nfkc_cross_form_passphrase_round_trips() {
// "é" composed (U+00E9) vs decomposed (e + U+0301): NIP-49 mandates NFKC
// normalization, so a passphrase entered in either form must decrypt.
let keys = Keys::generate();
let composed = "caf\u{00e9} passphrase";
let decomposed = "cafe\u{0301} passphrase";
assert_ne!(composed, decomposed);
let blob = create_backup_blob(&keys, composed, FAST_LOG_N).unwrap();
let recovered = decrypt_ncryptsec(&blob, decomposed).unwrap();
assert_eq!(recovered.public_key(), keys.public_key());
}
#[test]
fn parse_rejects_garbage_and_wrong_hrp() {
assert!(parse_ncryptsec("garbage").is_err());
assert!(parse_ncryptsec("").is_err());
// Valid bech32, wrong HRP (an nsec is not an encrypted backup).
let nsec = Keys::generate().secret_key().to_bech32().unwrap();
assert!(parse_ncryptsec(&nsec).is_err());
// Truncated blob.
assert!(parse_ncryptsec(&SPEC_NCRYPTSEC[..SPEC_NCRYPTSEC.len() - 10]).is_err());
}
#[test]
fn verify_backup_blob_catches_pubkey_mismatch() {
// Corrupted-blob simulation: the blob decrypts fine but recovers a key
// that is not the live identity — verification must fail.
let other = Keys::generate();
let blob = create_backup_blob(&other, "some password", FAST_LOG_N).unwrap();
let live = Keys::generate();
let err = verify_backup_blob(&blob, "some password", &live.public_key()).unwrap_err();
assert!(err.contains("does not match identity"), "{err}");
}
// ── Import key recovery ───────────────────────────────────────────────────────
#[test]
fn recover_keys_ncryptsec_happy_path() {
let keys = recover_keys_from_input(&format!(" {SPEC_NCRYPTSEC}\n"), Some("nostr")).unwrap();
assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX);
}
#[test]
fn recover_keys_ncryptsec_requires_password() {
let err = recover_keys_from_input(SPEC_NCRYPTSEC, None).unwrap_err();
assert_eq!(err, "encrypted backup requires a passphrase");
}
#[test]
fn recover_keys_ncryptsec_wrong_password() {
let err = recover_keys_from_input(SPEC_NCRYPTSEC, Some("wrong")).unwrap_err();
assert_eq!(err, "wrong passphrase or corrupted backup");
}
#[test]
fn recover_keys_raw_nsec_path_unchanged() {
let keys = Keys::generate();
let nsec = keys.secret_key().to_bech32().unwrap();
// Password is ignored on the raw path — exactly today's behavior.
let recovered = recover_keys_from_input(&nsec, Some("ignored")).unwrap();
assert_eq!(recovered.public_key(), keys.public_key());
let recovered = recover_keys_from_input(&nsec, None).unwrap();
assert_eq!(recovered.public_key(), keys.public_key());
assert!(recover_keys_from_input("garbage", None).is_err());
}
// ── File lifecycle ────────────────────────────────────────────────────────────
#[test]
fn write_backup_file_persists_0600_and_verifies() {
let dir = tempfile::tempdir().unwrap();
let path = backup_file_path(dir.path());
write_backup_file(&path, SPEC_NCRYPTSEC).unwrap();
let on_disk = std::fs::read_to_string(&path).unwrap();
assert_eq!(on_disk, SPEC_NCRYPTSEC);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "backup file must be owner-only");
}
}
#[test]
fn write_backup_file_overwrites_atomically() {
let dir = tempfile::tempdir().unwrap();
let path = backup_file_path(dir.path());
write_backup_file(&path, "ncryptsec1old").unwrap();
write_backup_file(&path, SPEC_NCRYPTSEC).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), SPEC_NCRYPTSEC);
// No leftover temp files from the atomic write.
let entries: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(entries, vec![std::ffi::OsString::from(BACKUP_FILE_NAME)]);
}
#[test]
fn delete_backup_file_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
delete_backup_file(dir.path()).unwrap(); // missing → Ok
let path = backup_file_path(dir.path());
write_backup_file(&path, SPEC_NCRYPTSEC).unwrap();
delete_backup_file(dir.path()).unwrap();
assert!(!path.exists());
}
#[test]
fn cleanup_stale_backup_removes_only_on_identity_change() {
let dir = tempfile::tempdir().unwrap();
let path = backup_file_path(dir.path());
let a = Keys::generate().public_key();
let b = Keys::generate().public_key();
write_backup_file(&path, SPEC_NCRYPTSEC).unwrap();
cleanup_stale_backup(&a, &a, dir.path()).unwrap();
assert!(path.exists(), "same identity must keep the backup");
cleanup_stale_backup(&a, &b, dir.path()).unwrap();
assert!(
!path.exists(),
"identity change must remove the stale backup"
);
}
// ── Passphrase generation ─────────────────────────────────────────────────────
#[test]
fn generated_passphrase_is_six_known_words() {
let words: std::collections::HashSet<&str> =
WORDLIST.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(words.len(), 1296, "EFF short wordlist 2.0 has 1296 words");
for _ in 0..8 {
let phrase = generate_passphrase().unwrap();
let parts: Vec<&str> = phrase.split(' ').collect();
assert_eq!(parts.len(), 6);
for w in &parts {
assert!(words.contains(w), "unknown word {w:?}");
}
assert!(phrase.chars().count() >= MIN_PASSPHRASE_LEN);
}
}
#[test]
fn generated_passphrases_are_not_repeated() {
// 6 words × ~10.3 bits each — a collision across 8 draws would indicate a
// broken entropy source, not bad luck.
let mut seen = std::collections::HashSet::new();
for _ in 0..8 {
assert!(seen.insert(generate_passphrase().unwrap()));
}
}
+5
View File
@@ -4,9 +4,11 @@ mod archive;
mod builderlab;
mod commands;
mod deep_link;
mod egress_guard;
mod event_sync;
mod events;
mod huddle;
mod key_backup;
mod managed_agents;
mod media_proxy;
#[cfg(feature = "mesh-llm")]
@@ -663,6 +665,9 @@ pub fn run() {
title_bar_double_click,
get_identity,
get_nsec,
generate_backup_passphrase,
create_ncryptsec_backup,
save_ncryptsec_copy,
import_identity,
persist_current_identity,
get_profile,
+16 -4
View File
@@ -24,7 +24,7 @@ type Id = u32;
#[derive(Debug, Deserialize)]
#[serde(tag = "type", content = "data")]
enum WebSocketMessage {
pub(crate) enum WebSocketMessage {
Text(String),
Binary(Vec<u8>),
Ping(Vec<u8>),
@@ -33,7 +33,7 @@ enum WebSocketMessage {
}
#[derive(Debug, Deserialize)]
struct CloseFramePayload {
pub(crate) struct CloseFramePayload {
code: u16,
reason: String,
}
@@ -82,7 +82,7 @@ struct ConnectionHandle {
}
#[derive(Clone)]
struct WebSocketManager {
pub(crate) struct WebSocketManager {
connections: Arc<Mutex<HashMap<Id, Arc<ConnectionHandle>>>>,
connect_cancel: Arc<Mutex<CancellationToken>>,
}
@@ -182,11 +182,23 @@ async fn connect(
open_connection(manager.inner(), &url, on_message).await
}
async fn send_message(
pub(crate) async fn send_message(
manager: &WebSocketManager,
id: Id,
message: WebSocketMessage,
) -> Result<(), String> {
// Egress guard: the NIP-49 local key backup must never reach a relay.
// This is the single choke point for all webview-originated websocket
// frames (see `crate::egress_guard`).
match &message {
WebSocketMessage::Text(text) => {
crate::egress_guard::assert_no_key_backup(text, "websocket text frame")?
}
WebSocketMessage::Binary(bytes) => {
crate::egress_guard::assert_no_key_backup_bytes(bytes, "websocket binary frame")?
}
_ => {}
}
let handle = manager
.connections
.lock()
+3
View File
@@ -450,6 +450,7 @@ pub async fn sync_managed_agent_profile(
let event = build_profile_event(agent_keys, display_name, avatar_url, auth_tag)?;
let event_json = event.as_json();
let body_bytes = event_json.into_bytes();
crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "agent profile sync")?;
let url = format!("{}/events", relay_http_base_url(relay_url));
let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, &url, &body_bytes)?;
@@ -548,6 +549,7 @@ pub async fn submit_signed_event(
crate::relay_admission::wait_for_rate_limit().await;
let url = format!("{}/events", relay_api_base_url_with_override(state));
let body_bytes = event.as_json().into_bytes();
crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "signed event submit")?;
let auth_header = {
let keys = state.signing_keys()?;
build_nip98_auth_header_for_keys(&keys, &Method::POST, &url, &body_bytes)?
@@ -606,6 +608,7 @@ pub async fn submit_signed_event_with_keys(
crate::relay_admission::wait_for_rate_limit().await;
let url = format!("{}/events", relay_api_base_url_with_override(state));
let body_bytes = event.as_json().into_bytes();
crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "signed event submit (keys)")?;
let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?;
let mut request = state
+1
View File
@@ -25,6 +25,7 @@ pub async fn submit_event_at_with_keys(
.sign_with_keys(keys)
.map_err(|e| format!("failed to sign event: {e}"))?;
let body_bytes = event.as_json().into_bytes();
crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "relay event submit")?;
let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?;
let response = state
+20
View File
@@ -463,6 +463,26 @@ mod tests {
assert_eq!(kc.delete_calls.get(), 1, "keychain deleted once");
}
// ── NIP-49: the boot wipe destroys the app-managed key backup ─────────────
#[test]
fn test_wipe_removes_app_managed_key_backup() {
let tmp = TempDir::new().unwrap();
let app_data = make_app_data(&tmp);
let backup = crate::key_backup::backup_file_path(&app_data);
std::fs::write(&backup, b"encrypted-backup-bytes").unwrap();
write_sentinel(&app_data).unwrap();
let kc = FakeKeychain::ok();
let outcome = run_boot_reset_with_keychain(make_ctx(&app_data, &kc, false));
assert!(outcome.completed);
assert!(
!backup.exists(),
"sign-out wipe must destroy the app-managed key backup"
);
}
// ── Test 3: keychain failure keeps sentinel ────────────────────────────────
#[test]