use crate::constants::{NOSTR_CREATED_AT_JITTER_SECS, NOSTR_INITIAL_QUERY_LIMIT};
use crate::nip44::{self, ConversationKey};
use crate::{Error, Result};
use hkdf::Hkdf;
use k256::schnorr::SigningKey;
use offline_protocol_core::{Address, Username};
use rand_core::{OsRng, RngCore};
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;
const SIGNING_KEY_HKDF_INFO: &[u8] = b"offline-protocol/nostr/v1/signing-key/";
const RECORD_SEAL_HKDF_INFO: &[u8] = b"offline-protocol/nostr/v1/record-seal-key/";
const DISCOVERY_SEAL_HKDF_INFO: &[u8] = b"offline-protocol/nostr/v1/discovery-seal-key/";
const DISCOVERY_TAG_DOMAIN: &[u8] = b"offline-disc-v1:";
const MAX_DERIVE_ATTEMPTS: u8 = 8;
const MIN_INSTALL_SECRET_LEN: usize = 16;
pub struct NostrKeypair {
signing_key: SigningKey,
public_key_hex: String,
}
impl NostrKeypair {
pub fn from_install_secret(secret: &[u8]) -> Result<Self> {
if secret.len() < MIN_INSTALL_SECRET_LEN {
return Err(Error::CryptoError(format!(
"Install secret too short: {} bytes (minimum {})",
secret.len(),
MIN_INSTALL_SECRET_LEN
)));
}
let hkdf = Hkdf::<Sha256>::new(None, secret);
let mut info = Vec::with_capacity(SIGNING_KEY_HKDF_INFO.len() + 1);
for counter in 0..MAX_DERIVE_ATTEMPTS {
info.clear();
info.extend_from_slice(SIGNING_KEY_HKDF_INFO);
info.push(counter);
let mut candidate = Zeroizing::new([0u8; 32]);
hkdf.expand(&info, &mut *candidate)
.map_err(|e| Error::CryptoError(format!("HKDF expand failed: {}", e)))?;
if let Ok(signing_key) = SigningKey::from_bytes(&*candidate) {
let public_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
return Ok(Self {
signing_key,
public_key_hex,
});
}
}
Err(Error::CryptoError(
"Failed to derive a valid signing key from the install secret".to_string(),
))
}
pub fn generate_ephemeral() -> Result<Self> {
let secret = Self::generate_install_secret()?;
Self::from_install_secret(&*secret)
}
pub fn generate_install_secret() -> Result<Zeroizing<[u8; 32]>> {
let mut secret = Zeroizing::new([0u8; 32]);
OsRng
.try_fill_bytes(&mut *secret)
.map_err(|e| Error::CryptoError(format!("OS RNG failure: {}", e)))?;
Ok(secret)
}
pub fn public_key_hex(&self) -> &str {
&self.public_key_hex
}
}
pub fn record_seal_keypair_for_address(address: &Address) -> Result<NostrKeypair> {
let address = address.to_string();
let hkdf = Hkdf::<Sha256>::new(None, address.as_bytes());
let mut info = Vec::with_capacity(RECORD_SEAL_HKDF_INFO.len() + 1);
for counter in 0..MAX_DERIVE_ATTEMPTS {
info.clear();
info.extend_from_slice(RECORD_SEAL_HKDF_INFO);
info.push(counter);
let mut candidate = Zeroizing::new([0u8; 32]);
hkdf.expand(&info, &mut *candidate)
.map_err(|e| Error::CryptoError(format!("HKDF expand failed: {}", e)))?;
if let Ok(signing_key) = SigningKey::from_bytes(&*candidate) {
let public_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
return Ok(NostrKeypair {
signing_key,
public_key_hex,
});
}
}
Err(Error::CryptoError(
"Failed to derive a record-seal key for the address".to_string(),
))
}
pub(crate) fn now_unix_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
pub fn routing_tag_for_address(address: &Address) -> Result<String> {
let address = address.to_string();
let tag_scalar = Sha256::digest(address.as_bytes());
let tag_key = SigningKey::from_bytes(tag_scalar.as_slice())
.map_err(|e| Error::CryptoError(format!("Invalid routing tag for address: {}", e)))?;
Ok(hex::encode(tag_key.verifying_key().to_bytes()))
}
pub fn discovery_tag_for_username(username: &Username) -> Result<String> {
let mut preimage = Vec::with_capacity(DISCOVERY_TAG_DOMAIN.len() + username.as_str().len());
preimage.extend_from_slice(DISCOVERY_TAG_DOMAIN);
preimage.extend_from_slice(username.as_str().as_bytes());
let tag_scalar = Sha256::digest(&preimage);
let tag_key = SigningKey::from_bytes(tag_scalar.as_slice())
.map_err(|e| Error::CryptoError(format!("Invalid discovery tag for username: {}", e)))?;
Ok(hex::encode(tag_key.verifying_key().to_bytes()))
}
pub fn discovery_seal_keypair_for_username(username: &Username) -> Result<NostrKeypair> {
let hkdf = Hkdf::<Sha256>::new(None, username.as_str().as_bytes());
let mut info = Vec::with_capacity(DISCOVERY_SEAL_HKDF_INFO.len() + 1);
for counter in 0..MAX_DERIVE_ATTEMPTS {
info.clear();
info.extend_from_slice(DISCOVERY_SEAL_HKDF_INFO);
info.push(counter);
let mut candidate = Zeroizing::new([0u8; 32]);
hkdf.expand(&info, &mut *candidate)
.map_err(|e| Error::CryptoError(format!("HKDF expand failed: {}", e)))?;
if let Ok(signing_key) = SigningKey::from_bytes(&*candidate) {
let public_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
return Ok(NostrKeypair {
signing_key,
public_key_hex,
});
}
}
Err(Error::CryptoError(
"Failed to derive a discovery-seal key for the username".to_string(),
))
}
pub const NOSTR_GIFT_WRAP_KIND: u32 = 1059;
pub const NOSTR_LEGACY_DM_KIND: u32 = 4;
pub const NOSTR_KEY_PACKAGE_KIND: u32 = 30443;
pub const NOSTR_DISCOVERY_KIND: u32 = 30777;
pub const NOSTR_DELETION_KIND: u32 = 5;
fn jittered_created_at() -> i64 {
let now = now_unix_secs();
let mut buf = [0u8; 8];
if OsRng.try_fill_bytes(&mut buf).is_err() {
return now;
}
let offset = (u64::from_be_bytes(buf) % (NOSTR_CREATED_AT_JITTER_SECS as u64 + 1)) as i64;
now.saturating_sub(offset).max(0)
}
#[derive(Debug, Clone)]
pub struct NostrEvent {
pub id: String,
pub pubkey: String,
pub created_at: i64,
pub kind: u32,
pub tags: Vec<Vec<String>>,
pub content: String,
pub sig: String,
}
impl NostrEvent {
pub fn create_dm(
keypair: &NostrKeypair,
recipient_pubkey_hex: &str,
content_base64: &str,
) -> Result<Self> {
Self::sign(
keypair,
NOSTR_LEGACY_DM_KIND,
recipient_pubkey_hex,
content_base64,
now_unix_secs(),
)
}
pub fn create_gift_wrap(
recipient_tag: &str,
recipient_encryption_pubkey: &str,
plaintext: &[u8],
) -> Result<Self> {
let ephemeral = NostrKeypair::generate_ephemeral()?;
let peer = hex::decode(recipient_encryption_pubkey).map_err(|e| {
Error::CryptoError(format!("Invalid recipient encryption pubkey: {}", e))
})?;
let conversation_key = ConversationKey::derive(&ephemeral.signing_key, &peer)?;
let sealed = nip44::encrypt(plaintext, &conversation_key)?;
Self::sign(
&ephemeral,
NOSTR_GIFT_WRAP_KIND,
recipient_tag,
&sealed,
jittered_created_at(),
)
}
pub fn create_key_package_publication(
keypair: &NostrKeypair,
routing_tag: &str,
seal_pubkey: &str,
slot_id: &str,
plaintext: &[u8],
) -> Result<Self> {
let seal_bytes = hex::decode(seal_pubkey)
.map_err(|e| Error::CryptoError(format!("Invalid record-seal pubkey: {}", e)))?;
let conversation_key = ConversationKey::derive(&keypair.signing_key, &seal_bytes)?;
let sealed = nip44::encrypt(plaintext, &conversation_key)?;
let tags = vec![
vec!["d".to_string(), slot_id.to_string()],
vec!["p".to_string(), routing_tag.to_string()],
];
Self::sign_with_tags(
keypair,
NOSTR_KEY_PACKAGE_KIND,
tags,
&sealed,
now_unix_secs(),
)
}
pub fn create_discovery_publication(
keypair: &NostrKeypair,
discovery_tag: &str,
seal_pubkey: &str,
plaintext: &[u8],
) -> Result<Self> {
let seal_bytes = hex::decode(seal_pubkey)
.map_err(|e| Error::CryptoError(format!("Invalid discovery-seal pubkey: {}", e)))?;
let conversation_key = ConversationKey::derive(&keypair.signing_key, &seal_bytes)?;
let sealed = nip44::encrypt(plaintext, &conversation_key)?;
let tags = vec![
vec!["d".to_string(), discovery_tag.to_string()],
vec!["p".to_string(), discovery_tag.to_string()],
];
Self::sign_with_tags(
keypair,
NOSTR_DISCOVERY_KIND,
tags,
&sealed,
now_unix_secs(),
)
}
pub fn create_discovery_deletion(keypair: &NostrKeypair, discovery_tag: &str) -> Result<Self> {
let coordinate = format!(
"{}:{}:{}",
NOSTR_DISCOVERY_KIND,
keypair.public_key_hex(),
discovery_tag
);
let tags = vec![
vec!["a".to_string(), coordinate],
vec!["k".to_string(), NOSTR_DISCOVERY_KIND.to_string()],
];
Self::sign_with_tags(keypair, NOSTR_DELETION_KIND, tags, "", now_unix_secs())
}
fn sign(
keypair: &NostrKeypair,
kind: u32,
recipient_pubkey_hex: &str,
content: &str,
created_at: i64,
) -> Result<Self> {
let tags = vec![vec!["p".to_string(), recipient_pubkey_hex.to_string()]];
Self::sign_with_tags(keypair, kind, tags, content, created_at)
}
fn sign_with_tags(
keypair: &NostrKeypair,
kind: u32,
tags: Vec<Vec<String>>,
content: &str,
created_at: i64,
) -> Result<Self> {
let pubkey = keypair.public_key_hex().to_string();
let tags_json =
serde_json::to_string(&tags).map_err(|e| Error::SerializationError(e.to_string()))?;
let event_id_bytes = nip01_event_id(&pubkey, created_at, kind as u64, &tags_json, content)?;
let event_id = hex::encode(&event_id_bytes);
let sig = sign_event_id(&keypair.signing_key, &event_id_bytes)?;
Ok(Self {
id: event_id,
pubkey,
created_at,
kind,
tags,
content: content.to_string(),
sig,
})
}
pub fn to_relay_message(&self) -> Result<String> {
let event_json = self.to_event_json()?;
Ok(format!("[\"EVENT\",{}]", event_json))
}
fn to_event_json(&self) -> Result<String> {
let obj = serde_json::json!({
"id": self.id,
"pubkey": self.pubkey,
"created_at": self.created_at,
"kind": self.kind,
"tags": self.tags,
"content": self.content,
"sig": self.sig,
});
serde_json::to_string(&obj).map_err(|e| Error::SerializationError(e.to_string()))
}
}
pub fn create_subscription_message(
pubkey_hex: &str,
subscription_id: &str,
since: i64,
) -> Result<String> {
let filter = serde_json::json!({
"#p": [pubkey_hex],
"kinds": [NOSTR_LEGACY_DM_KIND, NOSTR_GIFT_WRAP_KIND],
"since": since.max(0),
"limit": NOSTR_INITIAL_QUERY_LIMIT
});
let msg = serde_json::json!(["REQ", subscription_id, filter]);
serde_json::to_string(&msg).map_err(|e| Error::SerializationError(e.to_string()))
}
pub(crate) fn unwrap_gift_wrap(
recipient_key: &NostrKeypair,
sender_ephemeral_pubkey_hex: &str,
sealed: &[u8],
) -> Result<Vec<u8>> {
let sender = hex::decode(sender_ephemeral_pubkey_hex)
.map_err(|e| Error::CryptoError(format!("Invalid sender pubkey: {}", e)))?;
let conversation_key = ConversationKey::derive(&recipient_key.signing_key, &sender)?;
nip44::decrypt_bytes(sealed, &conversation_key)
}
pub(crate) fn open_key_package_publication(
peer_derivable_key: &NostrKeypair,
author_pubkey_hex: &str,
sealed: &[u8],
) -> Result<Vec<u8>> {
unwrap_gift_wrap(peer_derivable_key, author_pubkey_hex, sealed)
}
pub(crate) fn create_key_package_query_message(
routing_tag: &str,
subscription_id: &str,
) -> Result<String> {
let filter = serde_json::json!({
"#p": [routing_tag],
"kinds": [NOSTR_KEY_PACKAGE_KIND],
"limit": crate::constants::NOSTR_KEY_PACKAGE_SLOTS
});
let msg = serde_json::json!(["REQ", subscription_id, filter]);
serde_json::to_string(&msg).map_err(|e| Error::SerializationError(e.to_string()))
}
pub(crate) fn open_discovery_record(
discovery_seal_key: &NostrKeypair,
author_pubkey_hex: &str,
sealed: &[u8],
) -> Result<Vec<u8>> {
unwrap_gift_wrap(discovery_seal_key, author_pubkey_hex, sealed)
}
pub(crate) fn event_is_authentic(event: &serde_json::Value) -> bool {
use k256::ecdsa::signature::hazmat::PrehashVerifier;
use k256::schnorr::VerifyingKey;
let (
Some(id),
Some(pubkey),
Some(created_at),
Some(kind),
Some(tags),
Some(content),
Some(sig),
) = (
event.get("id").and_then(|v| v.as_str()),
event.get("pubkey").and_then(|v| v.as_str()),
event.get("created_at").and_then(|v| v.as_i64()),
event.get("kind").and_then(|v| v.as_u64()),
event.get("tags"),
event.get("content").and_then(|v| v.as_str()),
event.get("sig").and_then(|v| v.as_str()),
)
else {
return false;
};
let Ok(pubkey_bytes) = hex::decode(pubkey) else {
return false;
};
if pubkey_bytes.len() != 32 {
return false;
}
let Ok(tags_json) = serde_json::to_string(tags) else {
return false;
};
let Ok(recomputed) = nip01_event_id(pubkey, created_at, kind, &tags_json, content) else {
return false;
};
if !id.eq_ignore_ascii_case(&hex::encode(&recomputed)) {
return false;
}
let Ok(vk) = VerifyingKey::from_bytes(&pubkey_bytes) else {
return false;
};
let Ok(sig_bytes) = hex::decode(sig) else {
return false;
};
let Ok(signature) = k256::schnorr::Signature::try_from(sig_bytes.as_slice()) else {
return false;
};
vk.verify_prehash(&recomputed, &signature).is_ok()
}
pub(crate) fn create_discovery_query_message(
discovery_tag: &str,
subscription_id: &str,
) -> Result<String> {
let filter = serde_json::json!({
"#p": [discovery_tag],
"kinds": [NOSTR_DISCOVERY_KIND],
"limit": crate::constants::NOSTR_DISCOVERY_QUERY_LIMIT
});
let msg = serde_json::json!(["REQ", subscription_id, filter]);
serde_json::to_string(&msg).map_err(|e| Error::SerializationError(e.to_string()))
}
pub(crate) fn is_sealed_payload(data: &[u8]) -> bool {
nip44::looks_like_payload(data)
}
pub(crate) fn nip01_event_id(
pubkey: &str,
created_at: i64,
kind: u64,
tags_json: &str,
content: &str,
) -> Result<Vec<u8>> {
let content_escaped =
serde_json::to_string(content).map_err(|e| Error::SerializationError(e.to_string()))?;
let serialized = format!(
"[0,\"{}\",{},{},{},{}]",
pubkey, created_at, kind, tags_json, content_escaped
);
Ok(Sha256::digest(serialized.as_bytes()).to_vec())
}
fn sign_event_id(signing_key: &SigningKey, event_id_hash: &[u8]) -> Result<String> {
use k256::ecdsa::signature::hazmat::PrehashSigner;
let signature: k256::schnorr::Signature = signing_key
.sign_prehash(event_id_hash)
.map_err(|e| Error::CryptoError(format!("Schnorr signing failed: {}", e)))?;
Ok(hex::encode(signature.to_bytes()))
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
fn addr(label: &str) -> Address {
let digest = Sha256::digest(label.as_bytes());
let mut hash = [0u8; Address::HASH_LEN];
hash.copy_from_slice(&digest[..Address::HASH_LEN]);
Address::from_hash_bytes(hash)
}
fn user(label: &str) -> Username {
label.parse().expect("username should parse")
}
#[test]
fn test_discovery_tag_is_not_the_address_routing_tag() {
for label in ["alice", "bob", "carol"] {
let username = user(label);
let discovery = discovery_tag_for_username(&username).expect("discovery tag");
let address = addr(label);
let routing = routing_tag_for_address(&address).expect("routing tag");
assert_ne!(
discovery, routing,
"the discovery tag must not equal the routing tag for {label}"
);
}
}
#[test]
fn test_discovery_tag_domain_separates_from_the_bare_hash() {
let username = user("alice");
let tag = discovery_tag_for_username(&username).expect("tag");
let bare_scalar = Sha256::digest(b"alice");
let bare = hex::encode(
SigningKey::from_bytes(bare_scalar.as_slice())
.expect("scalar")
.verifying_key()
.to_bytes(),
);
assert_ne!(
tag, bare,
"the discovery tag must not be the undomained hash of the username"
);
}
#[test]
fn test_discovery_seal_key_is_not_the_discovery_tag() {
for label in ["alice", "bob", "a-name-with-dashes"] {
let username = user(label);
assert_ne!(
discovery_seal_keypair_for_username(&username)
.unwrap()
.public_key_hex(),
discovery_tag_for_username(&username).unwrap(),
"the discovery-seal key must not be the discovery tag for {label}"
);
}
}
#[test]
fn test_discovery_seal_key_is_not_the_record_seal_key() {
for label in ["alice", "bob"] {
let username = user(label);
let address = addr(label);
assert_ne!(
discovery_seal_keypair_for_username(&username)
.unwrap()
.public_key_hex(),
record_seal_keypair_for_address(&address)
.unwrap()
.public_key_hex(),
"the discovery-seal key must not be the record-seal key for {label}"
);
}
}
#[test]
fn test_discovery_tag_and_seal_key_are_deterministic() {
let username = user("alice");
assert_eq!(
discovery_tag_for_username(&username).unwrap(),
discovery_tag_for_username(&username).unwrap()
);
assert_eq!(
discovery_seal_keypair_for_username(&username)
.unwrap()
.public_key_hex(),
discovery_seal_keypair_for_username(&username)
.unwrap()
.public_key_hex()
);
}
#[test]
fn test_discovery_tag_is_username_specific() {
assert_ne!(
discovery_tag_for_username(&user("alice")).unwrap(),
discovery_tag_for_username(&user("bob")).unwrap()
);
}
#[test]
fn test_discovery_derivation_golden_values() {
let username = user("alice");
assert_eq!(
hex::encode(Sha256::digest(b"offline-disc-v1:alice")),
"f0fe1d050a281d611fdd951f13e6e6878407ec87a80cd7fa0b42442f44020466"
);
assert_eq!(
discovery_tag_for_username(&username).unwrap(),
"179183d1f394327bb8a244dbe9160dd77bf86f7125caa21c2d2eec6e50267703"
);
assert_eq!(
discovery_seal_keypair_for_username(&username)
.unwrap()
.public_key_hex(),
"a008efac2c8ebe103dc2dfc78acd7890cff5cadb238159eaf615aa7ad9d8994e"
);
}
#[test]
fn test_discovery_publication_is_addressable_at_the_tag() {
let keypair = NostrKeypair::generate_ephemeral().expect("keypair");
let username = user("alice");
let tag = discovery_tag_for_username(&username).expect("tag");
let seal = discovery_seal_keypair_for_username(&username).expect("seal");
let event =
NostrEvent::create_discovery_publication(&keypair, &tag, seal.public_key_hex(), b"{}")
.expect("publication");
assert_eq!(event.kind, NOSTR_DISCOVERY_KIND);
let d_tag = event
.tags
.iter()
.find(|t| t.first().map(String::as_str) == Some("d"))
.expect("d tag");
assert_eq!(d_tag[1], tag, "the d tag must be the discovery tag");
let p_tag = event
.tags
.iter()
.find(|t| t.first().map(String::as_str) == Some("p"))
.expect("p tag");
assert_eq!(p_tag[1], tag);
}
#[test]
fn test_discovery_republication_replaces_rather_than_accumulates() {
let keypair = NostrKeypair::generate_ephemeral().expect("keypair");
let tag = discovery_tag_for_username(&user("alice")).expect("tag");
let seal = discovery_seal_keypair_for_username(&user("alice")).expect("seal");
let first =
NostrEvent::create_discovery_publication(&keypair, &tag, seal.public_key_hex(), b"{}")
.expect("first");
let second = NostrEvent::create_discovery_publication(
&keypair,
&tag,
seal.public_key_hex(),
b"{\"v\":1}",
)
.expect("second");
assert_eq!(first.kind, second.kind);
assert_eq!(first.pubkey, second.pubkey);
let d_of = |e: &NostrEvent| {
e.tags
.iter()
.find(|t| t.first().map(String::as_str) == Some("d"))
.expect("d tag")[1]
.clone()
};
assert_eq!(d_of(&first), d_of(&second));
}
#[test]
fn test_discovery_publication_is_not_backdated() {
let keypair = NostrKeypair::generate_ephemeral().expect("keypair");
let tag = discovery_tag_for_username(&user("alice")).expect("tag");
let seal = discovery_seal_keypair_for_username(&user("alice")).expect("seal");
let before = now_unix_secs();
let event =
NostrEvent::create_discovery_publication(&keypair, &tag, seal.public_key_hex(), b"{}")
.expect("publication");
let after = now_unix_secs();
assert!(
event.created_at >= before && event.created_at <= after,
"created_at {} must sit in [{}, {}]",
event.created_at,
before,
after
);
}
#[test]
fn test_discovery_record_round_trips_through_its_seal() {
let keypair = NostrKeypair::generate_ephemeral().expect("keypair");
let username = user("alice");
let tag = discovery_tag_for_username(&username).expect("tag");
let seal = discovery_seal_keypair_for_username(&username).expect("seal");
let plaintext = b"{\"v\":1,\"username\":\"alice\"}";
let event = NostrEvent::create_discovery_publication(
&keypair,
&tag,
seal.public_key_hex(),
plaintext,
)
.expect("publication");
let sealed = base64::engine::general_purpose::STANDARD
.decode(&event.content)
.expect("content is base64");
let opened = open_discovery_record(&seal, &event.pubkey, &sealed).expect("open");
assert_eq!(opened, plaintext);
}
#[test]
fn test_discovery_seal_is_openable_by_anyone_who_knows_the_username() {
let keypair = NostrKeypair::generate_ephemeral().expect("keypair");
let username = user("alice");
let tag = discovery_tag_for_username(&username).expect("tag");
let publisher_seal = discovery_seal_keypair_for_username(&username).expect("seal");
let event = NostrEvent::create_discovery_publication(
&keypair,
&tag,
publisher_seal.public_key_hex(),
b"public",
)
.expect("publication");
let stranger_seal = discovery_seal_keypair_for_username(&user("alice")).expect("seal");
let sealed = base64::engine::general_purpose::STANDARD
.decode(&event.content)
.expect("base64");
assert_eq!(
open_discovery_record(&stranger_seal, &event.pubkey, &sealed).expect("open"),
b"public"
);
}
#[test]
fn test_discovery_query_names_the_right_kind_and_tag() {
let tag = discovery_tag_for_username(&user("alice")).expect("tag");
let req = create_discovery_query_message(&tag, "sub1").expect("req");
let parsed: serde_json::Value = serde_json::from_str(&req).expect("json");
assert_eq!(parsed[0], "REQ");
assert_eq!(parsed[1], "sub1");
assert_eq!(parsed[2]["kinds"][0], NOSTR_DISCOVERY_KIND);
assert_eq!(parsed[2]["#p"][0], tag);
assert_eq!(
parsed[2]["limit"],
crate::constants::NOSTR_DISCOVERY_QUERY_LIMIT
);
assert!(
parsed[2].get("since").is_none(),
"a discovery query must carry no `since`: a settled claim is old \
and current at the same time"
);
}
#[test]
fn test_discovery_deletion_names_the_addressable_coordinate() {
let keypair = NostrKeypair::generate_ephemeral().expect("keypair");
let tag = discovery_tag_for_username(&user("alice")).expect("tag");
let event = NostrEvent::create_discovery_deletion(&keypair, &tag).expect("deletion");
assert_eq!(event.kind, NOSTR_DELETION_KIND);
let a_tag = event
.tags
.iter()
.find(|t| t.first().map(String::as_str) == Some("a"))
.expect("a tag");
assert_eq!(
a_tag[1],
format!(
"{}:{}:{}",
NOSTR_DISCOVERY_KIND,
keypair.public_key_hex(),
tag
)
);
}
#[test]
fn test_from_install_secret_deterministic() {
let secret = [7u8; 32];
let kp = NostrKeypair::from_install_secret(&secret).unwrap();
assert_eq!(kp.public_key_hex().len(), 64);
let kp2 = NostrKeypair::from_install_secret(&secret).unwrap();
assert_eq!(kp.public_key_hex(), kp2.public_key_hex());
}
#[test]
fn test_different_secrets_different_keys() {
let kp1 = NostrKeypair::from_install_secret(&[1u8; 32]).unwrap();
let kp2 = NostrKeypair::from_install_secret(&[2u8; 32]).unwrap();
assert_ne!(kp1.public_key_hex(), kp2.public_key_hex());
}
#[test]
fn test_from_install_secret_rejects_short_secret() {
assert!(NostrKeypair::from_install_secret(&[0u8; 15]).is_err());
assert!(NostrKeypair::from_install_secret(b"").is_err());
}
#[test]
fn test_signing_key_is_not_derivable_from_the_address() {
let address = addr("alice");
let secret = Sha256::digest(address.to_string().as_bytes());
let kp = NostrKeypair::from_install_secret(secret.as_slice()).unwrap();
assert_ne!(
kp.public_key_hex(),
routing_tag_for_address(&address).unwrap()
);
assert_ne!(
kp.public_key_hex(),
record_seal_keypair_for_address(&address)
.unwrap()
.public_key_hex()
);
}
#[test]
fn test_generate_ephemeral_is_random() {
let kp1 = NostrKeypair::generate_ephemeral().unwrap();
let kp2 = NostrKeypair::generate_ephemeral().unwrap();
assert_ne!(kp1.public_key_hex(), kp2.public_key_hex());
}
#[test]
fn test_generate_install_secret_is_random() {
let s1 = NostrKeypair::generate_install_secret().unwrap();
let s2 = NostrKeypair::generate_install_secret().unwrap();
assert_ne!(*s1, *s2);
assert_ne!(*s1, [0u8; 32]);
}
#[test]
fn test_routing_tag_golden_values() {
assert_eq!(
routing_tag_for_address(&vector_address(
"off1qy4aspkf0u8qptc6rlpn9ra8vw5jd9ereq4cwpfs"
))
.unwrap(),
"2ba510b01e5a0f1a76ed8e66beb430642960e740aedf7d8f1c8b21cb11028fc2"
);
assert_eq!(
routing_tag_for_address(&vector_address(
"off1qxqmvd7clnfvdknrt8nfvvgn5ytsmeu4us5lrr0g"
))
.unwrap(),
"32d26c7b8fbbb9268aba57a45d84a5554cc23e880aef5a7b2ce06e490a1ba35c"
);
assert_eq!(
routing_tag_for_address(&vector_address(
"off1qyv04gxa02f8jpkt8cu06nlk3x0mu35wdqyxt6jq"
))
.unwrap(),
"e7505f56a2de5a5d3946949ab58bde530b452f46f4c11f85130c18e48b7bbd51"
);
}
fn vector_address(literal: &str) -> Address {
literal
.parse::<Address>()
.unwrap_or_else(|e| panic!("golden vector '{literal}' is not a valid address: {e}"))
}
#[test]
fn test_record_seal_key_golden_values() {
assert_eq!(
record_seal_keypair_for_address(&vector_address(
"off1qy4aspkf0u8qptc6rlpn9ra8vw5jd9ereq4cwpfs"
))
.unwrap()
.public_key_hex(),
"62f2835dc8788282d7ce92864fd1819b12b472e209b0116c17a1d06a5f9eebb0"
);
assert_eq!(
record_seal_keypair_for_address(&vector_address(
"off1qxqmvd7clnfvdknrt8nfvvgn5ytsmeu4us5lrr0g"
))
.unwrap()
.public_key_hex(),
"c6c428f62e777b97c29a8e1a8d7fb2aaaa14bf55e5adf3ced4ed30a5c3c27755"
);
}
#[test]
fn test_create_dm_event() {
let sender = NostrKeypair::generate_ephemeral().unwrap();
let recipient_pubkey = routing_tag_for_address(&addr("bob")).unwrap();
let event = NostrEvent::create_dm(&sender, &recipient_pubkey, "dGVzdCBtZXNzYWdl").unwrap();
assert_eq!(event.id.len(), 64); assert_eq!(event.pubkey, sender.public_key_hex());
assert_eq!(event.kind, 4);
assert_eq!(event.tags.len(), 1);
assert_eq!(event.tags[0][0], "p");
assert_eq!(event.tags[0][1], recipient_pubkey);
assert_eq!(event.content, "dGVzdCBtZXNzYWdl");
assert_eq!(event.sig.len(), 128); }
#[test]
fn test_event_id_is_sha256_of_serialization() {
let sender = NostrKeypair::generate_ephemeral().unwrap();
let recipient_pubkey = routing_tag_for_address(&addr("bob")).unwrap();
let event = NostrEvent::create_dm(&sender, &recipient_pubkey, "dGVzdCBtZXNzYWdl").unwrap();
let tags_json = serde_json::to_string(&event.tags).unwrap();
let content_escaped = serde_json::to_string(&event.content).unwrap();
let serialized = format!(
"[0,\"{}\",{},{},{},{}]",
event.pubkey, event.created_at, event.kind, tags_json, content_escaped
);
let expected_id = hex::encode(Sha256::digest(serialized.as_bytes()));
assert_eq!(event.id, expected_id);
}
#[test]
fn test_signature_verification() {
let sender = NostrKeypair::generate_ephemeral().unwrap();
let recipient_pubkey = routing_tag_for_address(&addr("bob")).unwrap();
let event = NostrEvent::create_dm(&sender, &recipient_pubkey, "dGVzdCBtZXNzYWdl").unwrap();
let verifying_key = sender.signing_key.verifying_key();
let event_id_bytes = hex::decode(&event.id).unwrap();
let sig_bytes = hex::decode(&event.sig).unwrap();
let signature = k256::schnorr::Signature::try_from(sig_bytes.as_slice()).unwrap();
use k256::ecdsa::signature::hazmat::PrehashVerifier;
verifying_key
.verify_prehash(&event_id_bytes, &signature)
.expect("Signature should be valid");
}
#[test]
fn test_to_relay_message() {
let sender = NostrKeypair::generate_ephemeral().unwrap();
let recipient_pubkey = routing_tag_for_address(&addr("bob")).unwrap();
let event = NostrEvent::create_dm(&sender, &recipient_pubkey, "dGVzdCBtZXNzYWdl").unwrap();
let relay_msg = event.to_relay_message().unwrap();
assert!(relay_msg.starts_with("[\"EVENT\",{"));
assert!(relay_msg.ends_with("}]"));
assert!(relay_msg.contains(&format!("\"id\":\"{}\"", event.id)));
assert!(relay_msg.contains(&format!("\"sig\":\"{}\"", event.sig)));
}
#[test]
fn test_create_subscription_message() {
let pubkey = routing_tag_for_address(&addr("alice")).unwrap();
let msg = create_subscription_message(&pubkey, "sub123", 1_700_000_000).unwrap();
assert!(msg.starts_with("[\"REQ\",\"sub123\",{"));
assert!(msg.contains(&format!("\"#p\":[\"{}\"]", pubkey)));
assert!(msg.contains(&format!(
"\"kinds\":[{},{}]",
NOSTR_LEGACY_DM_KIND, NOSTR_GIFT_WRAP_KIND
)));
}
#[test]
fn test_subscription_keeps_requesting_the_legacy_dm_kind() {
let pubkey = routing_tag_for_address(&addr("alice")).unwrap();
let msg = create_subscription_message(&pubkey, "sub123", 1_700_000_000).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap();
let kinds: Vec<u64> = parsed[2]["kinds"]
.as_array()
.unwrap()
.iter()
.map(|k| k.as_u64().unwrap())
.collect();
assert!(kinds.contains(&u64::from(NOSTR_LEGACY_DM_KIND)));
assert!(kinds.contains(&u64::from(NOSTR_GIFT_WRAP_KIND)));
}
#[test]
fn test_gift_wrap_is_signed_by_a_single_use_key() {
let bob_tag = routing_tag_for_address(&addr("bob")).unwrap();
let a = NostrEvent::create_gift_wrap(&bob_tag, &bob_tag, b"hello").unwrap();
let b = NostrEvent::create_gift_wrap(&bob_tag, &bob_tag, b"hello").unwrap();
assert_eq!(a.kind, NOSTR_GIFT_WRAP_KIND);
assert_ne!(
a.pubkey, b.pubkey,
"outer key must not repeat across events"
);
assert_ne!(a.content, b.content, "fresh nonce must change the payload");
assert_eq!(a.tags[0], vec!["p".to_string(), bob_tag.clone()]);
}
#[test]
fn test_gift_wrap_round_trips_to_the_recipients_key() {
let bob = NostrKeypair::from_install_secret(&[77u8; 32]).unwrap();
let bob_tag = routing_tag_for_address(&addr("bob")).unwrap();
let event =
NostrEvent::create_gift_wrap(&bob_tag, bob.public_key_hex(), b"payload").unwrap();
let sealed = base64::engine::general_purpose::STANDARD
.decode(&event.content)
.unwrap();
assert_eq!(
unwrap_gift_wrap(&bob, &event.pubkey, &sealed).unwrap(),
b"payload"
);
let bob_derivable = record_seal_keypair_for_address(&addr("bob")).unwrap();
assert!(unwrap_gift_wrap(&bob_derivable, &event.pubkey, &sealed).is_err());
}
#[test]
fn test_bootstrap_wrap_opens_with_the_record_seal_key() {
let bob_tag = routing_tag_for_address(&addr("bob")).unwrap();
let bob_seal = record_seal_keypair_for_address(&addr("bob")).unwrap();
let event =
NostrEvent::create_gift_wrap(&bob_tag, bob_seal.public_key_hex(), b"first contact")
.unwrap();
let sealed = base64::engine::general_purpose::STANDARD
.decode(&event.content)
.unwrap();
assert_eq!(
unwrap_gift_wrap(&bob_seal, &event.pubkey, &sealed).unwrap(),
b"first contact"
);
assert_eq!(
event.tags[0],
vec!["p".to_string(), bob_tag],
"the frame is addressed to the tag even though it is sealed to the key"
);
}
#[test]
fn test_record_seal_key_is_not_the_routing_tag() {
for label in ["alice", "bob", "device1", "a-very-long-user-id-with-dashes"] {
let address = addr(label);
assert_ne!(
record_seal_keypair_for_address(&address)
.unwrap()
.public_key_hex(),
routing_tag_for_address(&address).unwrap(),
"the record-seal key must not be the routing tag for {label}"
);
}
}
#[test]
fn test_record_seal_key_is_deterministic_and_address_specific() {
assert_eq!(
record_seal_keypair_for_address(&addr("alice"))
.unwrap()
.public_key_hex(),
record_seal_keypair_for_address(&addr("alice"))
.unwrap()
.public_key_hex()
);
assert_ne!(
record_seal_keypair_for_address(&addr("alice"))
.unwrap()
.public_key_hex(),
record_seal_keypair_for_address(&addr("bob"))
.unwrap()
.public_key_hex()
);
}
#[test]
fn test_gift_wrap_created_at_is_jittered_into_the_past_only() {
let bob_tag = routing_tag_for_address(&addr("bob")).unwrap();
let mut saw_jitter = false;
for _ in 0..64 {
let now = now_unix_secs();
let event = NostrEvent::create_gift_wrap(&bob_tag, &bob_tag, b"x").unwrap();
assert!(
event.created_at <= now,
"created_at must never be in the future: {} > {}",
event.created_at,
now
);
assert!(
now - event.created_at <= NOSTR_CREATED_AT_JITTER_SECS,
"created_at must stay inside the jitter window the subscription overlaps"
);
if now - event.created_at > 0 {
saw_jitter = true;
}
}
assert!(
saw_jitter,
"timestamps were never jittered across 64 events"
);
}
#[test]
fn test_subscription_filter_bounds_stored_event_replay() {
let pubkey = routing_tag_for_address(&addr("alice")).unwrap();
let msg = create_subscription_message(&pubkey, "sub123", 1_700_000_000).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap();
let filter = &parsed[2];
assert_eq!(
filter["limit"].as_u64(),
Some(NOSTR_INITIAL_QUERY_LIMIT as u64),
"REQ filter must carry a limit: {}",
msg
);
}
#[test]
fn test_subscription_filter_carries_since() {
let pubkey = routing_tag_for_address(&addr("alice")).unwrap();
let msg = create_subscription_message(&pubkey, "sub123", 1_700_000_000).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap();
assert_eq!(
parsed[2]["since"].as_i64(),
Some(1_700_000_000),
"REQ filter must carry the caller's since: {}",
msg
);
}
#[test]
fn test_subscription_since_is_never_negative() {
let pubkey = routing_tag_for_address(&addr("alice")).unwrap();
let msg = create_subscription_message(&pubkey, "sub123", -42).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap();
assert_eq!(parsed[2]["since"].as_i64(), Some(0));
}
fn event_value(event: &NostrEvent) -> serde_json::Value {
serde_json::from_str(&event.to_event_json().expect("event json")).expect("value")
}
fn restamp_id(event: &mut serde_json::Value) {
let tags_json = serde_json::to_string(&event["tags"]).expect("tags");
let id = hex::encode(
nip01_event_id(
event["pubkey"].as_str().expect("pubkey"),
event["created_at"].as_i64().expect("created_at"),
event["kind"].as_u64().expect("kind"),
&tags_json,
event["content"].as_str().expect("content"),
)
.expect("id"),
);
event["id"] = serde_json::Value::String(id);
}
fn discovery_event(keypair: &NostrKeypair, content: &[u8]) -> NostrEvent {
let username = user("alice");
let tag = discovery_tag_for_username(&username).expect("tag");
let seal = discovery_seal_keypair_for_username(&username).expect("seal");
NostrEvent::create_discovery_publication(keypair, &tag, seal.public_key_hex(), content)
.expect("event")
}
#[test]
fn test_event_is_authentic_accepts_a_genuine_event() {
let keypair = NostrKeypair::generate_ephemeral().expect("keypair");
let event = discovery_event(&keypair, b"{}");
assert!(event_is_authentic(&event_value(&event)));
}
#[test]
fn test_event_is_authentic_refuses_a_well_formed_signature_by_another_key() {
let attacker = NostrKeypair::generate_ephemeral().expect("attacker");
let victim = NostrKeypair::generate_ephemeral().expect("victim");
let mut forged = event_value(&discovery_event(&attacker, b"{\"v\":1,\"retracted\":true}"));
forged["pubkey"] = serde_json::Value::String(victim.public_key_hex().to_string());
restamp_id(&mut forged);
assert!(
!event_is_authentic(&forged),
"an event standing under a key that did not sign it must be refused, \
or one hostile relay can retract any name it knows"
);
}
#[test]
fn test_event_is_authentic_refuses_an_unparseable_signature() {
let keypair = NostrKeypair::generate_ephemeral().expect("keypair");
let mut forged = event_value(&discovery_event(&keypair, b"{}"));
forged["sig"] = serde_json::Value::String("0".repeat(128));
restamp_id(&mut forged);
assert!(!event_is_authentic(&forged));
}
#[test]
fn test_event_is_authentic_refuses_a_stale_id() {
let keypair = NostrKeypair::generate_ephemeral().expect("keypair");
let mut forged = event_value(&discovery_event(&keypair, b"{}"));
forged["created_at"] = serde_json::Value::from(forged["created_at"].as_i64().unwrap() + 1);
assert!(!event_is_authentic(&forged));
}
#[test]
fn test_event_is_authentic_refuses_swapped_content_under_a_restamped_id() {
let keypair = NostrKeypair::generate_ephemeral().expect("keypair");
let mut forged = event_value(&discovery_event(&keypair, b"{}"));
forged["content"] = serde_json::Value::String("substituted".to_string());
restamp_id(&mut forged);
assert!(!event_is_authentic(&forged));
}
#[test]
fn test_event_is_authentic_refuses_malformed_pubkeys_without_panicking() {
let keypair = NostrKeypair::generate_ephemeral().expect("keypair");
for pubkey in ["", "zz", &"ab".repeat(31), &"ab".repeat(33)] {
let mut forged = event_value(&discovery_event(&keypair, b"{}"));
forged["pubkey"] = serde_json::Value::String(pubkey.to_string());
restamp_id(&mut forged);
assert!(
!event_is_authentic(&forged),
"a {}-character pubkey must be refused",
pubkey.len()
);
}
}
#[test]
fn test_event_is_authentic_refuses_events_missing_fields() {
let keypair = NostrKeypair::generate_ephemeral().expect("keypair");
for field in [
"id",
"pubkey",
"created_at",
"kind",
"tags",
"content",
"sig",
] {
let mut forged = event_value(&discovery_event(&keypair, b"{}"));
forged
.as_object_mut()
.expect("object")
.remove(field)
.expect("field present");
assert!(
!event_is_authentic(&forged),
"an event with no {} must be refused",
field
);
}
}
}