cashu/nuts/nut28/mod.rs
1//! # Pay-to-Blinded-Key (P2BK) Implementation
2//!
3//! This module implements NUT-28: Pay-to-Blinded-Key, a privacy enhancement for P2PK (NUT-11)
4//! that allows "silent payments" - tokens can be locked to a public key without exposing
5//! which public key they're locked to, even to the mint.
6//!
7//! ## Key Concepts
8//!
9//! * **Ephemeral Keys**: Sender generates a fresh ephemeral keypair `(e, E)` for each transaction
10//! * **ECDH**: Both sides derive the same shared secret via Elliptic Curve Diffie-Hellman
11//! * **Blinding**: Public keys are blinded before being sent to the mint
12//! * **Key Recovery**: Receiver uses ECDH to recover the original blinding factor and derive signing key
13//!
14//! ## Feature Highlights
15//!
16//! * Privacy-preserving P2PK operations
17//! * Compatible with existing mints (no mint-side changes needed)
18//! * BIP-340 compatibility for x-only pubkeys
19//! * Canonical slot mapping for multi-key proofs
20//!
21//! ## Implementation Details
22//!
23//! * Uses SHA-256 for key derivation with domain separation
24//! * Supports rejection sampling for out-of-range blinding factors
25//! * Properly handles SEC1 and BIP-340 key formats
26//!
27//! See the NUT-28 specification for full details:
28//! <https://github.com/cashubtc/nuts/blob/main/28.md>
29
30use std::sync::LazyLock;
31
32use bitcoin::hashes::sha256::Hash as Sha256;
33use bitcoin::hashes::{Hash, HashEngine};
34use bitcoin::secp256k1::Secp256k1;
35use thiserror::Error;
36
37use crate::nuts::nut01::{PublicKey, SecretKey};
38
39// Create a static SECP256K1 context that we'll use for operations
40static SECP: LazyLock<Secp256k1<bitcoin::secp256k1::All>> = LazyLock::new(Secp256k1::new);
41
42/// NUT-28 Error
43#[derive(Debug, Error)]
44pub enum Error {
45 /// Invalid canonical slot
46 #[error("Invalid canonical slot {0}")]
47 InvalidCanonicalSlot(u8),
48 /// Invalid scalar hex string
49 #[error("Invalid scalar hex string: {0}")]
50 InvalidScalarHex(String),
51 /// Scalar must be 32 bytes (64 hex chars)
52 #[error("Scalar must be 32 bytes (64 hex chars), got {0}")]
53 InvalidScalarLength(usize),
54 /// Scalar is zero
55 #[error("Derived signing key is zero (invalid)")]
56 ZeroSigningKey,
57 /// Could not match even-Y pubkey for BIP340
58 #[error("Could not derive valid BIP340 signing key (neither k nor -k matched blinded pubkey)")]
59 NoValidBip340Key,
60 /// Secp256k1 error
61 #[error(transparent)]
62 Secp256k1(#[from] bitcoin::secp256k1::Error),
63 /// Hex decode error
64 #[error(transparent)]
65 Hex(#[from] crate::util::hex::Error),
66 /// NUT-01 error
67 #[error(transparent)]
68 NUT01(#[from] crate::nuts::nut01::Error),
69}
70
71/// Perform ECDH and get blinding factor r
72///
73/// This uses the NUT-28 Key Derivation Function (KDF):
74/// KDF = SHA256(domain_tag || x_only(Z) || canonical_slot_byte)
75/// Per the spec, if the scalar is invalid (0 or >= n), retry with 0xff appended.
76///
77/// # Arguments
78/// * `secret_key` - The secret key to use for ECDH (sender's ephemeral key or receiver's private key)
79/// * `pubkey` - The public key to use for ECDH (receiver's public key or sender's ephemeral key)
80/// * `_keyset_id` - Ignored (kept for API compatibility)
81/// * `canonical_slot` - The canonical slot index (0-10)
82///
83/// # Returns
84/// * A scalar that can be used to blind the public key (blinding factor r)
85///
86/// # Errors
87/// * If the canonical slot is invalid (must be 0-10)
88/// * If the ECDH operation fails
89/// * If the derived scalar is invalid
90#[allow(unused_variables)]
91pub fn ecdh_kdf(
92 secret_key: &SecretKey,
93 pubkey: &PublicKey,
94 canonical_slot: u8,
95) -> Result<SecretKey, Error> {
96 if canonical_slot > 10 {
97 return Err(Error::InvalidCanonicalSlot(canonical_slot));
98 }
99
100 // Compute shared point Z = secret_key * pubkey
101 // Use SharedSecret if available (produces 32 bytes typically equal to x-coordinate)
102 let shared = pubkey.mul_tweak(&SECP, &secret_key.as_scalar())?;
103
104 // SharedSecret exposes 32 bytes (x-coordinate)
105 let z_x: [u8; 32] = shared.x_only_public_key().0.serialize();
106
107 // Build KDF input per NUT-28 spec: domain tag || x-only(Z) || canonical_slot (1 byte)
108 let mut engine = Sha256::engine();
109 engine.input(b"Cashu_P2BK_v1");
110 engine.input(&z_x);
111 engine.input(&[canonical_slot]);
112
113 // First attempt
114 let digest = Sha256::from_engine(engine.clone());
115 match SecretKey::from_slice(digest.as_byte_array()).map_err(Error::from) {
116 Ok(result) => Ok(result),
117 Err(_) => {
118 // Retry once with 0xff counter byte if first attempt failed (per spec)
119 engine.input(&[0xFF]);
120 let digest = Sha256::from_engine(engine);
121 SecretKey::from_slice(digest.as_byte_array()).map_err(Error::from)
122 }
123 }
124}
125
126/// Blind a public key with a random scalar r
127///
128/// Computes P' = P + r·G where:
129/// - P is the original (unblinded) public key
130/// - r is the blinding scalar
131/// - G is the secp256k1 base point
132/// - P' is the blinded public key
133///
134/// # Arguments
135/// * `pubkey` - The public key to blind
136/// * `r` - The blinding scalar
137///
138/// # Returns
139/// * The blinded public key
140///
141/// # Errors
142/// * If the point addition fails
143pub fn blind_public_key(pubkey: &PublicKey, r: &SecretKey) -> Result<PublicKey, Error> {
144 let r_pubkey = r.public_key();
145 Ok(pubkey.combine(&r_pubkey)?.into())
146}
147
148/// Derive BIP-340 compatible signing key from private key and blinding scalar
149///
150/// For BIP-340 compatibility, we must handle the even-Y requirement. This function:
151/// 1. Unblinds the public key to verify it matches our private key
152/// 2. Checks if the parity matches
153/// 3. Uses p or -p based on parity to derive the correct key
154///
155/// # Arguments
156/// * `privkey` - The private key
157/// * `r` - The blinding scalar
158/// * `blinded_pubkey` - The blinded public key (P')
159///
160/// # Returns
161/// * The derived signing key that produces a public key matching blinded_pubkey
162///
163/// # Errors
164/// * If the unblinding fails
165/// * If neither k nor -k matches the blinded pubkey
166/// * If the resulting scalar is zero (invalid)
167pub fn derive_signing_key_bip340(
168 privkey: &SecretKey,
169 r: &SecretKey,
170 blinded_pubkey: &PublicKey,
171) -> Result<SecretKey, Error> {
172 // Unblind the public key
173 let r_pubkey = r.public_key();
174 let r_pubkey_neg = r_pubkey.negate(&SECP);
175 let unblinded_pubkey = blinded_pubkey.combine(&r_pubkey_neg)?;
176
177 // Get the public key from privkey
178 let privkey_pubkey = privkey.public_key();
179
180 // Verify the x-coordinates match
181 let (unblinded_x_only, unblinded_parity) = unblinded_pubkey.x_only_public_key();
182 let privkey_x_only = privkey_pubkey.x_only_public_key();
183
184 // Compute parity from the compressed public key bytes
185 // First byte is 02 (even) or 03 (odd)
186 let privkey_pubkey_parity = privkey_pubkey.to_bytes()[0] == 0x02;
187
188 // Convert Parity to bool for comparison (Even = true, Odd = false)
189 let unblinded_parity_is_even = matches!(unblinded_parity, bitcoin::key::Parity::Even);
190
191 // Compare the x-only public keys
192 if unblinded_x_only != privkey_x_only {
193 return Err(Error::NoValidBip340Key);
194 }
195
196 match privkey_pubkey_parity == unblinded_parity_is_even {
197 true => Ok(privkey.add_tweak(&r.as_scalar())?.into()),
198 false => Ok(privkey.negate().add_tweak(&r.as_scalar())?.into()),
199 }
200}
201
202#[cfg(feature = "wallet")]
203#[cfg(test)]
204mod tests;
205
206#[cfg(test)]
207mod test_vectors;