mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
414 lines
16 KiB
Rust
414 lines
16 KiB
Rust
//! NIP-AB HKDF-SHA256 key derivation primitives.
|
|
//!
|
|
//! All functions are pure (no I/O, no side effects) and operate on fixed-size
|
|
//! `[u8; 32]` arrays. The underlying HKDF implementation is
|
|
//! [`nostr::util::hkdf`], which uses `bitcoin::hashes` internally.
|
|
//!
|
|
//! # Derivation overview
|
|
//!
|
|
//! ```text
|
|
//! session_secret (32 bytes, random)
|
|
//! │
|
|
//! ├─► derive_session_id → session_id (HKDF, salt=[], info="nostr-pair-session-id")
|
|
//! │
|
|
//! ├─► derive_sas(ecdh_shared, …)
|
|
//! │ ├─ sas_input (HKDF, salt=session_secret, info="nostr-pair-sas-v1")
|
|
//! │ └─ sas_code = be_u32(sas_input[0..4]) % 1_000_000
|
|
//! │
|
|
//! └─► derive_transcript_hash(session_id, src_pk, tgt_pk, sas_input, …)
|
|
//! └─ transcript_hash (HKDF, salt=session_secret,
|
|
//! info="nostr-pair-transcript-v1")
|
|
//! ```
|
|
|
|
use nostr::hashes::Hash as _;
|
|
use nostr::util::hkdf;
|
|
|
|
const INFO_SESSION_ID: &[u8] = b"nostr-pair-session-id";
|
|
const INFO_SAS: &[u8] = b"nostr-pair-sas-v1";
|
|
const INFO_TRANSCRIPT: &[u8] = b"nostr-pair-transcript-v1";
|
|
|
|
/// Run HKDF-SHA256(IKM=`ikm`, salt=`salt`, info=`info`) and return 32 bytes.
|
|
///
|
|
/// Uses `nostr::util::hkdf::{extract, expand}` directly so we don't pull in
|
|
/// an extra `hkdf` crate dependency.
|
|
fn hkdf32(salt: &[u8], ikm: &[u8], info: &[u8]) -> [u8; 32] {
|
|
let prk = hkdf::extract(salt, ikm);
|
|
let okm = hkdf::expand(&prk.to_byte_array(), info, 32);
|
|
// HKDF-Expand with L=32 and SHA-256 (HashLen=32) always produces exactly
|
|
// 32 bytes (one iteration, truncated to L). Copy into a fixed-size array
|
|
// without expect/unwrap.
|
|
let mut out = [0u8; 32];
|
|
out.copy_from_slice(&okm[..32]);
|
|
out
|
|
}
|
|
|
|
/// Derive the session ID from the session secret.
|
|
///
|
|
/// ```text
|
|
/// session_id = HKDF-SHA256(IKM=session_secret, salt=[], info="nostr-pair-session-id", L=32)
|
|
/// ```
|
|
///
|
|
/// The session ID is safe to share publicly (e.g., in the QR code or as a
|
|
/// Nostr event tag). It uniquely identifies the pairing session without
|
|
/// revealing the secret.
|
|
pub fn derive_session_id(session_secret: &[u8; 32]) -> [u8; 32] {
|
|
hkdf32(b"", session_secret, INFO_SESSION_ID)
|
|
}
|
|
|
|
/// Derive the Short Authentication String (SAS) code and the raw SAS input.
|
|
///
|
|
/// ```text
|
|
/// sas_input = HKDF-SHA256(IKM=ecdh_shared, salt=session_secret, info="nostr-pair-sas-v1", L=32)
|
|
/// sas_code = be_u32(sas_input[0..4]) mod 1_000_000
|
|
/// ```
|
|
///
|
|
/// Returns `(sas_code, sas_input)`. The caller needs `sas_input` to compute
|
|
/// the transcript hash — see [`derive_transcript_hash`].
|
|
///
|
|
/// `ecdh_shared` is the raw 32-byte x-coordinate from
|
|
/// `nostr::util::generate_shared_key(own_secret, other_pubkey)`.
|
|
pub fn derive_sas(ecdh_shared: &[u8; 32], session_secret: &[u8; 32]) -> (u32, [u8; 32]) {
|
|
let sas_input = hkdf32(session_secret, ecdh_shared, INFO_SAS);
|
|
let sas_code =
|
|
u32::from_be_bytes([sas_input[0], sas_input[1], sas_input[2], sas_input[3]]) % 1_000_000;
|
|
(sas_code, sas_input)
|
|
}
|
|
|
|
/// Derive the transcript hash that binds all session parameters together.
|
|
///
|
|
/// ```text
|
|
/// transcript = session_id ‖ source_pubkey ‖ target_pubkey ‖ sas_input (128 bytes)
|
|
/// transcript_hash = HKDF-SHA256(IKM=transcript, salt=session_secret,
|
|
/// info="nostr-pair-transcript-v1", L=32)
|
|
/// ```
|
|
///
|
|
/// Both parties must independently compute this value and compare it before
|
|
/// exchanging the actual payload. A mismatch means the session is compromised.
|
|
///
|
|
/// `sas_input` is the second return value of [`derive_sas`].
|
|
pub fn derive_transcript_hash(
|
|
session_id: &[u8; 32],
|
|
source_pubkey: &[u8; 32],
|
|
target_pubkey: &[u8; 32],
|
|
sas_input: &[u8; 32],
|
|
session_secret: &[u8; 32],
|
|
) -> [u8; 32] {
|
|
// Concatenate into a 128-byte transcript.
|
|
let mut transcript = [0u8; 128];
|
|
transcript[0..32].copy_from_slice(session_id);
|
|
transcript[32..64].copy_from_slice(source_pubkey);
|
|
transcript[64..96].copy_from_slice(target_pubkey);
|
|
transcript[96..128].copy_from_slice(sas_input);
|
|
|
|
hkdf32(session_secret, &transcript, INFO_TRANSCRIPT)
|
|
}
|
|
|
|
/// Format a SAS code as a zero-padded 6-digit string.
|
|
///
|
|
/// # Examples
|
|
/// ```
|
|
/// use buzz_core::pairing::crypto::format_sas;
|
|
/// assert_eq!(format_sas(291), "000291");
|
|
/// assert_eq!(format_sas(47291), "047291");
|
|
/// assert_eq!(format_sas(999999), "999999");
|
|
/// assert_eq!(format_sas(0), "000000");
|
|
/// ```
|
|
pub fn format_sas(code: u32) -> String {
|
|
format!("{code:06}")
|
|
}
|
|
|
|
/// Constant-time comparison of two 32-byte arrays.
|
|
///
|
|
/// Returns `true` iff all bytes are equal. Uses [`subtle::ConstantTimeEq`]
|
|
/// to guarantee the comparison is not optimized into a short-circuit by the
|
|
/// compiler, preventing timing side-channels on secret-derived values like
|
|
/// transcript hashes and session IDs.
|
|
pub fn ct_eq(a: &[u8; 32], b: &[u8; 32]) -> bool {
|
|
use subtle::ConstantTimeEq;
|
|
a.ct_eq(b).into()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// session_secret = 0xa1b2c3d4…
|
|
fn session_secret() -> [u8; 32] {
|
|
hex_to_32("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2")
|
|
}
|
|
|
|
/// source ephemeral private key bytes (used to derive pubkey for transcript test)
|
|
fn source_privkey_bytes() -> [u8; 32] {
|
|
hex_to_32("7f4c11a9c9d1e3b5a7f2e4d6c8b0a2f4e6d8c0b2a4f6e8d0c2b4a6f8e0d2c4b5")
|
|
}
|
|
|
|
/// target ephemeral private key bytes
|
|
fn target_privkey_bytes() -> [u8; 32] {
|
|
hex_to_32("3a5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a1b3c5d7e9f1a3b5c7d9e1f3a5b")
|
|
}
|
|
|
|
fn hex_to_32(s: &str) -> [u8; 32] {
|
|
let bytes = hex::decode(s).expect("valid hex");
|
|
bytes.try_into().expect("32 bytes")
|
|
}
|
|
|
|
fn bytes_to_hex(b: &[u8]) -> String {
|
|
hex::encode(b)
|
|
}
|
|
|
|
#[test]
|
|
fn session_id_is_deterministic() {
|
|
let secret = session_secret();
|
|
let id1 = derive_session_id(&secret);
|
|
let id2 = derive_session_id(&secret);
|
|
assert_eq!(id1, id2, "session_id must be deterministic");
|
|
}
|
|
|
|
#[test]
|
|
fn session_id_is_32_bytes() {
|
|
let id = derive_session_id(&session_secret());
|
|
assert_eq!(id.len(), 32);
|
|
}
|
|
|
|
#[test]
|
|
fn session_id_differs_from_secret() {
|
|
let secret = session_secret();
|
|
let id = derive_session_id(&secret);
|
|
assert_ne!(id, secret, "session_id must not equal the raw secret");
|
|
}
|
|
|
|
#[test]
|
|
fn session_id_test_vector() {
|
|
let id = derive_session_id(&session_secret());
|
|
assert_eq!(
|
|
bytes_to_hex(&id),
|
|
"fb357d0f8e8d5a5ba3b2a91cb18c119e1567b07ffa38cdebb73e68df78f5a380",
|
|
"session_id must match NIP-AB spec test vector"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sas_code_is_six_digits() {
|
|
// Use a synthetic ECDH shared secret (just some fixed bytes).
|
|
let ecdh = hex_to_32("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20");
|
|
let (code, _) = derive_sas(&ecdh, &session_secret());
|
|
assert!(code < 1_000_000, "SAS code must be < 1_000_000, got {code}");
|
|
}
|
|
|
|
#[test]
|
|
fn sas_is_deterministic() {
|
|
let ecdh = hex_to_32("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20");
|
|
let (code1, input1) = derive_sas(&ecdh, &session_secret());
|
|
let (code2, input2) = derive_sas(&ecdh, &session_secret());
|
|
assert_eq!(code1, code2);
|
|
assert_eq!(input1, input2);
|
|
}
|
|
|
|
#[test]
|
|
fn sas_changes_with_different_ecdh() {
|
|
let ecdh1 = hex_to_32("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20");
|
|
let ecdh2 = hex_to_32("ff02030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20");
|
|
let (code1, _) = derive_sas(&ecdh1, &session_secret());
|
|
let (code2, _) = derive_sas(&ecdh2, &session_secret());
|
|
assert_ne!(
|
|
code1, code2,
|
|
"different ECDH inputs must produce different SAS codes"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sas_with_real_ecdh_keys() {
|
|
use nostr::{Keys, SecretKey};
|
|
|
|
let src_sk = SecretKey::from_slice(&source_privkey_bytes()).expect("valid key");
|
|
let tgt_sk = SecretKey::from_slice(&target_privkey_bytes()).expect("valid key");
|
|
let src_keys = Keys::new(src_sk);
|
|
let tgt_keys = Keys::new(tgt_sk);
|
|
|
|
// ECDH: source computes shared key with target's pubkey
|
|
let ecdh_from_src =
|
|
nostr::util::generate_shared_key(src_keys.secret_key(), &tgt_keys.public_key())
|
|
.unwrap();
|
|
// ECDH: target computes shared key with source's pubkey (must match)
|
|
let ecdh_from_tgt =
|
|
nostr::util::generate_shared_key(tgt_keys.secret_key(), &src_keys.public_key())
|
|
.unwrap();
|
|
|
|
assert_eq!(ecdh_from_src, ecdh_from_tgt, "ECDH must be symmetric");
|
|
|
|
let (code, sas_input) = derive_sas(&ecdh_from_src, &session_secret());
|
|
println!("sas_code = {}", format_sas(code));
|
|
println!("sas_input = {}", bytes_to_hex(&sas_input));
|
|
|
|
assert!(code < 1_000_000);
|
|
}
|
|
|
|
#[test]
|
|
fn transcript_hash_is_deterministic() {
|
|
use nostr::{Keys, SecretKey};
|
|
|
|
let src_sk = SecretKey::from_slice(&source_privkey_bytes()).expect("valid key");
|
|
let tgt_sk = SecretKey::from_slice(&target_privkey_bytes()).expect("valid key");
|
|
let src_keys = Keys::new(src_sk);
|
|
let tgt_keys = Keys::new(tgt_sk);
|
|
|
|
let session_id = derive_session_id(&session_secret());
|
|
let ecdh = nostr::util::generate_shared_key(src_keys.secret_key(), &tgt_keys.public_key())
|
|
.unwrap();
|
|
let (_, sas_input) = derive_sas(&ecdh, &session_secret());
|
|
|
|
let src_pk: [u8; 32] = src_keys.public_key().to_bytes();
|
|
let tgt_pk: [u8; 32] = tgt_keys.public_key().to_bytes();
|
|
|
|
let h1 =
|
|
derive_transcript_hash(&session_id, &src_pk, &tgt_pk, &sas_input, &session_secret());
|
|
let h2 =
|
|
derive_transcript_hash(&session_id, &src_pk, &tgt_pk, &sas_input, &session_secret());
|
|
assert_eq!(h1, h2);
|
|
}
|
|
|
|
/// Full test vector suite — all values pinned against the NIP-AB spec.
|
|
#[test]
|
|
fn all_test_vectors() {
|
|
use nostr::{Keys, SecretKey};
|
|
|
|
let src_sk = SecretKey::from_slice(&source_privkey_bytes()).expect("valid key");
|
|
let tgt_sk = SecretKey::from_slice(&target_privkey_bytes()).expect("valid key");
|
|
let src_keys = Keys::new(src_sk);
|
|
let tgt_keys = Keys::new(tgt_sk);
|
|
|
|
// Pubkeys
|
|
assert_eq!(
|
|
bytes_to_hex(&src_keys.public_key().to_bytes()),
|
|
"199e64ca60662cb2d6e91d16cb065be51ad74a6ee5f8c5b0fdc53d246611ed9a"
|
|
);
|
|
assert_eq!(
|
|
bytes_to_hex(&tgt_keys.public_key().to_bytes()),
|
|
"89a9fa762105d0aee2b19678246fe7b823aabbc4f4bf691a1ce8a70fcd36d6e4"
|
|
);
|
|
|
|
// ECDH
|
|
let ecdh = nostr::util::generate_shared_key(src_keys.secret_key(), &tgt_keys.public_key())
|
|
.unwrap();
|
|
assert_eq!(
|
|
bytes_to_hex(&ecdh),
|
|
"9b4b6d6990713d89d6d9982e506ee1bbcde6f05c54d9d2978696e8a7274d4408"
|
|
);
|
|
|
|
// Session ID
|
|
let session_id = derive_session_id(&session_secret());
|
|
assert_eq!(
|
|
bytes_to_hex(&session_id),
|
|
"fb357d0f8e8d5a5ba3b2a91cb18c119e1567b07ffa38cdebb73e68df78f5a380"
|
|
);
|
|
|
|
// SAS
|
|
let (sas_code, sas_input) = derive_sas(&ecdh, &session_secret());
|
|
assert_eq!(
|
|
bytes_to_hex(&sas_input),
|
|
"e8b03a329f3a0ac37fe7fbe929171e14b72812be67e33c5d6e193543c41798d3"
|
|
);
|
|
assert_eq!(format_sas(sas_code), "863346");
|
|
|
|
// Transcript hash
|
|
let src_pk = src_keys.public_key().to_bytes();
|
|
let tgt_pk = tgt_keys.public_key().to_bytes();
|
|
let transcript_hash =
|
|
derive_transcript_hash(&session_id, &src_pk, &tgt_pk, &sas_input, &session_secret());
|
|
assert_eq!(
|
|
bytes_to_hex(&transcript_hash),
|
|
"d662818ff8911fc60a2d025f8b8b4756107104e85888dd202d28db5ca2cf28d3"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn transcript_hash_sensitive_to_pubkey_order() {
|
|
use nostr::{Keys, SecretKey};
|
|
|
|
let src_sk = SecretKey::from_slice(&source_privkey_bytes()).expect("valid key");
|
|
let tgt_sk = SecretKey::from_slice(&target_privkey_bytes()).expect("valid key");
|
|
let src_keys = Keys::new(src_sk);
|
|
let tgt_keys = Keys::new(tgt_sk);
|
|
|
|
let session_id = derive_session_id(&session_secret());
|
|
let ecdh = nostr::util::generate_shared_key(src_keys.secret_key(), &tgt_keys.public_key())
|
|
.unwrap();
|
|
let (_, sas_input) = derive_sas(&ecdh, &session_secret());
|
|
|
|
let src_pk: [u8; 32] = src_keys.public_key().to_bytes();
|
|
let tgt_pk: [u8; 32] = tgt_keys.public_key().to_bytes();
|
|
|
|
let h_correct =
|
|
derive_transcript_hash(&session_id, &src_pk, &tgt_pk, &sas_input, &session_secret());
|
|
// Swap source and target — must produce a different hash.
|
|
let h_swapped =
|
|
derive_transcript_hash(&session_id, &tgt_pk, &src_pk, &sas_input, &session_secret());
|
|
assert_ne!(
|
|
h_correct, h_swapped,
|
|
"transcript_hash must be sensitive to pubkey order"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn format_sas_zero_padding() {
|
|
assert_eq!(format_sas(0), "000000");
|
|
assert_eq!(format_sas(1), "000001");
|
|
assert_eq!(format_sas(291), "000291");
|
|
assert_eq!(format_sas(47291), "047291");
|
|
assert_eq!(format_sas(999999), "999999");
|
|
}
|
|
|
|
#[test]
|
|
fn format_sas_always_six_chars() {
|
|
for code in [0u32, 1, 99, 1000, 99999, 100000, 999999] {
|
|
let s = format_sas(code);
|
|
assert_eq!(s.len(), 6, "format_sas({code}) = {s:?} (expected 6 chars)");
|
|
assert!(s.chars().all(|c| c.is_ascii_digit()), "all digits: {s}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn full_derivation_round_trip() {
|
|
use nostr::{Keys, SecretKey};
|
|
|
|
// Simulate both sides of the pairing independently deriving the same values.
|
|
let src_sk = SecretKey::from_slice(&source_privkey_bytes()).expect("valid key");
|
|
let tgt_sk = SecretKey::from_slice(&target_privkey_bytes()).expect("valid key");
|
|
let src_keys = Keys::new(src_sk);
|
|
let tgt_keys = Keys::new(tgt_sk);
|
|
let secret = session_secret();
|
|
|
|
// Both sides derive the same session_id.
|
|
let session_id = derive_session_id(&secret);
|
|
|
|
// Both sides compute ECDH (symmetric).
|
|
let ecdh_src =
|
|
nostr::util::generate_shared_key(src_keys.secret_key(), &tgt_keys.public_key())
|
|
.unwrap();
|
|
let ecdh_tgt =
|
|
nostr::util::generate_shared_key(tgt_keys.secret_key(), &src_keys.public_key())
|
|
.unwrap();
|
|
assert_eq!(ecdh_src, ecdh_tgt, "ECDH must be symmetric");
|
|
|
|
// Both sides derive the same SAS.
|
|
let (code_src, sas_input_src) = derive_sas(&ecdh_src, &secret);
|
|
let (code_tgt, sas_input_tgt) = derive_sas(&ecdh_tgt, &secret);
|
|
assert_eq!(code_src, code_tgt, "SAS codes must match");
|
|
assert_eq!(sas_input_src, sas_input_tgt, "sas_input must match");
|
|
|
|
// Both sides derive the same transcript hash (using the agreed pubkey ordering).
|
|
let src_pk: [u8; 32] = src_keys.public_key().to_bytes();
|
|
let tgt_pk: [u8; 32] = tgt_keys.public_key().to_bytes();
|
|
|
|
let th_src = derive_transcript_hash(&session_id, &src_pk, &tgt_pk, &sas_input_src, &secret);
|
|
let th_tgt = derive_transcript_hash(&session_id, &src_pk, &tgt_pk, &sas_input_tgt, &secret);
|
|
assert_eq!(th_src, th_tgt, "transcript hashes must match");
|
|
|
|
println!(
|
|
"✅ Round-trip OK: sas={} transcript={}",
|
|
format_sas(code_src),
|
|
bytes_to_hex(&th_src)
|
|
);
|
|
}
|
|
}
|