mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Tyler Longwell
parent
ab7aa8b120
commit
e5cff3865d
Generated
+1
@@ -1031,6 +1031,7 @@ dependencies = [
|
||||
"ed25519-dalek",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"getrandom 0.2.17",
|
||||
"hex",
|
||||
"image",
|
||||
"infer",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user