nostr identity: single fully-encrypted .backup envelope

Add to_encrypted_backup / from_encrypted_backup: the secret key is the
password-protected NIP-49 ncryptsec, and the rest of the identity is
NIP-44-sealed to our own key — so a GOBLIN-*.backup file leaks no npub or
username, yet any Goblin wallet reopens it with the password.
This commit is contained in:
2ro
2026-06-16 00:31:37 -04:00
parent 6ea94989bf
commit 222f149fc2
+83
View File
@@ -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<String, IdentityError> {
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::<serde_json::Value>(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();