use bitcoin::hashes::sha256::Hash as Sha256Hash;
use bitcoin::hashes::Hash;
use nostr_sdk::nips::nip44::{self, Version};
use nostr_sdk::{Event, EventBuilder, Keys, Kind, Tag, TagKind};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::mint_url::MintUrl;
const DOMAIN_SEPARATOR: &[u8] = b"cashu-mint-backup";
const KIND_APPLICATION_SPECIFIC_DATA: u16 = 30078;
const MINT_LIST_IDENTIFIER: &str = "mint-list";
#[derive(Debug, Error)]
pub enum Error {
#[error(transparent)]
NostrKey(#[from] nostr_sdk::key::Error),
#[error(transparent)]
NostrEventBuilder(#[from] nostr_sdk::event::builder::Error),
#[error(transparent)]
Nip44(#[from] nip44::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error("Invalid event kind: expected {expected}, got {got}")]
InvalidEventKind {
expected: u16,
got: u16,
},
#[error("Missing 'd' tag with identifier '{0}'")]
MissingIdentifierTag(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MintBackup {
pub mints: Vec<MintUrl>,
pub timestamp: u64,
}
impl MintBackup {
pub fn new(mints: Vec<MintUrl>) -> Self {
let timestamp = web_time::SystemTime::now()
.duration_since(web_time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
Self { mints, timestamp }
}
pub fn with_timestamp(mints: Vec<MintUrl>, timestamp: u64) -> Self {
Self { mints, timestamp }
}
}
pub fn derive_nostr_keys(seed: &[u8; 64]) -> Result<Keys, Error> {
let mut combined_data = Vec::with_capacity(seed.len() + DOMAIN_SEPARATOR.len());
combined_data.extend_from_slice(seed);
combined_data.extend_from_slice(DOMAIN_SEPARATOR);
let hash = Sha256Hash::hash(&combined_data);
let private_key_bytes = hash.to_byte_array();
let secret_key = nostr_sdk::SecretKey::from_slice(&private_key_bytes)?;
let keys = Keys::new(secret_key);
Ok(keys)
}
pub fn create_backup_event(
keys: &Keys,
backup: &MintBackup,
client: Option<&str>,
) -> Result<Event, Error> {
let plaintext = serde_json::to_string(backup)?;
let encrypted_content = nip44::encrypt(
keys.secret_key(),
&keys.public_key(),
plaintext,
Version::V2,
)?;
let mut builder = EventBuilder::new(
Kind::Custom(KIND_APPLICATION_SPECIFIC_DATA),
encrypted_content,
)
.tag(Tag::identifier(MINT_LIST_IDENTIFIER));
if let Some(client_name) = client {
builder = builder.tag(Tag::custom(
nostr_sdk::TagKind::Custom(std::borrow::Cow::Borrowed("client")),
[client_name],
));
}
let event = builder.sign_with_keys(keys)?;
Ok(event)
}
pub fn decrypt_backup_event(keys: &Keys, event: &Event) -> Result<MintBackup, Error> {
let expected_kind = Kind::Custom(KIND_APPLICATION_SPECIFIC_DATA);
if event.kind != expected_kind {
return Err(Error::InvalidEventKind {
expected: KIND_APPLICATION_SPECIFIC_DATA,
got: event.kind.as_u16(),
});
}
let has_mint_list_tag = event
.tags
.iter()
.any(|tag| tag.kind() == TagKind::d() && tag.content() == Some(MINT_LIST_IDENTIFIER));
if !has_mint_list_tag {
return Err(Error::MissingIdentifierTag(
MINT_LIST_IDENTIFIER.to_string(),
));
}
let decrypted = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content)?;
let backup: MintBackup = serde_json::from_str(&decrypted)?;
Ok(backup)
}
pub fn backup_filter_params(keys: &Keys) -> (Kind, nostr_sdk::PublicKey, &'static str) {
(
Kind::Custom(KIND_APPLICATION_SPECIFIC_DATA),
keys.public_key(),
MINT_LIST_IDENTIFIER,
)
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use bip39::Mnemonic;
use super::*;
fn test_keys() -> Keys {
let mnemonic = Mnemonic::from_str(
"half depart obvious quality work element tank gorilla view sugar picture humble",
)
.unwrap();
let seed: [u8; 64] = mnemonic.to_seed("");
derive_nostr_keys(&seed).unwrap()
}
#[test]
fn test_derive_nostr_keys_from_seed() {
let keys = test_keys();
let secret_key = keys.secret_key();
let public_key = keys.public_key();
assert_eq!(secret_key.as_secret_bytes().len(), 32);
assert_eq!(public_key.to_hex().len(), 64);
}
#[test]
fn test_key_derivation_vector() {
use crate::util::hex;
let keys = test_keys();
let expected_secret_key =
"e7ca79469a270b36617e4227ff2f068d3bcbb6b072c8584190b0203597c53c0d";
assert_eq!(
hex::encode(keys.secret_key().as_secret_bytes()),
expected_secret_key
);
let expected_public_key =
"0767277aaed200af7a8843491745272fc1ad2c7bfe340225e6f34f3a9a273aed";
assert_eq!(keys.public_key().to_hex(), expected_public_key);
}
#[test]
fn test_mint_backup_new() {
let mints = vec![
MintUrl::from_str("https://mint.example.com").unwrap(),
MintUrl::from_str("https://another-mint.org").unwrap(),
];
let backup = MintBackup::new(mints.clone());
assert_eq!(backup.mints, mints);
assert!(backup.timestamp > 0);
}
#[test]
fn test_mint_backup_serialization() {
let mints = vec![
MintUrl::from_str("https://mint.example.com").unwrap(),
MintUrl::from_str("https://another-mint.org").unwrap(),
];
let backup = MintBackup::with_timestamp(mints, 1703721600);
let json = serde_json::to_string(&backup).unwrap();
let parsed: MintBackup = serde_json::from_str(&json).unwrap();
assert_eq!(backup, parsed);
}
#[test]
fn test_create_and_decrypt_backup_event() {
let keys = test_keys();
let mints = vec![
MintUrl::from_str("https://mint.example.com").unwrap(),
MintUrl::from_str("https://another-mint.org").unwrap(),
];
let backup = MintBackup::with_timestamp(mints.clone(), 1703721600);
let event = create_backup_event(&keys, &backup, Some("cashu-test")).unwrap();
assert_eq!(event.kind, Kind::Custom(KIND_APPLICATION_SPECIFIC_DATA));
assert_eq!(event.pubkey, keys.public_key());
let has_d_tag = event
.tags
.iter()
.any(|tag| tag.kind() == TagKind::d() && tag.content() == Some(MINT_LIST_IDENTIFIER));
assert!(has_d_tag, "Event should have 'd' tag with 'mint-list'");
let decrypted = decrypt_backup_event(&keys, &event).unwrap();
assert_eq!(decrypted.mints, mints);
assert_eq!(decrypted.timestamp, 1703721600);
}
#[test]
fn test_create_backup_event_without_client() {
let keys = test_keys();
let backup = MintBackup::with_timestamp(vec![], 1703721600);
let event = create_backup_event(&keys, &backup, None).unwrap();
let has_client_tag = event.tags.iter().any(
|tag| matches!(tag.kind(), nostr_sdk::TagKind::Custom(cow) if cow.as_ref() == "client"),
);
assert!(!has_client_tag);
}
#[test]
fn test_decrypt_wrong_event_kind() {
let keys = test_keys();
let event = EventBuilder::new(Kind::TextNote, "test")
.tag(Tag::identifier(MINT_LIST_IDENTIFIER))
.sign_with_keys(&keys)
.unwrap();
let result = decrypt_backup_event(&keys, &event);
assert!(matches!(result, Err(Error::InvalidEventKind { .. })));
}
#[test]
fn test_decrypt_missing_d_tag() {
let keys = test_keys();
let backup = MintBackup::with_timestamp(vec![], 1703721600);
let plaintext = serde_json::to_string(&backup).unwrap();
let encrypted = nip44::encrypt(
keys.secret_key(),
&keys.public_key(),
plaintext,
Version::V2,
)
.unwrap();
let event = EventBuilder::new(Kind::Custom(KIND_APPLICATION_SPECIFIC_DATA), encrypted)
.sign_with_keys(&keys)
.unwrap();
let result = decrypt_backup_event(&keys, &event);
assert!(matches!(result, Err(Error::MissingIdentifierTag(_))));
}
#[test]
fn test_backup_filter_params() {
let keys = test_keys();
let (kind, pubkey, d_tag) = backup_filter_params(&keys);
assert_eq!(kind, Kind::Custom(KIND_APPLICATION_SPECIFIC_DATA));
assert_eq!(pubkey, keys.public_key());
assert_eq!(d_tag, MINT_LIST_IDENTIFIER);
}
}