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;
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 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 const NOSTR_GIFT_WRAP_KIND: u32 = 1059;
pub const NOSTR_LEGACY_DM_KIND: u32 = 4;
pub const NOSTR_KEY_PACKAGE_KIND: u32 = 30443;
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(),
)
}
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 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
);
let event_id_bytes = Sha256::digest(serialized.as_bytes());
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 is_sealed_payload(data: &[u8]) -> bool {
nip44::looks_like_payload(data)
}
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)
}
#[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));
}
}