Skip to content
Merged
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/cashu/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ strum_macros.workspace = true
nostr-sdk = { workspace = true, optional = true }
zeroize = "1"
web-time.workspace = true
unicode-normalization = "0.1.25"

[target.'cfg(target_arch = "wasm32")'.dependencies]
uuid = { workspace = true, features = ["js"], optional = true }
Expand Down
69 changes: 65 additions & 4 deletions crates/cashu/src/nuts/nut00/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ use std::hash::{Hash, Hasher};
use std::str::FromStr;
use std::string::FromUtf8Error;

#[cfg(feature = "mint")]
use bitcoin::hashes::Hash as BitcoinHash;
use serde::{Deserialize, Deserializer, Serialize};
use thiserror::Error;
use unicode_normalization::UnicodeNormalization;

use super::nut02::ShortKeysetId;
#[cfg(feature = "wallet")]
Expand Down Expand Up @@ -585,6 +588,10 @@ pub enum CurrencyUnit {
#[cfg(feature = "mint")]
impl CurrencyUnit {
/// Derivation index mint will use for unit
#[deprecated(
since = "0.15.0",
note = "This function is outdated; use `hashed_derivation_index` instead."
)]
Comment thread
thesimplekid marked this conversation as resolved.
pub fn derivation_index(&self) -> Option<u32> {
match self {
Self::Sat => Some(0),
Expand All @@ -595,6 +602,28 @@ impl CurrencyUnit {
_ => None,
}
}

/// Construct a custom unit, normalizing to uppercase and trimming whitespace.
pub fn custom<S: AsRef<str>>(value: S) -> Self {
Self::Custom(normalize_custom_unit(value.as_ref()).to_uppercase())
}

/// Big endian encoded integer of the first 4 bytes of the sha256 hash of the unit string.
pub fn hashed_derivation_index(&self) -> u32 {
use bitcoin::hashes::sha256;

// transform to uppercase
let unit_str = self.to_string().to_uppercase();

let bytes = <sha256::Hash as BitcoinHash>::hash(unit_str.as_bytes());
// Take the first 4 bytes and convert to u32 (big endian) make sure the integer
u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) & !(1 << 31)
Comment thread
thesimplekid marked this conversation as resolved.
}
}
Comment thread
thesimplekid marked this conversation as resolved.

fn normalize_custom_unit(value: &str) -> String {
let trimmed = value.trim_matches(|c: char| matches!(c, ' ' | '\t' | '\r' | '\n'));
trimmed.nfc().collect::<String>()
}

impl FromStr for CurrencyUnit {
Expand All @@ -607,13 +636,14 @@ impl FromStr for CurrencyUnit {
"USD" => Ok(Self::Usd),
"EUR" => Ok(Self::Eur),
"AUTH" => Ok(Self::Auth),
_ => Ok(Self::Custom(value.to_string())),
_ => Ok(Self::Custom(normalize_custom_unit(value))),
}
}
}

impl fmt::Display for CurrencyUnit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// let binding = normalize_custom_unit(&self).clone();
let s = match self {
CurrencyUnit::Sat => "SAT",
CurrencyUnit::Msat => "MSAT",
Expand All @@ -622,10 +652,16 @@ impl fmt::Display for CurrencyUnit {
CurrencyUnit::Auth => "AUTH",
CurrencyUnit::Custom(unit) => unit,
};

if let Some(width) = f.width() {
write!(f, "{:width$}", s.to_lowercase(), width = width)
write!(
f,
"{:width$}",
normalize_custom_unit(s).to_lowercase(),
width = width
)
} else {
write!(f, "{}", s.to_lowercase())
write!(f, "{}", normalize_custom_unit(s).to_lowercase())
}
}
}
Expand All @@ -635,7 +671,7 @@ impl Serialize for CurrencyUnit {
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
serializer.serialize_str(&self.to_string().to_lowercase())
}
}

Expand Down Expand Up @@ -1142,6 +1178,31 @@ mod tests {
);
}

#[test]
#[cfg(feature = "mint")]
fn four_bytes_hash_currency_unit() {
let unit = CurrencyUnit::Sat;
let index = unit.hashed_derivation_index();
assert_eq!(index, 1967237907);

let unit = CurrencyUnit::Msat;
let index = unit.hashed_derivation_index();
assert_eq!(index, 142929756);

let unit = CurrencyUnit::Eur;
let index = unit.hashed_derivation_index();
assert_eq!(index, 1473545324);

let unit = CurrencyUnit::Usd;
let index = unit.hashed_derivation_index();
assert_eq!(index, 577560378);

let unit = CurrencyUnit::Auth;
let index = unit.hashed_derivation_index();

assert_eq!(index, 1222349093)
}

#[test]
fn test_payment_method_parsing() {
// Test known methods (case insensitive)
Expand Down
36 changes: 36 additions & 0 deletions crates/cashu/src/nuts/nut02.rs

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion crates/cdk-common/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,9 @@ pub enum Error {
/// Oidc config not set
#[error("Oidc client not set")]
OidcNotSet,

/// Unit String collision
#[error("Unit string picked collided: `{0}`")]
UnitStringCollision(CurrencyUnit),
// Wallet Errors
/// P2PK spending conditions not met
#[error("P2PK condition not met `{0}`")]
Expand Down
13 changes: 8 additions & 5 deletions crates/cdk-common/src/grpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,31 @@ use tonic::{Request, Status};

/// Header name for protocol version
pub const VERSION_HEADER: &str = "x-cdk-protocol-version";
/// Header for version of the signatory protofile
pub const VERSION_SIGNATORY_HEADER: &str = "x-signatory-schema-version";

/// Creates a client-side interceptor that injects a specific protocol version into outgoing requests
///
/// # Panics
/// Panics if the version string is not a valid gRPC metadata ASCII value
pub fn create_version_inject_interceptor(
header: &'static str,
version: &'static str,
) -> impl Fn(Request<()>) -> Result<Request<()>, Status> + Clone {
move |mut request: Request<()>| {
request.metadata_mut().insert(
VERSION_HEADER,
version.parse().expect("Invalid protocol version"),
);
request
.metadata_mut()
.insert(header, version.parse().expect("Invalid protocol version"));
Ok(request)
}
}

/// Creates a server-side interceptor that validates a specific protocol version on incoming requests
pub fn create_version_check_interceptor(
header: &'static str,
expected_version: &'static str,
) -> impl Fn(Request<()>) -> Result<Request<()>, Status> + Clone {
move |request: Request<()>| match request.metadata().get(VERSION_HEADER) {
move |request: Request<()>| match request.metadata().get(header) {
Some(version) => {
let version = version
.to_str()
Expand Down
3 changes: 0 additions & 3 deletions crates/cdk-common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,6 @@ pub mod task;
/// Protocol version for gRPC Mint RPC communication
pub const MINT_RPC_PROTOCOL_VERSION: &str = "1.0.0";

/// Protocol version for gRPC Signatory communication
pub const SIGNATORY_PROTOCOL_VERSION: &str = "1.0.0";

/// Protocol version for gRPC Payment Processor communication
pub const PAYMENT_PROCESSOR_PROTOCOL_VERSION: &str = "1.0.0";

Expand Down
10 changes: 8 additions & 2 deletions crates/cdk-mint-rpc/src/proto/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,15 +139,21 @@ impl MintRPCServer {
Server::builder().tls_config(tls_config)?.add_service(
CdkMintServer::with_interceptor(
self.clone(),
create_version_check_interceptor(cdk_common::MINT_RPC_PROTOCOL_VERSION),
create_version_check_interceptor(
cdk_common::grpc::VERSION_HEADER,
cdk_common::MINT_RPC_PROTOCOL_VERSION,
),
),
)
}
None => {
tracing::warn!("No valid TLS configuration found, starting insecure server");
Server::builder().add_service(CdkMintServer::with_interceptor(
self.clone(),
create_version_check_interceptor(cdk_common::MINT_RPC_PROTOCOL_VERSION),
create_version_check_interceptor(
cdk_common::grpc::VERSION_HEADER,
cdk_common::MINT_RPC_PROTOCOL_VERSION,
),
))
}
};
Expand Down
2 changes: 2 additions & 0 deletions crates/cdk-payment-processor/src/proto/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ impl PaymentProcessorServer {
CdkPaymentProcessorServer::with_interceptor(
self.clone(),
create_version_check_interceptor(
cdk_common::grpc::VERSION_HEADER,
cdk_common::PAYMENT_PROCESSOR_PROTOCOL_VERSION,
),
),
Expand All @@ -118,6 +119,7 @@ impl PaymentProcessorServer {
Server::builder().add_service(CdkPaymentProcessorServer::with_interceptor(
self.clone(),
create_version_check_interceptor(
cdk_common::grpc::VERSION_HEADER,
cdk_common::PAYMENT_PROCESSOR_PROTOCOL_VERSION,
),
))
Expand Down
47 changes: 34 additions & 13 deletions crates/cdk-signatory/src/common.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use std::sync::Arc;

use bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv};
use bitcoin::secp256k1::{self, All, Secp256k1};
use cdk_common::common::IssuerVersion;
use cdk_common::database;
use cdk_common::error::Error;
use cdk_common::mint::MintKeySetInfo;
use cdk_common::nuts::{CurrencyUnit, MintKeySet};
use cdk_common::util::unix_time;
use cdk_common::{database, nut02};

/// Initialize keysets
pub async fn init_keysets(
Expand Down Expand Up @@ -79,14 +79,8 @@ pub fn create_new_keyset<C: secp256k1::Signing>(
amounts: &[u64],
input_fee_ppk: u64,
final_expiry: Option<u64>,
use_keyset_v2: bool,
keyset_id_version: nut02::KeySetVersion,
) -> (MintKeySet, MintKeySetInfo) {
let version = if use_keyset_v2 {
cdk_common::nut02::KeySetVersion::Version01
} else {
cdk_common::nut02::KeySetVersion::Version00
};

let keyset = MintKeySet::generate(
secp,
xpriv
Expand All @@ -96,7 +90,7 @@ pub fn create_new_keyset<C: secp256k1::Signing>(
amounts,
input_fee_ppk,
final_expiry,
version,
keyset_id_version,
);
let keyset_info = MintKeySetInfo {
id: keyset.id,
Expand All @@ -114,11 +108,38 @@ pub fn create_new_keyset<C: secp256k1::Signing>(
}

pub fn derivation_path_from_unit(unit: CurrencyUnit, index: u32) -> Option<DerivationPath> {
let unit_index = unit.derivation_index()?;
let unit_index = unit.hashed_derivation_index();

Some(DerivationPath::from(vec![
ChildNumber::from_hardened_idx(0).expect("0 is a valid index"),
ChildNumber::from_hardened_idx(unit_index).expect("0 is a valid index"),
ChildNumber::from_hardened_idx(129372).expect("129372 is a valid index"),
ChildNumber::from_hardened_idx(unit_index).expect("unit index should be valid"),
ChildNumber::from_hardened_idx(index).expect("0 is a valid index"),
]))
}

/// take all the keyset units and if te new keyset is a new unit we check
pub fn check_unit_string_collision(
keysets: Vec<crate::signatory::SignatoryKeySet>,
new_keyset: &MintKeySetInfo,
) -> Result<(), Error> {
let mut unit_hash: HashSet<CurrencyUnit> = HashSet::new();

for key in keysets {
unit_hash.insert(key.unit);
}

if unit_hash.contains(&new_keyset.unit) {
// the currency unit already exists so we don't have to check it
return Ok(());
}

let new_unit_int = new_keyset.unit.hashed_derivation_index();
for unit in unit_hash.iter() {
let existing_unit_string = unit.hashed_derivation_index();
if existing_unit_string == new_unit_int {
return Err(Error::UnitStringCollision(new_keyset.unit.clone()));
}
}

Ok(())
}
Loading