mirror of
https://code.gri.mw/GUI/grim.git
synced 2026-07-15 11:18:55 +00:00
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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user