diff --git a/src/nostr/identity.rs b/src/nostr/identity.rs index 7fa3d3d0..299561e6 100644 --- a/src/nostr/identity.rs +++ b/src/nostr/identity.rs @@ -16,6 +16,7 @@ //! default (one seed restores money AND identity) or imported from an nsec. //! Stored at rest as NIP-49 ncryptsec encrypted with the wallet password. +use nostr_sdk::nips::nip44; use nostr_sdk::nips::nip49::{EncryptedSecretKey, KeySecurity}; use nostr_sdk::prelude::FromMnemonic; use nostr_sdk::{FromBech32, Keys, SecretKey, ToBech32}; @@ -243,6 +244,67 @@ impl NostrIdentity { .map_err(|e| IdentityError::Key(format!("bech32 failed: {e}")))?; Ok(()) } + + /// A single, fully-encrypted, portable backup of this identity (the contents + /// of a `GOBLIN-*.backup` file). Two sealed layers, no plaintext: the secret + /// key is the password-protected NIP-49 ncryptsec, and the rest of the + /// identity (username, history, source) is NIP-44-sealed to our own key. An + /// outside party sees only ciphertext — no npub, no name. Any Goblin wallet + /// reopens it with the backup's password. `keys` must be this identity's + /// unlocked keys (the caller unlocks with the password first). + pub fn to_encrypted_backup(&self, keys: &Keys) -> Result { + let json = serde_json::to_string(self)?; + let sealed = nip44::encrypt( + keys.secret_key(), + &keys.public_key(), + json, + nip44::Version::V2, + ) + .map_err(|e| IdentityError::Key(format!("seal failed: {e}")))?; + let envelope = serde_json::json!({ + "goblin_backup": 1, + "k": self.ncryptsec, + "d": sealed, + }); + serde_json::to_string(&envelope).map_err(IdentityError::from) + } + + /// True if `s` is a Goblin encrypted-backup envelope (vs a bare nsec or the + /// legacy plaintext identity JSON). + pub fn is_encrypted_backup(s: &str) -> bool { + serde_json::from_str::(s.trim()) + .ok() + .and_then(|v| v.get("goblin_backup").cloned()) + .is_some() + } + + /// Open an encrypted backup with its password, returning the embedded + /// identity and its unlocked keys. + pub fn from_encrypted_backup( + envelope: &str, + password: &str, + ) -> Result<(NostrIdentity, Keys), IdentityError> { + let v: serde_json::Value = serde_json::from_str(envelope.trim())?; + let k = v + .get("k") + .and_then(|x| x.as_str()) + .ok_or_else(|| IdentityError::Key("backup missing key".into()))?; + let d = v + .get("d") + .and_then(|x| x.as_str()) + .ok_or_else(|| IdentityError::Key("backup missing data".into()))?; + // Unlock the wrapper key with the password, then open the sealed JSON. + let enc = EncryptedSecretKey::from_bech32(k) + .map_err(|e| IdentityError::Key(format!("invalid backup: {e}")))?; + let secret = enc + .decrypt(password) + .map_err(|_| IdentityError::WrongPassword)?; + let keys = Keys::new(secret); + let json = nip44::decrypt(keys.secret_key(), &keys.public_key(), d) + .map_err(|_| IdentityError::WrongPassword)?; + let identity: NostrIdentity = serde_json::from_str(&json)?; + Ok((identity, keys)) + } } #[cfg(test)] @@ -268,6 +330,27 @@ mod tests { assert!(b.unlock("old-pw").is_err()); } + #[test] + fn encrypted_backup_roundtrips_and_is_opaque() { + // A .backup file: sealed under one password, reopened with it. The + // envelope must carry no plaintext npub/name, and a wrong password fails. + let (mut a, keys) = NostrIdentity::create_random("pw-1").unwrap(); + a.nip05 = Some("jimbob@goblin.st".to_string()); + a.anonymous = false; + let envelope = a.to_encrypted_backup(&keys).unwrap(); + assert!(NostrIdentity::is_encrypted_backup(&envelope)); + // Opaque: neither the public key nor the username leaks in the file. + assert!(!envelope.contains(&a.npub)); + assert!(!envelope.contains("jimbob")); + // Reopen with the password → same identity. + let (restored, rkeys) = NostrIdentity::from_encrypted_backup(&envelope, "pw-1").unwrap(); + assert_eq!(restored.npub, a.npub); + assert_eq!(restored.nip05.as_deref(), Some("jimbob@goblin.st")); + assert_eq!(rkeys.public_key(), keys.public_key()); + // Wrong password can't open it. + assert!(NostrIdentity::from_encrypted_backup(&envelope, "wrong").is_err()); + } + #[test] fn random_identities_are_unlinked_and_unlock() { let (a, ka) = NostrIdentity::create_random("pw-1").unwrap();