notedeck

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

pns.rs (8089B)


      1 //! NIP-PNS: Private Note Storage
      2 //!
      3 //! Deterministic key derivation and encryption for storing private nostr
      4 //! events on relays. Only the owner of the device key can publish and
      5 //! decrypt PNS events (kind 1080).
      6 //!
      7 //! Key derivation:
      8 //!   pns_key      = hkdf_extract(ikm=device_key, salt="nip-pns")
      9 //!   pns_keypair  = derive_secp256k1_keypair(pns_key)
     10 //!   pns_nip44_key = hkdf_extract(ikm=pns_key, salt="nip44-v2")
     11 
     12 use base64::engine::general_purpose::STANDARD as BASE64;
     13 use base64::Engine;
     14 use hkdf::Hkdf;
     15 use nostr::nips::nip44::v2::{self, ConversationKey};
     16 use sha2::Sha256;
     17 
     18 use crate::{FullKeypair, Pubkey};
     19 
     20 /// Kind number for PNS events.
     21 pub const PNS_KIND: u32 = 1080;
     22 
     23 /// Salt used for deriving pns_key from the device key.
     24 const PNS_SALT: &[u8] = b"nip-pns";
     25 
     26 /// Salt used for deriving the NIP-44 symmetric key from pns_key.
     27 const NIP44_SALT: &[u8] = b"nip44-v2";
     28 
     29 /// Derived PNS keys — everything needed to create and decrypt PNS events.
     30 pub struct PnsKeys {
     31     /// Keypair for signing kind-1080 events (derived from pns_key).
     32     pub keypair: FullKeypair,
     33     /// NIP-44 conversation key for encrypting/decrypting content.
     34     pub conversation_key: ConversationKey,
     35 }
     36 
     37 /// Derive all PNS keys from a device secret key.
     38 ///
     39 /// This is deterministic: the same device key always produces the same
     40 /// PNS keypair and encryption key.
     41 pub fn derive_pns_keys(device_key: &[u8; 32]) -> PnsKeys {
     42     let pns_key = hkdf_extract(device_key, PNS_SALT);
     43     let keypair = keypair_from_bytes(&pns_key);
     44     let nip44_key = hkdf_extract(&pns_key, NIP44_SALT);
     45     let conversation_key = ConversationKey::new(nip44_key);
     46 
     47     PnsKeys {
     48         keypair,
     49         conversation_key,
     50     }
     51 }
     52 
     53 /// Encrypt an inner event JSON string for PNS storage.
     54 ///
     55 /// Returns base64-encoded NIP-44 v2 ciphertext suitable for the
     56 /// `content` field of a kind-1080 event.
     57 pub fn encrypt(conversation_key: &ConversationKey, inner_json: &str) -> Result<String, PnsError> {
     58     let payload = v2::encrypt_to_bytes(conversation_key, inner_json).map_err(PnsError::Encrypt)?;
     59     Ok(BASE64.encode(payload))
     60 }
     61 
     62 /// Decrypt a PNS event's content field back to the inner event JSON.
     63 ///
     64 /// Takes base64-encoded NIP-44 v2 ciphertext from a kind-1080 event.
     65 pub fn decrypt(conversation_key: &ConversationKey, content: &str) -> Result<String, PnsError> {
     66     let payload = BASE64.decode(content).map_err(PnsError::Base64)?;
     67     let plaintext = v2::decrypt_to_bytes(conversation_key, &payload).map_err(PnsError::Decrypt)?;
     68     String::from_utf8(plaintext).map_err(PnsError::Utf8)
     69 }
     70 
     71 /// HMAC-SHA256(key=salt, msg=ikm) → 32-byte key.
     72 ///
     73 /// This matches the nostrdb C implementation which uses raw HMAC-SHA256
     74 /// (i.e. HKDF-Extract only, without HKDF-Expand).
     75 fn hkdf_extract(ikm: &[u8; 32], salt: &[u8]) -> [u8; 32] {
     76     let (prk, _) = Hkdf::<Sha256>::extract(Some(salt), ikm);
     77     let mut out = [0u8; 32];
     78     out.copy_from_slice(&prk);
     79     out
     80 }
     81 
     82 /// Derive a secp256k1 keypair from 32 bytes of key material.
     83 fn keypair_from_bytes(key: &[u8; 32]) -> FullKeypair {
     84     let secret_key =
     85         nostr::SecretKey::from_slice(key).expect("32 bytes of HKDF output is a valid secret key");
     86     let (xopk, _) = secret_key.x_only_public_key(&nostr::SECP256K1);
     87     FullKeypair {
     88         pubkey: Pubkey::new(xopk.serialize()),
     89         secret_key,
     90     }
     91 }
     92 
     93 #[derive(Debug)]
     94 pub enum PnsError {
     95     Encrypt(nostr::nips::nip44::Error),
     96     Decrypt(nostr::nips::nip44::Error),
     97     Base64(base64::DecodeError),
     98     Utf8(std::string::FromUtf8Error),
     99 }
    100 
    101 impl std::fmt::Display for PnsError {
    102     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    103         match self {
    104             PnsError::Encrypt(e) => write!(f, "PNS encrypt failed: {e}"),
    105             PnsError::Decrypt(e) => write!(f, "PNS decrypt failed: {e}"),
    106             PnsError::Base64(e) => write!(f, "PNS base64 decode failed: {e}"),
    107             PnsError::Utf8(e) => write!(f, "PNS decrypted content is not UTF-8: {e}"),
    108         }
    109     }
    110 }
    111 
    112 impl std::error::Error for PnsError {}
    113 
    114 #[cfg(test)]
    115 mod tests {
    116     use super::*;
    117 
    118     fn test_device_key() -> [u8; 32] {
    119         // Deterministic test key
    120         let mut key = [0u8; 32];
    121         key[0] = 0x01;
    122         key[31] = 0xff;
    123         key
    124     }
    125 
    126     #[test]
    127     fn test_derive_pns_keys_deterministic() {
    128         let dk = test_device_key();
    129         let keys1 = derive_pns_keys(&dk);
    130         let keys2 = derive_pns_keys(&dk);
    131 
    132         assert_eq!(keys1.keypair.pubkey, keys2.keypair.pubkey);
    133         assert_eq!(
    134             keys1.conversation_key.as_bytes(),
    135             keys2.conversation_key.as_bytes()
    136         );
    137     }
    138 
    139     #[test]
    140     fn test_pns_pubkey_differs_from_device_pubkey() {
    141         let dk = test_device_key();
    142         let pns = derive_pns_keys(&dk);
    143 
    144         // Device pubkey
    145         let device_sk = nostr::SecretKey::from_slice(&dk).unwrap();
    146         let (device_xopk, _) = device_sk.x_only_public_key(&nostr::SECP256K1);
    147         let device_pubkey = Pubkey::new(device_xopk.serialize());
    148 
    149         // PNS pubkey should be different (derived via HKDF)
    150         assert_ne!(pns.keypair.pubkey, device_pubkey);
    151     }
    152 
    153     #[test]
    154     fn test_encrypt_decrypt_roundtrip() {
    155         let dk = test_device_key();
    156         let keys = derive_pns_keys(&dk);
    157 
    158         let inner = r#"{"kind":1,"pubkey":"abc","content":"hello","tags":[],"created_at":0}"#;
    159         let encrypted = encrypt(&keys.conversation_key, inner).unwrap();
    160 
    161         // Should be base64
    162         assert!(BASE64.decode(&encrypted).is_ok());
    163 
    164         let decrypted = decrypt(&keys.conversation_key, &encrypted).unwrap();
    165         assert_eq!(decrypted, inner);
    166     }
    167 
    168     #[test]
    169     fn test_different_keys_cannot_decrypt() {
    170         let dk1 = test_device_key();
    171         let mut dk2 = test_device_key();
    172         dk2[0] = 0x02;
    173 
    174         let keys1 = derive_pns_keys(&dk1);
    175         let keys2 = derive_pns_keys(&dk2);
    176 
    177         let inner = r#"{"content":"secret"}"#;
    178         let encrypted = encrypt(&keys1.conversation_key, inner).unwrap();
    179 
    180         // Different key should fail to decrypt
    181         assert!(decrypt(&keys2.conversation_key, &encrypted).is_err());
    182     }
    183 
    184     #[test]
    185     fn test_matches_nostrdb_c_test_vector() {
    186         // Same device key as nostrdb's test_pns_unwrap in test.c:
    187         //   unsigned char device_sec[32] = {0,...,0,2};
    188         let mut device_key = [0u8; 32];
    189         device_key[31] = 0x02;
    190 
    191         let keys = derive_pns_keys(&device_key);
    192 
    193         // The C test expects PNS pubkey:
    194         //   "fa22d53e9d38ca7af1e66dcf88f5fb2444368df6bd16580b5827c8cfbc622d4e"
    195         let expected_pns_pubkey =
    196             "fa22d53e9d38ca7af1e66dcf88f5fb2444368df6bd16580b5827c8cfbc622d4e";
    197         let actual_pns_pubkey = hex::encode(keys.keypair.pubkey.bytes());
    198         assert_eq!(
    199             actual_pns_pubkey, expected_pns_pubkey,
    200             "PNS pubkey must match nostrdb C implementation"
    201         );
    202 
    203         // Also verify device pubkey matches (sanity check):
    204         //   c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5
    205         let device_sk = nostr::SecretKey::from_slice(&device_key).unwrap();
    206         let (device_xopk, _) = device_sk.x_only_public_key(&nostr::SECP256K1);
    207         let expected_device_pubkey =
    208             "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5";
    209         assert_eq!(hex::encode(device_xopk.serialize()), expected_device_pubkey);
    210     }
    211 
    212     #[test]
    213     fn test_encrypt_produces_different_ciphertext() {
    214         // NIP-44 uses random nonce, so encrypting same plaintext twice
    215         // should produce different ciphertext
    216         let dk = test_device_key();
    217         let keys = derive_pns_keys(&dk);
    218 
    219         let inner = r#"{"content":"hello"}"#;
    220         let enc1 = encrypt(&keys.conversation_key, inner).unwrap();
    221         let enc2 = encrypt(&keys.conversation_key, inner).unwrap();
    222 
    223         assert_ne!(enc1, enc2);
    224 
    225         // But both should decrypt to the same thing
    226         assert_eq!(decrypt(&keys.conversation_key, &enc1).unwrap(), inner);
    227         assert_eq!(decrypt(&keys.conversation_key, &enc2).unwrap(), inner);
    228     }
    229 }