Skip to main content

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;