notedeck

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

commit 0be6a8947ff15ca4febefd835910273bfae976aa
parent a3c3016121e8756d5e8d15d9a7570c0e7962000a
Author: William Casarin <jb55@jb55.com>
Date:   Tue, 17 Feb 2026 12:00:49 -0800

fix PNS key derivation: use HKDF-Extract only, not Extract+Expand

The old code called Hkdf::new() then hk.expand(), which runs both
HKDF-Extract and HKDF-Expand. The nostrdb C code only does raw
HMAC-SHA256 (equivalent to HKDF-Extract). The extra Expand step
produced different keys, so ndb couldn't match the PNS pubkey and
1080 events were never auto-unwrapped.

Add a test vector from nostrdb's test_pns_unwrap (device key 0x02)
to ensure the derived PNS pubkey matches the C implementation.

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

Diffstat:
Mcrates/enostr/src/pns.rs | 47++++++++++++++++++++++++++++++++++++-----------
1 file changed, 36 insertions(+), 11 deletions(-)

diff --git a/crates/enostr/src/pns.rs b/crates/enostr/src/pns.rs @@ -69,18 +69,15 @@ pub fn decrypt(conversation_key: &ConversationKey, content: &str) -> Result<Stri String::from_utf8(plaintext).map_err(PnsError::Utf8) } -/// HKDF-Extract: HMAC-SHA256(salt, ikm) → 32-byte PRK. +/// HMAC-SHA256(key=salt, msg=ikm) → 32-byte key. +/// +/// This matches the nostrdb C implementation which uses raw HMAC-SHA256 +/// (i.e. HKDF-Extract only, without HKDF-Expand). 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 + let (prk, _) = Hkdf::<Sha256>::extract(Some(salt), ikm); + let mut out = [0u8; 32]; + out.copy_from_slice(&prk); + out } /// Derive a secp256k1 keypair from 32 bytes of key material. @@ -186,6 +183,34 @@ mod tests { } #[test] + fn test_matches_nostrdb_c_test_vector() { + // Same device key as nostrdb's test_pns_unwrap in test.c: + // unsigned char device_sec[32] = {0,...,0,2}; + let mut device_key = [0u8; 32]; + device_key[31] = 0x02; + + let keys = derive_pns_keys(&device_key); + + // The C test expects PNS pubkey: + // "fa22d53e9d38ca7af1e66dcf88f5fb2444368df6bd16580b5827c8cfbc622d4e" + let expected_pns_pubkey = + "fa22d53e9d38ca7af1e66dcf88f5fb2444368df6bd16580b5827c8cfbc622d4e"; + let actual_pns_pubkey = hex::encode(keys.keypair.pubkey.bytes()); + assert_eq!( + actual_pns_pubkey, expected_pns_pubkey, + "PNS pubkey must match nostrdb C implementation" + ); + + // Also verify device pubkey matches (sanity check): + // c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5 + let device_sk = nostr::SecretKey::from_slice(&device_key).unwrap(); + let (device_xopk, _) = device_sk.x_only_public_key(&nostr::SECP256K1); + let expected_device_pubkey = + "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + assert_eq!(hex::encode(device_xopk.serialize()), expected_device_pubkey); + } + + #[test] fn test_encrypt_produces_different_ciphertext() { // NIP-44 uses random nonce, so encrypting same plaintext twice // should produce different ciphertext