notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit bb68dd10dda9405083b2d601b34e5505739c0dc7
parent 00e1437fc22acc4ddc98ef9ccfca03a0a3d5a991
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 16 Feb 2026 14:40:42 -0800

enostr: add NIP-PNS (Private Note Storage) crypto module

Implements deterministic key derivation and NIP-44 v2 encryption for
kind-1080 PNS events. Used for encrypting AI conversation events
before publishing to relays for remote session control.

- derive_pns_keys(): HKDF-based key derivation from device secret key
- encrypt()/decrypt(): NIP-44 v2 with pre-derived conversation key
- 5 tests covering determinism, isolation, and round-trip

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
MCargo.lock | 12++++++++++++
MCargo.toml | 1+
Mcrates/enostr/Cargo.toml | 7+++++--
Mcrates/enostr/src/lib.rs | 1+
Acrates/enostr/src/pns.rs | 205+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 224 insertions(+), 2 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1845,16 +1845,19 @@ checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" name = "enostr" version = "0.3.0" dependencies = [ + "base64 0.22.1", "bech32", "ewebsock", "hashbrown 0.15.4", "hex", + "hkdf", "mio", "nostr 0.37.0", "nostrdb", "serde", "serde_derive", "serde_json", + "sha2", "thiserror 2.0.12", "tokenator", "tokio", @@ -2731,6 +2734,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" [[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] name = "hmac" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -97,6 +97,7 @@ url = "2.5.2" urlencoding = "2.1.3" uuid = { version = "1.10.0", features = ["v4"] } sha2 = "0.10.8" +hkdf = "0.12.4" bincode = "1.3.3" mime_guess = "2.0.5" pretty_assertions = "1.4.1" diff --git a/crates/enostr/Cargo.toml b/crates/enostr/Cargo.toml @@ -20,4 +20,7 @@ url = { workspace = true } mio = { workspace = true } tokio = { workspace = true } tokenator = { workspace = true } -hashbrown = { workspace = true } -\ No newline at end of file +hashbrown = { workspace = true } +hkdf = { workspace = true } +sha2 = { workspace = true } +base64 = { workspace = true } +\ No newline at end of file diff --git a/crates/enostr/src/lib.rs b/crates/enostr/src/lib.rs @@ -3,6 +3,7 @@ mod error; mod filter; mod keypair; mod note; +pub mod pns; mod profile; mod pubkey; mod relay; diff --git a/crates/enostr/src/pns.rs b/crates/enostr/src/pns.rs @@ -0,0 +1,205 @@ +//! NIP-PNS: Private Note Storage +//! +//! Deterministic key derivation and encryption for storing private nostr +//! events on relays. Only the owner of the device key can publish and +//! decrypt PNS events (kind 1080). +//! +//! Key derivation: +//! pns_key = hkdf_extract(ikm=device_key, salt="nip-pns") +//! pns_keypair = derive_secp256k1_keypair(pns_key) +//! pns_nip44_key = hkdf_extract(ikm=pns_key, salt="nip44-v2") + +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use hkdf::Hkdf; +use nostr::nips::nip44::v2::{self, ConversationKey}; +use sha2::Sha256; + +use crate::{FullKeypair, Pubkey}; + +/// Kind number for PNS events. +pub const PNS_KIND: u32 = 1080; + +/// Salt used for deriving pns_key from the device key. +const PNS_SALT: &[u8] = b"nip-pns"; + +/// Salt used for deriving the NIP-44 symmetric key from pns_key. +const NIP44_SALT: &[u8] = b"nip44-v2"; + +/// Derived PNS keys — everything needed to create and decrypt PNS events. +pub struct PnsKeys { + /// Keypair for signing kind-1080 events (derived from pns_key). + pub keypair: FullKeypair, + /// NIP-44 conversation key for encrypting/decrypting content. + pub conversation_key: ConversationKey, +} + +/// Derive all PNS keys from a device secret key. +/// +/// This is deterministic: the same device key always produces the same +/// PNS keypair and encryption key. +pub fn derive_pns_keys(device_key: &[u8; 32]) -> PnsKeys { + let pns_key = hkdf_extract(device_key, PNS_SALT); + let keypair = keypair_from_bytes(&pns_key); + let nip44_key = hkdf_extract(&pns_key, NIP44_SALT); + let conversation_key = ConversationKey::new(nip44_key); + + PnsKeys { + keypair, + conversation_key, + } +} + +/// Encrypt an inner event JSON string for PNS storage. +/// +/// Returns base64-encoded NIP-44 v2 ciphertext suitable for the +/// `content` field of a kind-1080 event. +pub fn encrypt(conversation_key: &ConversationKey, inner_json: &str) -> Result<String, PnsError> { + let payload = + v2::encrypt_to_bytes(conversation_key, inner_json).map_err(PnsError::Encrypt)?; + Ok(BASE64.encode(payload)) +} + +/// Decrypt a PNS event's content field back to the inner event JSON. +/// +/// Takes base64-encoded NIP-44 v2 ciphertext from a kind-1080 event. +pub fn decrypt(conversation_key: &ConversationKey, content: &str) -> Result<String, PnsError> { + let payload = BASE64.decode(content).map_err(PnsError::Base64)?; + let plaintext = v2::decrypt_to_bytes(conversation_key, &payload).map_err(PnsError::Decrypt)?; + String::from_utf8(plaintext).map_err(PnsError::Utf8) +} + +/// HKDF-Extract: HMAC-SHA256(salt, ikm) → 32-byte PRK. +fn hkdf_extract(ikm: &[u8; 32], salt: &[u8]) -> [u8; 32] { + let hk = Hkdf::<Sha256>::new(Some(salt), ikm); + let mut prk = [0u8; 32]; + // HKDF extract output is the PRK itself. We use expand with empty + // info to get the 32-byte output matching the spec's hkdf_extract. + // + // Note: Hkdf::new() does extract internally. The PRK is stored in + // the Hkdf struct. We extract it via expand with empty info. + hk.expand(&[], &mut prk) + .expect("32 bytes is valid for HMAC-SHA256"); + prk +} + +/// Derive a secp256k1 keypair from 32 bytes of key material. +fn keypair_from_bytes(key: &[u8; 32]) -> FullKeypair { + let secret_key = + nostr::SecretKey::from_slice(key).expect("32 bytes of HKDF output is a valid secret key"); + let (xopk, _) = secret_key.x_only_public_key(&nostr::SECP256K1); + FullKeypair { + pubkey: Pubkey::new(xopk.serialize()), + secret_key, + } +} + +#[derive(Debug)] +pub enum PnsError { + Encrypt(nostr::nips::nip44::Error), + Decrypt(nostr::nips::nip44::Error), + Base64(base64::DecodeError), + Utf8(std::string::FromUtf8Error), +} + +impl std::fmt::Display for PnsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PnsError::Encrypt(e) => write!(f, "PNS encrypt failed: {e}"), + PnsError::Decrypt(e) => write!(f, "PNS decrypt failed: {e}"), + PnsError::Base64(e) => write!(f, "PNS base64 decode failed: {e}"), + PnsError::Utf8(e) => write!(f, "PNS decrypted content is not UTF-8: {e}"), + } + } +} + +impl std::error::Error for PnsError {} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_device_key() -> [u8; 32] { + // Deterministic test key + let mut key = [0u8; 32]; + key[0] = 0x01; + key[31] = 0xff; + key + } + + #[test] + fn test_derive_pns_keys_deterministic() { + let dk = test_device_key(); + let keys1 = derive_pns_keys(&dk); + let keys2 = derive_pns_keys(&dk); + + assert_eq!(keys1.keypair.pubkey, keys2.keypair.pubkey); + assert_eq!( + keys1.conversation_key.as_bytes(), + keys2.conversation_key.as_bytes() + ); + } + + #[test] + fn test_pns_pubkey_differs_from_device_pubkey() { + let dk = test_device_key(); + let pns = derive_pns_keys(&dk); + + // Device pubkey + let device_sk = nostr::SecretKey::from_slice(&dk).unwrap(); + let (device_xopk, _) = device_sk.x_only_public_key(&nostr::SECP256K1); + let device_pubkey = Pubkey::new(device_xopk.serialize()); + + // PNS pubkey should be different (derived via HKDF) + assert_ne!(pns.keypair.pubkey, device_pubkey); + } + + #[test] + fn test_encrypt_decrypt_roundtrip() { + let dk = test_device_key(); + let keys = derive_pns_keys(&dk); + + let inner = r#"{"kind":1,"pubkey":"abc","content":"hello","tags":[],"created_at":0}"#; + let encrypted = encrypt(&keys.conversation_key, inner).unwrap(); + + // Should be base64 + assert!(BASE64.decode(&encrypted).is_ok()); + + let decrypted = decrypt(&keys.conversation_key, &encrypted).unwrap(); + assert_eq!(decrypted, inner); + } + + #[test] + fn test_different_keys_cannot_decrypt() { + let dk1 = test_device_key(); + let mut dk2 = test_device_key(); + dk2[0] = 0x02; + + let keys1 = derive_pns_keys(&dk1); + let keys2 = derive_pns_keys(&dk2); + + let inner = r#"{"content":"secret"}"#; + let encrypted = encrypt(&keys1.conversation_key, inner).unwrap(); + + // Different key should fail to decrypt + assert!(decrypt(&keys2.conversation_key, &encrypted).is_err()); + } + + #[test] + fn test_encrypt_produces_different_ciphertext() { + // NIP-44 uses random nonce, so encrypting same plaintext twice + // should produce different ciphertext + let dk = test_device_key(); + let keys = derive_pns_keys(&dk); + + let inner = r#"{"content":"hello"}"#; + let enc1 = encrypt(&keys.conversation_key, inner).unwrap(); + let enc2 = encrypt(&keys.conversation_key, inner).unwrap(); + + assert_ne!(enc1, enc2); + + // But both should decrypt to the same thing + assert_eq!(decrypt(&keys.conversation_key, &enc1).unwrap(), inner); + assert_eq!(decrypt(&keys.conversation_key, &enc2).unwrap(), inner); + } +}