Clear and Blind Auth (#510)

* feat: auth

* chore: corret error codes

* chore: corret error codes

* fix: feature auth in cdk-axum

* refactor: auth logging

* feat: include dleq in auth proof

* feat: mint max auth proofs

* chore: clippy
This commit is contained in:
thesimplekid
2025-03-24 11:13:22 +00:00
committed by GitHub
parent cd71cd47d9
commit be93ff2384
91 changed files with 11300 additions and 503 deletions

View File

@@ -84,16 +84,26 @@ jobs:
-p cashu --no-default-features --features wallet, -p cashu --no-default-features --features wallet,
-p cashu --no-default-features --features mint, -p cashu --no-default-features --features mint,
-p cashu --no-default-features --features "mint swagger", -p cashu --no-default-features --features "mint swagger",
-p cashu --no-default-features --features auth,
-p cashu --no-default-features --features "mint auth",
-p cashu --no-default-features --features "wallet auth",
-p cdk-common, -p cdk-common,
-p cdk-common --no-default-features, -p cdk-common --no-default-features,
-p cdk-common --no-default-features --features wallet, -p cdk-common --no-default-features --features wallet,
-p cdk-common --no-default-features --features mint, -p cdk-common --no-default-features --features mint,
-p cdk-common --no-default-features --features "mint swagger", -p cdk-common --no-default-features --features "mint swagger",
-p cdk-common --no-default-features --features "auth",
-p cdk-common --no-default-features --features "mint auth",
-p cdk-common --no-default-features --features "wallet auth",
-p cdk, -p cdk,
-p cdk --no-default-features, -p cdk --no-default-features,
-p cdk --no-default-features --features wallet, -p cdk --no-default-features --features wallet,
-p cdk --no-default-features --features mint, -p cdk --no-default-features --features mint,
-p cdk --no-default-features --features "mint swagger", -p cdk --no-default-features --features "mint swagger",
-p cdk --no-default-features --features auth,
-p cdk --features auth,
-p cdk --no-default-features --features "auth mint",
-p cdk --no-default-features --features "auth wallet",
-p cdk-redb, -p cdk-redb,
-p cdk-sqlite, -p cdk-sqlite,
-p cdk-sqlite --features sqlcipher, -p cdk-sqlite --features sqlcipher,
@@ -101,6 +111,7 @@ jobs:
-p cdk-axum --no-default-features --features swagger, -p cdk-axum --no-default-features --features swagger,
-p cdk-axum --no-default-features --features redis, -p cdk-axum --no-default-features --features redis,
-p cdk-axum --no-default-features --features "redis swagger", -p cdk-axum --no-default-features --features "redis swagger",
-p cdk-axum --no-default-features --features "auth redis",
-p cdk-axum, -p cdk-axum,
-p cdk-cln, -p cdk-cln,
-p cdk-lnd, -p cdk-lnd,
@@ -126,6 +137,8 @@ jobs:
--bin cdk-mintd --no-default-features --features "swagger lnd", --bin cdk-mintd --no-default-features --features "swagger lnd",
--bin cdk-mintd --no-default-features --features "swagger cln", --bin cdk-mintd --no-default-features --features "swagger cln",
--bin cdk-mintd --no-default-features --features "swagger lnbits", --bin cdk-mintd --no-default-features --features "swagger lnbits",
--bin cdk-mintd --no-default-features --features "auth lnd",
--bin cdk-mintd --no-default-features --features "auth cln",
--bin cdk-mint-cli, --bin cdk-mint-cli,
] ]
steps: steps:
@@ -142,11 +155,11 @@ jobs:
- name: Test - name: Test
run: nix develop -i -L .#stable --command cargo test ${{ matrix.build-args }} run: nix develop -i -L .#stable --command cargo test ${{ matrix.build-args }}
itest: regtest-itest:
name: "Integration regtest tests" name: "Integration regtest tests"
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
needs: [pre-commit-checks, clippy, pure-itest, fake-wallet-itest] needs: [pre-commit-checks, clippy, pure-itest, fake-mint-itest]
strategy: strategy:
matrix: matrix:
build-args: build-args:
@@ -167,13 +180,11 @@ jobs:
uses: DeterminateSystems/magic-nix-cache-action@v6 uses: DeterminateSystems/magic-nix-cache-action@v6
- name: Rust Cache - name: Rust Cache
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
- name: Clippy
run: nix develop -i -L .#stable --command cargo clippy ${{ matrix.build-args }} -- -D warnings
- name: Test - name: Test
run: nix develop -i -L .#stable --command just itest ${{ matrix.database }} run: nix develop -i -L .#stable --command just itest ${{ matrix.database }}
fake-wallet-itest: fake-mint-itest:
name: "Integration fake wallet tests" name: "Integration fake mint tests"
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
needs: [pre-commit-checks, clippy] needs: [pre-commit-checks, clippy]
@@ -198,8 +209,8 @@ jobs:
- name: Rust Cache - name: Rust Cache
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
- name: Clippy - name: Clippy
run: nix develop -i -L .#stable --command cargo clippy ${{ matrix.build-args }} -- -D warnings run: nix develop -i -L .#stable --command cargo clippy -- -D warnings
- name: Test fake mint - name: Test fake auth mint
run: nix develop -i -L .#stable --command just fake-mint-itest ${{ matrix.database }} run: nix develop -i -L .#stable --command just fake-mint-itest ${{ matrix.database }}
pure-itest: pure-itest:
@@ -224,7 +235,7 @@ jobs:
name: "Payment processor tests" name: "Payment processor tests"
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
needs: [pre-commit-checks, clippy, pure-itest, fake-wallet-itest, itest] needs: [pre-commit-checks, clippy, pure-itest, fake-mint-itest, regtest-itest]
strategy: strategy:
matrix: matrix:
ln: ln:
@@ -256,7 +267,10 @@ jobs:
[ [
-p cashu --no-default-features --features "wallet mint", -p cashu --no-default-features --features "wallet mint",
-p cdk-common --no-default-features --features "wallet mint", -p cdk-common --no-default-features --features "wallet mint",
-p cdk --no-default-features --features "mint mint", -p cdk,
-p cdk --no-default-features --features "mint auth",
-p cdk --no-default-features --features "wallet auth",
-p cdk --no-default-features --features "http_subscription",
-p cdk-axum, -p cdk-axum,
-p cdk-axum --no-default-features --features redis, -p cdk-axum --no-default-features --features redis,
-p cdk-lnbits, -p cdk-lnbits,
@@ -339,3 +353,39 @@ jobs:
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
- name: Build cdk wasm - name: Build cdk wasm
run: nix develop -i -L ".#${{ matrix.rust }}" --command cargo build ${{ matrix.build-args }} --target ${{ matrix.target }} run: nix develop -i -L ".#${{ matrix.rust }}" --command cargo build ${{ matrix.build-args }} --target ${{ matrix.target }}
fake-mint-auth-itest:
name: "Integration fake mint auth tests"
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [pre-commit-checks, clippy, pure-itest, fake-mint-itest]
strategy:
matrix:
database:
[
REDB,
SQLITE,
]
steps:
- name: checkout
uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@v11
- name: Nix Cache
uses: DeterminateSystems/magic-nix-cache-action@v6
- name: Rust Cache
uses: Swatinem/rust-cache@v2
- name: Start Keycloak with Backup
run: |
docker compose -f misc/keycloak/docker-compose-recover.yml up -d
until docker logs $(docker ps -q --filter "ancestor=quay.io/keycloak/keycloak:25.0.6") | grep "Keycloak 25.0.6 on JVM (powered by Quarkus 3.8.5) started"; do sleep 1; done
- name: Verify Keycloak Import
run: |
docker logs $(docker ps -q --filter "ancestor=quay.io/keycloak/keycloak:25.0.6") | grep "Imported"
- name: Test fake auth mint
run: nix develop -i -L .#stable --command just fake-auth-mint-itest ${{ matrix.database }} http://127.0.0.1:8080/realms/cdk-test-realm/.well-known/openid-configuration
- name: Stop and clean up Docker Compose
run: |
docker compose -f misc/keycloak/docker-compose-recover.yml down

2
.gitignore vendored
View File

@@ -10,3 +10,5 @@ config.toml
result result
Cargo.lock Cargo.lock
.aider* .aider*
**/postgres_data/
**/.env

View File

@@ -3,5 +3,8 @@ extend-ignore-re = [
# Ignore cashu tokens # Ignore cashu tokens
"cashuA[A-Za-z0-9-_]+", "cashuA[A-Za-z0-9-_]+",
"cashuB[A-Za-z0-9-_]+", "cashuB[A-Za-z0-9-_]+",
"casshuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9" "casshuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9",
"autheticator",
"Gam",
"flate2"
] ]

View File

@@ -17,6 +17,7 @@ async-trait = "0.1"
axum = { version = "0.8.1", features = ["ws"] } axum = { version = "0.8.1", features = ["ws"] }
bitcoin = { version = "0.32.2", features = ["base64", "serde", "rand", "rand-std"] } bitcoin = { version = "0.32.2", features = ["base64", "serde", "rand", "rand-std"] }
bip39 = { version = "2.0", features = ["rand"] } bip39 = { version = "2.0", features = ["rand"] }
jsonwebtoken = "9.2.0"
cashu = { path = "./crates/cashu", version = "=0.7.1" } cashu = { path = "./crates/cashu", version = "=0.7.1" }
cdk = { path = "./crates/cdk", default-features = false, version = "=0.7.2" } cdk = { path = "./crates/cdk", default-features = false, version = "=0.7.2" }
cdk-common = { path = "./crates/cdk-common", default-features = false, version = "=0.7.1" } cdk-common = { path = "./crates/cdk-common", default-features = false, version = "=0.7.1" }
@@ -64,6 +65,7 @@ reqwest = { version = "0.12", default-features = false, features = [
once_cell = "1.20.2" once_cell = "1.20.2"
instant = { version = "0.1", default-features = false } instant = { version = "0.1", default-features = false }
rand = "0.8.5" rand = "0.8.5"
regex = "1"
home = "0.5.5" home = "0.5.5"
tonic = { version = "0.12.3", features = [ tonic = { version = "0.12.3", features = [
"channel", "channel",
@@ -72,6 +74,8 @@ tonic = { version = "0.12.3", features = [
] } ] }
prost = "0.13.1" prost = "0.13.1"
tonic-build = "0.12" tonic-build = "0.12"
strum = "0.27.1"
strum_macros = "0.27.1"

View File

@@ -10,10 +10,11 @@ rust-version = "1.75.0" # MSRV
license.workspace = true license.workspace = true
[features] [features]
default = ["mint", "wallet"] default = ["mint", "wallet", "auth"]
swagger = ["dep:utoipa"] swagger = ["dep:utoipa"]
mint = ["dep:uuid"] mint = ["dep:uuid"]
wallet = [] wallet = []
auth = ["dep:strum", "dep:strum_macros", "dep:regex"]
bench = [] bench = []
[dependencies] [dependencies]
@@ -30,6 +31,9 @@ url.workspace = true
utoipa = { workspace = true, optional = true } utoipa = { workspace = true, optional = true }
serde_json.workspace = true serde_json.workspace = true
serde_with.workspace = true serde_with.workspace = true
regex = { workspace = true, optional = true }
strum = { workspace = true, optional = true }
strum_macros = { workspace = true, optional = true }
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
instant = { workspace = true, features = ["wasm-bindgen", "inaccurate"] } instant = { workspace = true, features = ["wasm-bindgen", "inaccurate"] }

View File

@@ -0,0 +1,8 @@
pub mod nut21;
pub mod nut22;
pub use nut21::{Method, ProtectedEndpoint, RoutePath, Settings as ClearAuthSettings};
pub use nut22::{
AuthProof, AuthRequired, AuthToken, BlindAuthToken, MintAuthRequest,
Settings as BlindAuthSettings,
};

View File

@@ -0,0 +1,409 @@
//! 21 Clear Auth
use std::collections::HashSet;
use std::str::FromStr;
use regex::Regex;
use serde::{Deserialize, Serialize};
use strum::IntoEnumIterator;
use strum_macros::EnumIter;
use thiserror::Error;
/// NUT21 Error
#[derive(Debug, Error)]
pub enum Error {
/// Invalid regex pattern
#[error("Invalid regex pattern: {0}")]
InvalidRegex(#[from] regex::Error),
}
/// Clear Auth Settings
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize)]
#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
pub struct Settings {
/// Openid discovery
pub openid_discovery: String,
/// Client ID
pub client_id: String,
/// Protected endpoints
pub protected_endpoints: Vec<ProtectedEndpoint>,
}
impl Settings {
/// Create new [`Settings`]
pub fn new(
openid_discovery: String,
client_id: String,
protected_endpoints: Vec<ProtectedEndpoint>,
) -> Self {
Self {
openid_discovery,
client_id,
protected_endpoints,
}
}
}
// Custom deserializer for Settings to expand regex patterns in protected endpoints
impl<'de> Deserialize<'de> for Settings {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
// Define a temporary struct to deserialize the raw data
#[derive(Deserialize)]
struct RawSettings {
openid_discovery: String,
client_id: String,
protected_endpoints: Vec<RawProtectedEndpoint>,
}
#[derive(Deserialize)]
struct RawProtectedEndpoint {
method: Method,
path: String,
}
// Deserialize into the temporary struct
let raw = RawSettings::deserialize(deserializer)?;
// Process protected endpoints, expanding regex patterns if present
let mut protected_endpoints = HashSet::new();
for raw_endpoint in raw.protected_endpoints {
let expanded_paths = matching_route_paths(&raw_endpoint.path).map_err(|e| {
serde::de::Error::custom(format!(
"Invalid regex pattern '{}': {}",
raw_endpoint.path, e
))
})?;
for path in expanded_paths {
protected_endpoints.insert(ProtectedEndpoint::new(raw_endpoint.method, path));
}
}
// Create the final Settings struct
Ok(Settings {
openid_discovery: raw.openid_discovery,
client_id: raw.client_id,
protected_endpoints: protected_endpoints.into_iter().collect(),
})
}
}
/// List of the methods and paths that are protected
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
pub struct ProtectedEndpoint {
/// HTTP Method
pub method: Method,
/// Route path
pub path: RoutePath,
}
impl ProtectedEndpoint {
/// Create [`CachedEndpoint`]
pub fn new(method: Method, path: RoutePath) -> Self {
Self { method, path }
}
}
/// HTTP method
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
pub enum Method {
/// Get
Get,
/// POST
Post,
}
/// Route path
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, EnumIter)]
#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum RoutePath {
/// Bolt11 Mint Quote
#[serde(rename = "/v1/mint/quote/bolt11")]
MintQuoteBolt11,
/// Bolt11 Mint
#[serde(rename = "/v1/mint/bolt11")]
MintBolt11,
/// Bolt11 Melt Quote
#[serde(rename = "/v1/melt/quote/bolt11")]
MeltQuoteBolt11,
/// Bolt11 Melt
#[serde(rename = "/v1/melt/bolt11")]
MeltBolt11,
/// Swap
#[serde(rename = "/v1/swap")]
Swap,
/// Checkstate
#[serde(rename = "/v1/checkstate")]
Checkstate,
/// Restore
#[serde(rename = "/v1/restore")]
Restore,
/// Mint Blind Auth
#[serde(rename = "/v1/auth/blind/mint")]
MintBlindAuth,
}
pub fn matching_route_paths(pattern: &str) -> Result<Vec<RoutePath>, Error> {
let regex = Regex::from_str(pattern)?;
Ok(RoutePath::iter()
.filter(|path| regex.is_match(&path.to_string()))
.collect())
}
impl std::fmt::Display for RoutePath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Use serde to serialize to a JSON string, then extract the value without quotes
let json_str = match serde_json::to_string(self) {
Ok(s) => s,
Err(_) => return write!(f, "<error>"),
};
// Remove the quotes from the JSON string
let path = json_str.trim_matches('"');
write!(f, "{}", path)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_matching_route_paths_all() {
// Regex that matches all paths
let paths = matching_route_paths(".*").unwrap();
// Should match all variants
assert_eq!(paths.len(), RoutePath::iter().count());
// Verify all variants are included
assert!(paths.contains(&RoutePath::MintQuoteBolt11));
assert!(paths.contains(&RoutePath::MintBolt11));
assert!(paths.contains(&RoutePath::MeltQuoteBolt11));
assert!(paths.contains(&RoutePath::MeltBolt11));
assert!(paths.contains(&RoutePath::Swap));
assert!(paths.contains(&RoutePath::Checkstate));
assert!(paths.contains(&RoutePath::Restore));
assert!(paths.contains(&RoutePath::MintBlindAuth));
}
#[test]
fn test_matching_route_paths_mint_only() {
// Regex that matches only mint paths
let paths = matching_route_paths("^/v1/mint/.*").unwrap();
// Should match only mint paths
assert_eq!(paths.len(), 2);
assert!(paths.contains(&RoutePath::MintQuoteBolt11));
assert!(paths.contains(&RoutePath::MintBolt11));
// Should not match other paths
assert!(!paths.contains(&RoutePath::MeltQuoteBolt11));
assert!(!paths.contains(&RoutePath::MeltBolt11));
assert!(!paths.contains(&RoutePath::Swap));
}
#[test]
fn test_matching_route_paths_quote_only() {
// Regex that matches only quote paths
let paths = matching_route_paths(".*/quote/.*").unwrap();
// Should match only quote paths
assert_eq!(paths.len(), 2);
assert!(paths.contains(&RoutePath::MintQuoteBolt11));
assert!(paths.contains(&RoutePath::MeltQuoteBolt11));
// Should not match non-quote paths
assert!(!paths.contains(&RoutePath::MintBolt11));
assert!(!paths.contains(&RoutePath::MeltBolt11));
}
#[test]
fn test_matching_route_paths_no_match() {
// Regex that matches nothing
let paths = matching_route_paths("/nonexistent/path").unwrap();
// Should match nothing
assert!(paths.is_empty());
}
#[test]
fn test_matching_route_paths_quote_bolt11_only() {
// Regex that matches only quote paths
let paths = matching_route_paths("/v1/mint/quote/bolt11").unwrap();
// Should match only quote paths
assert_eq!(paths.len(), 1);
assert!(paths.contains(&RoutePath::MintQuoteBolt11));
}
#[test]
fn test_matching_route_paths_invalid_regex() {
// Invalid regex pattern
let result = matching_route_paths("(unclosed parenthesis");
// Should return an error for invalid regex
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::InvalidRegex(_)));
}
#[test]
fn test_route_path_to_string() {
// Test that to_string() returns the correct path strings
assert_eq!(
RoutePath::MintQuoteBolt11.to_string(),
"/v1/mint/quote/bolt11"
);
assert_eq!(RoutePath::MintBolt11.to_string(), "/v1/mint/bolt11");
assert_eq!(
RoutePath::MeltQuoteBolt11.to_string(),
"/v1/melt/quote/bolt11"
);
assert_eq!(RoutePath::MeltBolt11.to_string(), "/v1/melt/bolt11");
assert_eq!(RoutePath::Swap.to_string(), "/v1/swap");
assert_eq!(RoutePath::Checkstate.to_string(), "/v1/checkstate");
assert_eq!(RoutePath::Restore.to_string(), "/v1/restore");
assert_eq!(RoutePath::MintBlindAuth.to_string(), "/v1/auth/blind/mint");
}
#[test]
fn test_settings_deserialize_direct_paths() {
let json = r#"{
"openid_discovery": "https://example.com/.well-known/openid-configuration",
"client_id": "client123",
"protected_endpoints": [
{
"method": "GET",
"path": "/v1/mint/bolt11"
},
{
"method": "POST",
"path": "/v1/swap"
}
]
}"#;
let settings: Settings = serde_json::from_str(json).unwrap();
assert_eq!(
settings.openid_discovery,
"https://example.com/.well-known/openid-configuration"
);
assert_eq!(settings.client_id, "client123");
assert_eq!(settings.protected_endpoints.len(), 2);
// Check that both paths are included
let paths = settings
.protected_endpoints
.iter()
.map(|ep| (ep.method, ep.path))
.collect::<Vec<_>>();
assert!(paths.contains(&(Method::Get, RoutePath::MintBolt11)));
assert!(paths.contains(&(Method::Post, RoutePath::Swap)));
}
#[test]
fn test_settings_deserialize_with_regex() {
let json = r#"{
"openid_discovery": "https://example.com/.well-known/openid-configuration",
"client_id": "client123",
"protected_endpoints": [
{
"method": "GET",
"path": "^/v1/mint/.*"
},
{
"method": "POST",
"path": "/v1/swap"
}
]
}"#;
let settings: Settings = serde_json::from_str(json).unwrap();
assert_eq!(
settings.openid_discovery,
"https://example.com/.well-known/openid-configuration"
);
assert_eq!(settings.client_id, "client123");
assert_eq!(settings.protected_endpoints.len(), 3); // 2 mint paths + 1 swap path
let expected_protected: HashSet<ProtectedEndpoint> = HashSet::from_iter(vec![
ProtectedEndpoint::new(Method::Post, RoutePath::Swap),
ProtectedEndpoint::new(Method::Get, RoutePath::MintBolt11),
ProtectedEndpoint::new(Method::Get, RoutePath::MintQuoteBolt11),
]);
let deserlized_protected = settings.protected_endpoints.into_iter().collect();
assert_eq!(expected_protected, deserlized_protected);
}
#[test]
fn test_settings_deserialize_invalid_regex() {
let json = r#"{
"openid_discovery": "https://example.com/.well-known/openid-configuration",
"client_id": "client123",
"protected_endpoints": [
{
"method": "GET",
"path": "(unclosed parenthesis"
}
]
}"#;
let result = serde_json::from_str::<Settings>(json);
assert!(result.is_err());
}
#[test]
fn test_settings_deserialize_exact_path_match() {
let json = r#"{
"openid_discovery": "https://example.com/.well-known/openid-configuration",
"client_id": "client123",
"protected_endpoints": [
{
"method": "GET",
"path": "/v1/mint/quote/bolt11"
}
]
}"#;
let settings: Settings = serde_json::from_str(json).unwrap();
assert_eq!(settings.protected_endpoints.len(), 1);
assert_eq!(settings.protected_endpoints[0].method, Method::Get);
assert_eq!(
settings.protected_endpoints[0].path,
RoutePath::MintQuoteBolt11
);
}
#[test]
fn test_settings_deserialize_all_paths() {
let json = r#"{
"openid_discovery": "https://example.com/.well-known/openid-configuration",
"client_id": "client123",
"protected_endpoints": [
{
"method": "GET",
"path": ".*"
}
]
}"#;
let settings: Settings = serde_json::from_str(json).unwrap();
assert_eq!(
settings.protected_endpoints.len(),
RoutePath::iter().count()
);
}
}

View File

@@ -0,0 +1,369 @@
//! 22 Blind Auth
use std::fmt;
use bitcoin::base64::engine::general_purpose;
use bitcoin::base64::Engine;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use super::nut21::ProtectedEndpoint;
use crate::dhke::hash_to_curve;
use crate::secret::Secret;
use crate::util::hex;
use crate::{BlindedMessage, Id, Proof, ProofDleq, PublicKey};
/// NUT22 Error
#[derive(Debug, Error)]
pub enum Error {
/// Invalid Prefix
#[error("Invalid prefix")]
InvalidPrefix,
/// Dleq proof not included
#[error("Dleq Proof not included for auth proof")]
DleqProofNotIncluded,
/// Hex Error
#[error(transparent)]
HexError(#[from] hex::Error),
/// Base64 error
#[error(transparent)]
Base64Error(#[from] bitcoin::base64::DecodeError),
/// Serde Json error
#[error(transparent)]
SerdeJsonError(#[from] serde_json::Error),
/// Utf8 parse error
#[error(transparent)]
Utf8ParseError(#[from] std::string::FromUtf8Error),
/// DHKE error
#[error(transparent)]
DHKE(#[from] crate::dhke::Error),
}
/// Blind auth settings
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize)]
#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
pub struct Settings {
/// Max number of blind auth tokens that can be minted per request
pub bat_max_mint: u64,
/// Protected endpoints
pub protected_endpoints: Vec<ProtectedEndpoint>,
}
impl Settings {
/// Create new [`Settings`]
pub fn new(bat_max_mint: u64, protected_endpoints: Vec<ProtectedEndpoint>) -> Self {
Self {
bat_max_mint,
protected_endpoints,
}
}
}
// Custom deserializer for Settings to expand regex patterns in protected endpoints
impl<'de> Deserialize<'de> for Settings {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use std::collections::HashSet;
use super::nut21::matching_route_paths;
// Define a temporary struct to deserialize the raw data
#[derive(Deserialize)]
struct RawSettings {
bat_max_mint: u64,
protected_endpoints: Vec<RawProtectedEndpoint>,
}
#[derive(Deserialize)]
struct RawProtectedEndpoint {
method: super::nut21::Method,
path: String,
}
// Deserialize into the temporary struct
let raw = RawSettings::deserialize(deserializer)?;
// Process protected endpoints, expanding regex patterns if present
let mut protected_endpoints = HashSet::new();
for raw_endpoint in raw.protected_endpoints {
let expanded_paths = matching_route_paths(&raw_endpoint.path).map_err(|e| {
serde::de::Error::custom(format!(
"Invalid regex pattern '{}': {}",
raw_endpoint.path, e
))
})?;
for path in expanded_paths {
protected_endpoints.insert(super::nut21::ProtectedEndpoint::new(
raw_endpoint.method,
path,
));
}
}
// Create the final Settings struct
Ok(Settings {
bat_max_mint: raw.bat_max_mint,
protected_endpoints: protected_endpoints.into_iter().collect(),
})
}
}
/// Auth Token
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AuthToken {
/// Clear Auth token
ClearAuth(String),
/// Blind Auth token
BlindAuth(BlindAuthToken),
}
impl fmt::Display for AuthToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ClearAuth(cat) => cat.fmt(f),
Self::BlindAuth(bat) => bat.fmt(f),
}
}
}
impl AuthToken {
/// Header key for auth token type
pub fn header_key(&self) -> String {
match self {
Self::ClearAuth(_) => "Clear-auth".to_string(),
Self::BlindAuth(_) => "Blind-auth".to_string(),
}
}
}
/// Required Auth
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AuthRequired {
/// Clear Auth token
Clear,
/// Blind Auth token
Blind,
}
/// Auth Proofs
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
pub struct AuthProof {
/// `Keyset id`
#[serde(rename = "id")]
pub keyset_id: Id,
/// Secret message
#[cfg_attr(feature = "swagger", schema(value_type = String))]
pub secret: Secret,
/// Unblinded signature
#[serde(rename = "C")]
#[cfg_attr(feature = "swagger", schema(value_type = String))]
pub c: PublicKey,
/// Auth Proof Dleq
pub dleq: ProofDleq,
}
impl AuthProof {
/// Y of AuthProof
pub fn y(&self) -> Result<PublicKey, Error> {
Ok(hash_to_curve(self.secret.as_bytes())?)
}
}
impl From<AuthProof> for Proof {
fn from(value: AuthProof) -> Self {
Self {
amount: 1.into(),
keyset_id: value.keyset_id,
secret: value.secret,
c: value.c,
witness: None,
dleq: Some(value.dleq),
}
}
}
impl TryFrom<Proof> for AuthProof {
type Error = Error;
fn try_from(value: Proof) -> Result<Self, Self::Error> {
Ok(Self {
keyset_id: value.keyset_id,
secret: value.secret,
c: value.c,
dleq: value.dleq.ok_or({
tracing::warn!("Dleq proof not included in auth");
Error::DleqProofNotIncluded
})?,
})
}
}
/// Blind Auth Token
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BlindAuthToken {
/// [AuthProof]
pub auth_proof: AuthProof,
}
impl BlindAuthToken {
/// Create new [ `BlindAuthToken`]
pub fn new(auth_proof: AuthProof) -> Self {
BlindAuthToken { auth_proof }
}
}
impl fmt::Display for BlindAuthToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let json_string = serde_json::to_string(&self.auth_proof).map_err(|_| fmt::Error)?;
let encoded = general_purpose::URL_SAFE.encode(json_string);
write!(f, "authA{}", encoded)
}
}
impl std::str::FromStr for BlindAuthToken {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
// Check prefix and extract the base64 encoded part in one step
let encoded = s.strip_prefix("authA").ok_or(Error::InvalidPrefix)?;
// Decode the base64 URL-safe string
let json_string = general_purpose::URL_SAFE.decode(encoded)?;
// Convert bytes to UTF-8 string
let json_str = String::from_utf8(json_string)?;
// Deserialize the JSON string into AuthProof
let auth_proof: AuthProof = serde_json::from_str(&json_str)?;
Ok(BlindAuthToken { auth_proof })
}
}
/// Mint auth request [NUT-XX]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
pub struct MintAuthRequest {
/// Outputs
#[cfg_attr(feature = "swagger", schema(max_items = 1_000))]
pub outputs: Vec<BlindedMessage>,
}
impl MintAuthRequest {
/// Count of tokens
pub fn amount(&self) -> u64 {
self.outputs.len() as u64
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use strum::IntoEnumIterator;
use super::super::nut21::{Method, RoutePath};
use super::*;
#[test]
fn test_settings_deserialize_direct_paths() {
let json = r#"{
"bat_max_mint": 10,
"protected_endpoints": [
{
"method": "GET",
"path": "/v1/mint/bolt11"
},
{
"method": "POST",
"path": "/v1/swap"
}
]
}"#;
let settings: Settings = serde_json::from_str(json).unwrap();
assert_eq!(settings.bat_max_mint, 10);
assert_eq!(settings.protected_endpoints.len(), 2);
// Check that both paths are included
let paths = settings
.protected_endpoints
.iter()
.map(|ep| (ep.method, ep.path))
.collect::<Vec<_>>();
assert!(paths.contains(&(Method::Get, RoutePath::MintBolt11)));
assert!(paths.contains(&(Method::Post, RoutePath::Swap)));
}
#[test]
fn test_settings_deserialize_with_regex() {
let json = r#"{
"bat_max_mint": 5,
"protected_endpoints": [
{
"method": "GET",
"path": "^/v1/mint/.*"
},
{
"method": "POST",
"path": "/v1/swap"
}
]
}"#;
let settings: Settings = serde_json::from_str(json).unwrap();
assert_eq!(settings.bat_max_mint, 5);
assert_eq!(settings.protected_endpoints.len(), 3); // 2 mint paths + 1 swap path
let expected_protected: HashSet<ProtectedEndpoint> = HashSet::from_iter(vec![
ProtectedEndpoint::new(Method::Post, RoutePath::Swap),
ProtectedEndpoint::new(Method::Get, RoutePath::MintBolt11),
ProtectedEndpoint::new(Method::Get, RoutePath::MintQuoteBolt11),
]);
let deserialized_protected = settings.protected_endpoints.into_iter().collect();
assert_eq!(expected_protected, deserialized_protected);
}
#[test]
fn test_settings_deserialize_invalid_regex() {
let json = r#"{
"bat_max_mint": 5,
"protected_endpoints": [
{
"method": "GET",
"path": "(unclosed parenthesis"
}
]
}"#;
let result = serde_json::from_str::<Settings>(json);
assert!(result.is_err());
}
#[test]
fn test_settings_deserialize_all_paths() {
let json = r#"{
"bat_max_mint": 5,
"protected_endpoints": [
{
"method": "GET",
"path": ".*"
}
]
}"#;
let settings: Settings = serde_json::from_str(json).unwrap();
assert_eq!(
settings.protected_endpoints.len(),
RoutePath::iter().count()
);
}
}

View File

@@ -24,6 +24,14 @@ pub mod nut18;
pub mod nut19; pub mod nut19;
pub mod nut20; pub mod nut20;
#[cfg(feature = "auth")]
mod auth;
#[cfg(feature = "auth")]
pub use auth::{
nut21, nut22, AuthProof, AuthRequired, AuthToken, BlindAuthSettings, BlindAuthToken,
ClearAuthSettings, Method, MintAuthRequest, ProtectedEndpoint, RoutePath,
};
pub use nut00::{ pub use nut00::{
BlindSignature, BlindedMessage, CurrencyUnit, PaymentMethod, Proof, Proofs, ProofsMethods, BlindSignature, BlindedMessage, CurrencyUnit, PaymentMethod, Proof, Proofs, ProofsMethods,
Token, TokenV3, TokenV4, Witness, Token, TokenV3, TokenV4, Witness,

View File

@@ -445,6 +445,8 @@ pub enum CurrencyUnit {
Usd, Usd,
/// Euro /// Euro
Eur, Eur,
/// Auth
Auth,
/// Custom currency unit /// Custom currency unit
Custom(String), Custom(String),
} }
@@ -458,6 +460,7 @@ impl CurrencyUnit {
Self::Msat => Some(1), Self::Msat => Some(1),
Self::Usd => Some(2), Self::Usd => Some(2),
Self::Eur => Some(3), Self::Eur => Some(3),
Self::Auth => Some(4),
_ => None, _ => None,
} }
} }
@@ -472,6 +475,7 @@ impl FromStr for CurrencyUnit {
"MSAT" => Ok(Self::Msat), "MSAT" => Ok(Self::Msat),
"USD" => Ok(Self::Usd), "USD" => Ok(Self::Usd),
"EUR" => Ok(Self::Eur), "EUR" => Ok(Self::Eur),
"AUTH" => Ok(Self::Auth),
c => Ok(Self::Custom(c.to_string())), c => Ok(Self::Custom(c.to_string())),
} }
} }
@@ -484,6 +488,7 @@ impl fmt::Display for CurrencyUnit {
CurrencyUnit::Msat => "MSAT", CurrencyUnit::Msat => "MSAT",
CurrencyUnit::Usd => "USD", CurrencyUnit::Usd => "USD",
CurrencyUnit::Eur => "EUR", CurrencyUnit::Eur => "EUR",
CurrencyUnit::Auth => "AUTH",
CurrencyUnit::Custom(unit) => unit, CurrencyUnit::Custom(unit) => unit,
}; };
if let Some(width) = f.width() { if let Some(width) = f.width() {

View File

@@ -16,7 +16,7 @@ use bitcoin::hashes::Hash;
use bitcoin::key::Secp256k1; use bitcoin::key::Secp256k1;
#[cfg(feature = "mint")] #[cfg(feature = "mint")]
use bitcoin::secp256k1; use bitcoin::secp256k1;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Deserializer, Serialize};
use serde_with::{serde_as, VecSkipError}; use serde_with::{serde_as, VecSkipError};
use thiserror::Error; use thiserror::Error;
@@ -49,6 +49,7 @@ pub enum Error {
/// Keyset version /// Keyset version
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
pub enum KeySetVersion { pub enum KeySetVersion {
/// Current Version 00 /// Current Version 00
Version00, Version00,
@@ -85,6 +86,7 @@ impl fmt::Display for KeySetVersion {
/// which mint or keyset it was generated from. /// which mint or keyset it was generated from.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")] #[serde(into = "String", try_from = "String")]
#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
pub struct Id { pub struct Id {
version: KeySetVersion, version: KeySetVersion,
id: [u8; Self::BYTELEN], id: [u8; Self::BYTELEN],
@@ -258,10 +260,22 @@ pub struct KeySetInfo {
/// Mint will only sign from an active keyset /// Mint will only sign from an active keyset
pub active: bool, pub active: bool,
/// Input Fee PPK /// Input Fee PPK
#[serde(default = "default_input_fee_ppk")] #[serde(
deserialize_with = "deserialize_input_fee_ppk",
default = "default_input_fee_ppk"
)]
pub input_fee_ppk: u64, pub input_fee_ppk: u64,
} }
fn deserialize_input_fee_ppk<'de, D>(deserializer: D) -> Result<u64, D::Error>
where
D: Deserializer<'de>,
{
// This will either give us a u64 or null (which becomes None)
let opt = Option::<u64>::deserialize(deserializer)?;
Ok(opt.unwrap_or_else(default_input_fee_ppk))
}
fn default_input_fee_ppk() -> u64 { fn default_input_fee_ppk() -> u64 {
0 0
} }
@@ -484,6 +498,10 @@ mod test {
let h = r#"{"id":"009a1f293253e41e","unit":"sat","active":true}"#; let h = r#"{"id":"009a1f293253e41e","unit":"sat","active":true}"#;
let _keyset_response: KeySetInfo = serde_json::from_str(h).unwrap(); let _keyset_response: KeySetInfo = serde_json::from_str(h).unwrap();
let h = r#"{"id":"009a1f293253e41e","unit":"sat","active":true, "input_fee_ppk":null}"#;
let _keyset_response: KeySetInfo = serde_json::from_str(h).unwrap();
} }
#[test] #[test]

View File

@@ -2,12 +2,17 @@
//! //!
//! <https://github.com/cashubtc/nuts/blob/main/06.md> //! <https://github.com/cashubtc/nuts/blob/main/06.md>
#[cfg(feature = "auth")]
use std::collections::HashMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde::{Deserialize, Deserializer, Serialize, Serializer};
use super::nut01::PublicKey; use super::nut01::PublicKey;
use super::nut17::SupportedMethods; use super::nut17::SupportedMethods;
use super::nut19::CachedEndpoint; use super::nut19::CachedEndpoint;
use super::{nut04, nut05, nut15, nut19, MppMethodSettings}; use super::{nut04, nut05, nut15, nut19, MppMethodSettings};
#[cfg(feature = "auth")]
use super::{AuthRequired, BlindAuthSettings, ClearAuthSettings, ProtectedEndpoint};
/// Mint Version /// Mint Version
#[derive(Debug, Clone, PartialEq, Eq, Hash)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
@@ -211,6 +216,46 @@ impl MintInfo {
..self ..self
} }
} }
/// Get protected endpoints
#[cfg(feature = "auth")]
pub fn protected_endpoints(&self) -> HashMap<ProtectedEndpoint, AuthRequired> {
let mut protected_endpoints = HashMap::new();
if let Some(nut21_settings) = &self.nuts.nut21 {
for endpoint in nut21_settings.protected_endpoints.iter() {
protected_endpoints.insert(*endpoint, AuthRequired::Clear);
}
}
if let Some(nut22_settings) = &self.nuts.nut22 {
for endpoint in nut22_settings.protected_endpoints.iter() {
protected_endpoints.insert(*endpoint, AuthRequired::Blind);
}
}
protected_endpoints
}
/// Get Openid discovery of the mint if it is set
#[cfg(feature = "auth")]
pub fn openid_discovery(&self) -> Option<String> {
self.nuts
.nut21
.as_ref()
.map(|s| s.openid_discovery.to_string())
}
/// Get Openid discovery of the mint if it is set
#[cfg(feature = "auth")]
pub fn client_id(&self) -> Option<String> {
self.nuts.nut21.as_ref().map(|s| s.client_id.clone())
}
/// Max bat mint
#[cfg(feature = "auth")]
pub fn bat_max_mint(&self) -> Option<u64> {
self.nuts.nut22.as_ref().map(|s| s.bat_max_mint)
}
} }
/// Supported nuts and settings /// Supported nuts and settings
@@ -269,6 +314,16 @@ pub struct Nuts {
#[serde(default)] #[serde(default)]
#[serde(rename = "20")] #[serde(rename = "20")]
pub nut20: SupportedSettings, pub nut20: SupportedSettings,
/// NUT21 Settings
#[serde(rename = "21")]
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg(feature = "auth")]
pub nut21: Option<ClearAuthSettings>,
/// NUT22 Settings
#[serde(rename = "22")]
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg(feature = "auth")]
pub nut22: Option<BlindAuthSettings>,
} }
impl Nuts { impl Nuts {

View File

@@ -8,9 +8,12 @@ repository = "https://github.com/cashubtc/cdk.git"
rust-version = "1.75.0" # MSRV rust-version = "1.75.0" # MSRV
description = "Cashu CDK axum webserver" description = "Cashu CDK axum webserver"
[features] [features]
default = ["auth"]
redis = ["dep:redis"] redis = ["dep:redis"]
swagger = ["cdk/swagger", "dep:utoipa"] swagger = ["cdk/swagger", "dep:utoipa"]
auth = ["cdk/auth"]
[dependencies] [dependencies]
anyhow.workspace = true anyhow.workspace = true

194
crates/cdk-axum/src/auth.rs Normal file
View File

@@ -0,0 +1,194 @@
use std::str::FromStr;
use axum::extract::{FromRequestParts, State};
use axum::http::request::Parts;
use axum::http::StatusCode;
use axum::response::Response;
use axum::routing::{get, post};
use axum::{Json, Router};
#[cfg(feature = "swagger")]
use cdk::error::ErrorResponse;
use cdk::nuts::{
AuthToken, BlindAuthToken, KeysResponse, KeysetResponse, MintAuthRequest, MintBolt11Response,
};
use serde::{Deserialize, Serialize};
#[cfg(feature = "auth")]
use crate::{get_keyset_pubkeys, into_response, MintState};
const CLEAR_AUTH_KEY: &str = "Clear-auth";
const BLIND_AUTH_KEY: &str = "Blind-auth";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AuthHeader {
/// Clear Auth token
Clear(String),
/// Blind Auth token
Blind(BlindAuthToken),
/// No auth
None,
}
impl From<AuthHeader> for Option<AuthToken> {
fn from(value: AuthHeader) -> Option<AuthToken> {
match value {
AuthHeader::Clear(token) => Some(AuthToken::ClearAuth(token)),
AuthHeader::Blind(token) => Some(AuthToken::BlindAuth(token)),
AuthHeader::None => None,
}
}
}
impl<S> FromRequestParts<S> for AuthHeader
where
S: Send + Sync,
{
type Rejection = (StatusCode, String);
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
// Check for Blind-auth header
if let Some(bat) = parts.headers.get(BLIND_AUTH_KEY) {
let token = bat
.to_str()
.map_err(|_| {
(
StatusCode::BAD_REQUEST,
"Invalid Blind-auth header value".to_string(),
)
})?
.to_string();
let token = BlindAuthToken::from_str(&token).map_err(|_| {
(
StatusCode::BAD_REQUEST,
"Invalid Blind-auth header value".to_string(),
)
})?;
return Ok(AuthHeader::Blind(token));
}
// Check for Clear-auth header
if let Some(cat) = parts.headers.get(CLEAR_AUTH_KEY) {
let token = cat
.to_str()
.map_err(|_| {
(
StatusCode::BAD_REQUEST,
"Invalid Clear-auth header value".to_string(),
)
})?
.to_string();
return Ok(AuthHeader::Clear(token));
}
// No authentication headers found - this is now valid
Ok(AuthHeader::None)
}
}
#[cfg_attr(feature = "swagger", utoipa::path(
get,
context_path = "/v1/auth/blind",
path = "/keysets",
responses(
(status = 200, description = "Successful response", body = KeysetResponse, content_type = "application/json"),
(status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
)
))]
/// Get all active keyset IDs of the mint
///
/// This endpoint returns a list of keysets that the mint currently supports and will accept tokens from.
#[cfg(feature = "auth")]
pub async fn get_auth_keysets(
State(state): State<MintState>,
) -> Result<Json<KeysetResponse>, Response> {
let keysets = state.mint.auth_keysets().await.map_err(|err| {
tracing::error!("Could not get keysets: {}", err);
into_response(err)
})?;
Ok(Json(keysets))
}
#[cfg_attr(feature = "swagger", utoipa::path(
get,
context_path = "/v1/auth/blind",
path = "/keys",
responses(
(status = 200, description = "Successful response", body = KeysResponse, content_type = "application/json")
)
))]
/// Get the public keys of the newest blind auth mint keyset
///
/// This endpoint returns a dictionary of all supported token values of the mint and their associated public key.
pub async fn get_blind_auth_keys(
State(state): State<MintState>,
) -> Result<Json<KeysResponse>, Response> {
let pubkeys = state.mint.auth_pubkeys().await.map_err(|err| {
tracing::error!("Could not get keys: {}", err);
into_response(err)
})?;
Ok(Json(pubkeys))
}
/// Mint tokens by paying a BOLT11 Lightning invoice.
///
/// Requests the minting of tokens belonging to a paid payment request.
///
/// Call this endpoint after `POST /v1/mint/quote`.
#[cfg_attr(feature = "swagger", utoipa::path(
post,
context_path = "/v1/auth",
path = "/blind/mint",
request_body(content = MintAuthRequest, description = "Request params", content_type = "application/json"),
responses(
(status = 200, description = "Successful response", body = MintBolt11Response, content_type = "application/json"),
(status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
)
))]
pub async fn post_mint_auth(
auth: AuthHeader,
State(state): State<MintState>,
Json(payload): Json<MintAuthRequest>,
) -> Result<Json<MintBolt11Response>, Response> {
let auth_token = match auth {
AuthHeader::Clear(cat) => {
if cat.is_empty() {
tracing::debug!("Received blind auth mint request without cat");
return Err(into_response(cdk::Error::ClearAuthRequired));
}
AuthToken::ClearAuth(cat)
}
_ => {
tracing::debug!("Received blind auth mint request without cat");
return Err(into_response(cdk::Error::ClearAuthRequired));
}
};
let res = state
.mint
.mint_blind_auth(auth_token, payload)
.await
.map_err(|err| {
tracing::error!("Could not process blind auth mint: {}", err);
into_response(err)
})?;
Ok(Json(res))
}
pub fn create_auth_router(state: MintState) -> Router<MintState> {
Router::new()
.nest(
"/auth/blind",
Router::new()
.route("/keys", get(get_blind_auth_keys))
.route("/keysets", get(get_auth_keysets))
.route("/keys/{keyset_id}", get(get_keyset_pubkeys))
.route("/mint", post(post_mint_auth)),
)
.with_state(state)
}

View File

@@ -6,12 +6,16 @@
use std::sync::Arc; use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
#[cfg(feature = "auth")]
use auth::create_auth_router;
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::Router; use axum::Router;
use cache::HttpCache; use cache::HttpCache;
use cdk::mint::Mint; use cdk::mint::Mint;
use router_handlers::*; use router_handlers::*;
#[cfg(feature = "auth")]
mod auth;
pub mod cache; pub mod cache;
mod router_handlers; mod router_handlers;
mod ws; mod ws;
@@ -165,7 +169,15 @@ pub async fn create_mint_router_with_custom_cache(
.route("/info", get(get_mint_info)) .route("/info", get(get_mint_info))
.route("/restore", post(post_restore)); .route("/restore", post(post_restore));
let mint_router = Router::new().nest("/v1", v1_router).with_state(state); let mint_router = Router::new().nest("/v1", v1_router);
#[cfg(feature = "auth")]
let mint_router = {
let auth_router = create_auth_router(state.clone());
mint_router.nest("/v1", auth_router)
};
let mint_router = mint_router.with_state(state);
Ok(mint_router) Ok(mint_router)
} }

View File

@@ -4,6 +4,8 @@ use axum::extract::{Json, Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use cdk::error::ErrorResponse; use cdk::error::ErrorResponse;
#[cfg(feature = "auth")]
use cdk::nuts::nut21::{Method, ProtectedEndpoint, RoutePath};
use cdk::nuts::{ use cdk::nuts::{
CheckStateRequest, CheckStateResponse, Id, KeysResponse, KeysetResponse, MeltBolt11Request, CheckStateRequest, CheckStateResponse, Id, KeysResponse, KeysetResponse, MeltBolt11Request,
MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintBolt11Request, MintBolt11Response, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintBolt11Request, MintBolt11Response,
@@ -15,6 +17,8 @@ use paste::paste;
use tracing::instrument; use tracing::instrument;
use uuid::Uuid; use uuid::Uuid;
#[cfg(feature = "auth")]
use crate::auth::AuthHeader;
use crate::ws::main_websocket; use crate::ws::main_websocket;
use crate::MintState; use crate::MintState;
@@ -24,25 +28,29 @@ macro_rules! post_cache_wrapper {
/// Cache wrapper function for $handler: /// Cache wrapper function for $handler:
/// Wrap $handler into a function that caches responses using the request as key /// Wrap $handler into a function that caches responses using the request as key
pub async fn [<cache_ $handler>]( pub async fn [<cache_ $handler>](
#[cfg(feature = "auth")] auth: AuthHeader,
state: State<MintState>, state: State<MintState>,
payload: Json<$request_type> payload: Json<$request_type>
) -> Result<Json<$response_type>, Response> { ) -> Result<Json<$response_type>, Response> {
use std::ops::Deref; use std::ops::Deref;
let json_extracted_payload = payload.deref(); let json_extracted_payload = payload.deref();
let State(mint_state) = state.clone(); let State(mint_state) = state.clone();
let cache_key = match mint_state.cache.calculate_key(&json_extracted_payload) { let cache_key = match mint_state.cache.calculate_key(&json_extracted_payload) {
Some(key) => key, Some(key) => key,
None => { None => {
// Could not calculate key, just return the handler result // Could not calculate key, just return the handler result
#[cfg(feature = "auth")]
return $handler(auth, state, payload).await;
#[cfg(not(feature = "auth"))]
return $handler( state, payload).await; return $handler( state, payload).await;
} }
}; };
if let Some(cached_response) = mint_state.cache.get::<$response_type>(&cache_key).await { if let Some(cached_response) = mint_state.cache.get::<$response_type>(&cache_key).await {
return Ok(Json(cached_response)); return Ok(Json(cached_response));
} }
#[cfg(feature = "auth")]
let response = $handler(auth, state, payload).await?;
#[cfg(not(feature = "auth"))]
let response = $handler(state, payload).await?; let response = $handler(state, payload).await?;
mint_state.cache.set(cache_key, &response.deref()).await; mint_state.cache.set(cache_key, &response.deref()).await;
Ok(response) Ok(response)
@@ -74,7 +82,10 @@ post_cache_wrapper!(
/// Get the public keys of the newest mint keyset /// Get the public keys of the newest mint keyset
/// ///
/// This endpoint returns a dictionary of all supported token values of the mint and their associated public key. /// This endpoint returns a dictionary of all supported token values of the mint and their associated public key.
pub async fn get_keys(State(state): State<MintState>) -> Result<Json<KeysResponse>, Response> { #[instrument(skip_all)]
pub(crate) async fn get_keys(
State(state): State<MintState>,
) -> Result<Json<KeysResponse>, Response> {
let pubkeys = state.mint.pubkeys().await.map_err(|err| { let pubkeys = state.mint.pubkeys().await.map_err(|err| {
tracing::error!("Could not get keys: {}", err); tracing::error!("Could not get keys: {}", err);
into_response(err) into_response(err)
@@ -98,7 +109,8 @@ pub async fn get_keys(State(state): State<MintState>) -> Result<Json<KeysRespons
/// Get the public keys of a specific keyset /// Get the public keys of a specific keyset
/// ///
/// Get the public keys of the mint from a specific keyset ID. /// Get the public keys of the mint from a specific keyset ID.
pub async fn get_keyset_pubkeys( #[instrument(skip_all, fields(keyset_id = ?keyset_id))]
pub(crate) async fn get_keyset_pubkeys(
State(state): State<MintState>, State(state): State<MintState>,
Path(keyset_id): Path<Id>, Path(keyset_id): Path<Id>,
) -> Result<Json<KeysResponse>, Response> { ) -> Result<Json<KeysResponse>, Response> {
@@ -122,7 +134,10 @@ pub async fn get_keyset_pubkeys(
/// Get all active keyset IDs of the mint /// Get all active keyset IDs of the mint
/// ///
/// This endpoint returns a list of keysets that the mint currently supports and will accept tokens from. /// This endpoint returns a list of keysets that the mint currently supports and will accept tokens from.
pub async fn get_keysets(State(state): State<MintState>) -> Result<Json<KeysetResponse>, Response> { #[instrument(skip_all)]
pub(crate) async fn get_keysets(
State(state): State<MintState>,
) -> Result<Json<KeysetResponse>, Response> {
let keysets = state.mint.keysets().await.map_err(|err| { let keysets = state.mint.keysets().await.map_err(|err| {
tracing::error!("Could not get keysets: {}", err); tracing::error!("Could not get keysets: {}", err);
into_response(err) into_response(err)
@@ -144,10 +159,22 @@ pub async fn get_keysets(State(state): State<MintState>) -> Result<Json<KeysetRe
/// Request a quote for minting of new tokens /// Request a quote for minting of new tokens
/// ///
/// Request minting of new tokens. The mint responds with a Lightning invoice. This endpoint can be used for a Lightning invoice UX flow. /// Request minting of new tokens. The mint responds with a Lightning invoice. This endpoint can be used for a Lightning invoice UX flow.
pub async fn post_mint_bolt11_quote( #[instrument(skip_all, fields(amount = ?payload.amount))]
pub(crate) async fn post_mint_bolt11_quote(
#[cfg(feature = "auth")] auth: AuthHeader,
State(state): State<MintState>, State(state): State<MintState>,
Json(payload): Json<MintQuoteBolt11Request>, Json(payload): Json<MintQuoteBolt11Request>,
) -> Result<Json<MintQuoteBolt11Response<Uuid>>, Response> { ) -> Result<Json<MintQuoteBolt11Response<Uuid>>, Response> {
#[cfg(feature = "auth")]
state
.mint
.verify_auth(
auth.into(),
&ProtectedEndpoint::new(Method::Post, RoutePath::MintQuoteBolt11),
)
.await
.map_err(into_response)?;
let quote = state let quote = state
.mint .mint
.get_mint_bolt11_quote(payload) .get_mint_bolt11_quote(payload)
@@ -172,10 +199,24 @@ pub async fn post_mint_bolt11_quote(
/// Get mint quote by ID /// Get mint quote by ID
/// ///
/// Get mint quote state. /// Get mint quote state.
pub async fn get_check_mint_bolt11_quote( #[instrument(skip_all, fields(quote_id = ?quote_id))]
pub(crate) async fn get_check_mint_bolt11_quote(
#[cfg(feature = "auth")] auth: AuthHeader,
State(state): State<MintState>, State(state): State<MintState>,
Path(quote_id): Path<Uuid>, Path(quote_id): Path<Uuid>,
) -> Result<Json<MintQuoteBolt11Response<Uuid>>, Response> { ) -> Result<Json<MintQuoteBolt11Response<Uuid>>, Response> {
#[cfg(feature = "auth")]
{
state
.mint
.verify_auth(
auth.into(),
&ProtectedEndpoint::new(Method::Get, RoutePath::MintQuoteBolt11),
)
.await
.map_err(into_response)?;
}
let quote = state let quote = state
.mint .mint
.check_mint_quote(&quote_id) .check_mint_quote(&quote_id)
@@ -188,7 +229,11 @@ pub async fn get_check_mint_bolt11_quote(
Ok(Json(quote)) Ok(Json(quote))
} }
pub async fn ws_handler(State(state): State<MintState>, ws: WebSocketUpgrade) -> impl IntoResponse { #[instrument(skip_all)]
pub(crate) async fn ws_handler(
State(state): State<MintState>,
ws: WebSocketUpgrade,
) -> impl IntoResponse {
ws.on_upgrade(|ws| main_websocket(ws, state)) ws.on_upgrade(|ws| main_websocket(ws, state))
} }
@@ -207,10 +252,24 @@ pub async fn ws_handler(State(state): State<MintState>, ws: WebSocketUpgrade) ->
(status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json") (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
) )
))] ))]
pub async fn post_mint_bolt11( #[instrument(skip_all, fields(quote_id = ?payload.quote))]
pub(crate) async fn post_mint_bolt11(
#[cfg(feature = "auth")] auth: AuthHeader,
State(state): State<MintState>, State(state): State<MintState>,
Json(payload): Json<MintBolt11Request<Uuid>>, Json(payload): Json<MintBolt11Request<Uuid>>,
) -> Result<Json<MintBolt11Response>, Response> { ) -> Result<Json<MintBolt11Response>, Response> {
#[cfg(feature = "auth")]
{
state
.mint
.verify_auth(
auth.into(),
&ProtectedEndpoint::new(Method::Post, RoutePath::MintBolt11),
)
.await
.map_err(into_response)?;
}
let res = state let res = state
.mint .mint
.process_mint_request(payload) .process_mint_request(payload)
@@ -235,10 +294,23 @@ pub async fn post_mint_bolt11(
))] ))]
#[instrument(skip_all)] #[instrument(skip_all)]
/// Request a quote for melting tokens /// Request a quote for melting tokens
pub async fn post_melt_bolt11_quote( pub(crate) async fn post_melt_bolt11_quote(
#[cfg(feature = "auth")] auth: AuthHeader,
State(state): State<MintState>, State(state): State<MintState>,
Json(payload): Json<MeltQuoteBolt11Request>, Json(payload): Json<MeltQuoteBolt11Request>,
) -> Result<Json<MeltQuoteBolt11Response<Uuid>>, Response> { ) -> Result<Json<MeltQuoteBolt11Response<Uuid>>, Response> {
#[cfg(feature = "auth")]
{
state
.mint
.verify_auth(
auth.into(),
&ProtectedEndpoint::new(Method::Post, RoutePath::MeltQuoteBolt11),
)
.await
.map_err(into_response)?;
}
let quote = state let quote = state
.mint .mint
.get_melt_bolt11_quote(&payload) .get_melt_bolt11_quote(&payload)
@@ -263,11 +335,24 @@ pub async fn post_melt_bolt11_quote(
/// Get melt quote by ID /// Get melt quote by ID
/// ///
/// Get melt quote state. /// Get melt quote state.
#[instrument(skip_all)] #[instrument(skip_all, fields(quote_id = ?quote_id))]
pub async fn get_check_melt_bolt11_quote( pub(crate) async fn get_check_melt_bolt11_quote(
#[cfg(feature = "auth")] auth: AuthHeader,
State(state): State<MintState>, State(state): State<MintState>,
Path(quote_id): Path<Uuid>, Path(quote_id): Path<Uuid>,
) -> Result<Json<MeltQuoteBolt11Response<Uuid>>, Response> { ) -> Result<Json<MeltQuoteBolt11Response<Uuid>>, Response> {
#[cfg(feature = "auth")]
{
state
.mint
.verify_auth(
auth.into(),
&ProtectedEndpoint::new(Method::Get, RoutePath::MeltQuoteBolt11),
)
.await
.map_err(into_response)?;
}
let quote = state let quote = state
.mint .mint
.check_melt_quote(&quote_id) .check_melt_quote(&quote_id)
@@ -294,10 +379,23 @@ pub async fn get_check_melt_bolt11_quote(
/// ///
/// Requests tokens to be destroyed and sent out via Lightning. /// Requests tokens to be destroyed and sent out via Lightning.
#[instrument(skip_all)] #[instrument(skip_all)]
pub async fn post_melt_bolt11( pub(crate) async fn post_melt_bolt11(
#[cfg(feature = "auth")] auth: AuthHeader,
State(state): State<MintState>, State(state): State<MintState>,
Json(payload): Json<MeltBolt11Request<Uuid>>, Json(payload): Json<MeltBolt11Request<Uuid>>,
) -> Result<Json<MeltQuoteBolt11Response<Uuid>>, Response> { ) -> Result<Json<MeltQuoteBolt11Response<Uuid>>, Response> {
#[cfg(feature = "auth")]
{
state
.mint
.verify_auth(
auth.into(),
&ProtectedEndpoint::new(Method::Post, RoutePath::MeltBolt11),
)
.await
.map_err(into_response)?;
}
let res = state let res = state
.mint .mint
.melt_bolt11(&payload) .melt_bolt11(&payload)
@@ -320,10 +418,24 @@ pub async fn post_melt_bolt11(
/// Check whether a proof is spent already or is pending in a transaction /// Check whether a proof is spent already or is pending in a transaction
/// ///
/// Check whether a secret has been spent already or not. /// Check whether a secret has been spent already or not.
pub async fn post_check( #[instrument(skip_all, fields(y_count = ?payload.ys.len()))]
pub(crate) async fn post_check(
#[cfg(feature = "auth")] auth: AuthHeader,
State(state): State<MintState>, State(state): State<MintState>,
Json(payload): Json<CheckStateRequest>, Json(payload): Json<CheckStateRequest>,
) -> Result<Json<CheckStateResponse>, Response> { ) -> Result<Json<CheckStateResponse>, Response> {
#[cfg(feature = "auth")]
{
state
.mint
.verify_auth(
auth.into(),
&ProtectedEndpoint::new(Method::Post, RoutePath::Checkstate),
)
.await
.map_err(into_response)?;
}
let state = state.mint.check_state(&payload).await.map_err(|err| { let state = state.mint.check_state(&payload).await.map_err(|err| {
tracing::error!("Could not check state of proofs"); tracing::error!("Could not check state of proofs");
into_response(err) into_response(err)
@@ -341,7 +453,10 @@ pub async fn post_check(
) )
))] ))]
/// Mint information, operator contact information, and other info /// Mint information, operator contact information, and other info
pub async fn get_mint_info(State(state): State<MintState>) -> Result<Json<MintInfo>, Response> { #[instrument(skip_all)]
pub(crate) async fn get_mint_info(
State(state): State<MintState>,
) -> Result<Json<MintInfo>, Response> {
Ok(Json( Ok(Json(
state state
.mint .mint
@@ -371,10 +486,24 @@ pub async fn get_mint_info(State(state): State<MintState>) -> Result<Json<MintIn
/// Requests a set of Proofs to be swapped for another set of BlindSignatures. /// Requests a set of Proofs to be swapped for another set of BlindSignatures.
/// ///
/// This endpoint can be used by Alice to swap a set of proofs before making a payment to Carol. It can then used by Carol to redeem the tokens for new proofs. /// This endpoint can be used by Alice to swap a set of proofs before making a payment to Carol. It can then used by Carol to redeem the tokens for new proofs.
pub async fn post_swap( #[instrument(skip_all, fields(inputs_count = ?payload.inputs.len()))]
pub(crate) async fn post_swap(
#[cfg(feature = "auth")] auth: AuthHeader,
State(state): State<MintState>, State(state): State<MintState>,
Json(payload): Json<SwapRequest>, Json(payload): Json<SwapRequest>,
) -> Result<Json<SwapResponse>, Response> { ) -> Result<Json<SwapResponse>, Response> {
#[cfg(feature = "auth")]
{
state
.mint
.verify_auth(
auth.into(),
&ProtectedEndpoint::new(Method::Post, RoutePath::Swap),
)
.await
.map_err(into_response)?;
}
let swap_response = state let swap_response = state
.mint .mint
.process_swap_request(payload) .process_swap_request(payload)
@@ -383,6 +512,7 @@ pub async fn post_swap(
tracing::error!("Could not process swap request: {}", err); tracing::error!("Could not process swap request: {}", err);
into_response(err) into_response(err)
})?; })?;
Ok(Json(swap_response)) Ok(Json(swap_response))
} }
@@ -397,10 +527,24 @@ pub async fn post_swap(
) )
))] ))]
/// Restores blind signature for a set of outputs. /// Restores blind signature for a set of outputs.
pub async fn post_restore( #[instrument(skip_all, fields(outputs_count = ?payload.outputs.len()))]
pub(crate) async fn post_restore(
#[cfg(feature = "auth")] auth: AuthHeader,
State(state): State<MintState>, State(state): State<MintState>,
Json(payload): Json<RestoreRequest>, Json(payload): Json<RestoreRequest>,
) -> Result<Json<RestoreResponse>, Response> { ) -> Result<Json<RestoreResponse>, Response> {
#[cfg(feature = "auth")]
{
state
.mint
.verify_auth(
auth.into(),
&ProtectedEndpoint::new(Method::Post, RoutePath::Restore),
)
.await
.map_err(into_response)?;
}
let restore_response = state.mint.restore(payload).await.map_err(|err| { let restore_response = state.mint.restore(payload).await.map_err(|err| {
tracing::error!("Could not process restore: {}", err); tracing::error!("Could not process restore: {}", err);
into_response(err) into_response(err)
@@ -409,7 +553,8 @@ pub async fn post_restore(
Ok(Json(restore_response)) Ok(Json(restore_response))
} }
pub fn into_response<T>(error: T) -> Response #[instrument(skip_all)]
pub(crate) fn into_response<T>(error: T) -> Response
where where
T: Into<ErrorResponse>, T: Into<ErrorResponse>,
{ {

View File

@@ -15,7 +15,7 @@ sqlcipher = ["cdk-sqlite/sqlcipher"]
[dependencies] [dependencies]
anyhow.workspace = true anyhow.workspace = true
bip39.workspace = true bip39.workspace = true
cdk = { workspace = true, default-features = false, features = ["wallet"]} cdk = { workspace = true, default-features = false, features = ["wallet", "auth"]}
cdk-redb = { workspace = true, features = ["wallet"] } cdk-redb = { workspace = true, features = ["wallet"] }
cdk-sqlite = { workspace = true, features = ["wallet"] } cdk-sqlite = { workspace = true, features = ["wallet"] }
clap.workspace = true clap.workspace = true

View File

@@ -8,8 +8,7 @@ use bip39::rand::{thread_rng, Rng};
use bip39::Mnemonic; use bip39::Mnemonic;
use cdk::cdk_database; use cdk::cdk_database;
use cdk::cdk_database::WalletDatabase; use cdk::cdk_database::WalletDatabase;
use cdk::wallet::client::HttpClient; use cdk::wallet::{HttpClient, MultiMintWallet, Wallet, WalletBuilder};
use cdk::wallet::{MultiMintWallet, Wallet};
use cdk_redb::WalletRedbDatabase; use cdk_redb::WalletRedbDatabase;
use cdk_sqlite::WalletSqliteDatabase; use cdk_sqlite::WalletSqliteDatabase;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
@@ -19,6 +18,7 @@ use url::Url;
mod nostr_storage; mod nostr_storage;
mod sub_commands; mod sub_commands;
mod token_storage;
const DEFAULT_WORK_DIR: &str = ".cdk-cli"; const DEFAULT_WORK_DIR: &str = ".cdk-cli";
@@ -83,6 +83,12 @@ enum Commands {
PayRequest(sub_commands::pay_request::PayRequestSubCommand), PayRequest(sub_commands::pay_request::PayRequestSubCommand),
/// Create Payment request /// Create Payment request
CreateRequest(sub_commands::create_request::CreateRequestSubCommand), CreateRequest(sub_commands::create_request::CreateRequestSubCommand),
/// Mint blind auth proofs
MintBlindAuth(sub_commands::mint_blind_auth::MintBlindAuthSubCommand),
/// Cat login with username/password
CatLogin(sub_commands::cat_login::CatLoginSubCommand),
/// Cat login with device code flow
CatDeviceLogin(sub_commands::cat_device_login::CatDeviceLoginSubCommand),
} }
#[tokio::main] #[tokio::main]
@@ -158,18 +164,19 @@ async fn main() -> Result<()> {
let mints = localstore.get_mints().await?; let mints = localstore.get_mints().await?;
for (mint_url, _) in mints { for (mint_url, _) in mints {
let mut wallet = Wallet::new( let mut builder = WalletBuilder::new()
&mint_url.to_string(), .mint_url(mint_url.clone())
cdk::nuts::CurrencyUnit::Sat, .unit(cdk::nuts::CurrencyUnit::Sat)
localstore.clone(), .localstore(localstore.clone())
&mnemonic.to_seed_normalized(""), .seed(&mnemonic.to_seed_normalized(""));
None,
)?;
if let Some(proxy_url) = args.proxy.as_ref() { if let Some(proxy_url) = args.proxy.as_ref() {
let http_client = HttpClient::with_proxy(mint_url, proxy_url.clone(), None, true)?; let http_client = HttpClient::with_proxy(mint_url, proxy_url.clone(), None, true)?;
wallet.set_client(http_client); builder = builder.client(http_client);
} }
let wallet = builder.build()?;
wallets.push(wallet); wallets.push(wallet);
} }
@@ -242,5 +249,35 @@ async fn main() -> Result<()> {
Commands::CreateRequest(sub_command_args) => { Commands::CreateRequest(sub_command_args) => {
sub_commands::create_request::create_request(&multi_mint_wallet, sub_command_args).await sub_commands::create_request::create_request(&multi_mint_wallet, sub_command_args).await
} }
Commands::MintBlindAuth(sub_command_args) => {
sub_commands::mint_blind_auth::mint_blind_auth(
&multi_mint_wallet,
&mnemonic.to_seed_normalized(""),
localstore,
sub_command_args,
&work_dir,
)
.await
}
Commands::CatLogin(sub_command_args) => {
sub_commands::cat_login::cat_login(
&multi_mint_wallet,
&mnemonic.to_seed_normalized(""),
localstore,
sub_command_args,
&work_dir,
)
.await
}
Commands::CatDeviceLogin(sub_command_args) => {
sub_commands::cat_device_login::cat_device_login(
&multi_mint_wallet,
&mnemonic.to_seed_normalized(""),
localstore,
sub_command_args,
&work_dir,
)
.await
}
} }
} }

View File

@@ -0,0 +1,196 @@
use std::path::Path;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use cdk::cdk_database::{Error, WalletDatabase};
use cdk::mint_url::MintUrl;
use cdk::nuts::{CurrencyUnit, MintInfo};
use cdk::wallet::types::WalletKey;
use cdk::wallet::{MultiMintWallet, Wallet};
use cdk::OidcClient;
use clap::Args;
use serde::{Deserialize, Serialize};
use tokio::time::sleep;
use crate::token_storage;
#[derive(Args, Serialize, Deserialize)]
pub struct CatDeviceLoginSubCommand {
/// Mint url
mint_url: MintUrl,
/// Currency unit e.g. sat
#[arg(default_value = "sat")]
#[arg(short, long)]
unit: String,
/// Client ID for OIDC authentication
#[arg(default_value = "cashu-client")]
#[arg(long)]
client_id: String,
}
pub async fn cat_device_login(
multi_mint_wallet: &MultiMintWallet,
seed: &[u8],
localstore: Arc<dyn WalletDatabase<Err = Error> + Sync + Send>,
sub_command_args: &CatDeviceLoginSubCommand,
work_dir: &Path,
) -> Result<()> {
let mint_url = sub_command_args.mint_url.clone();
let unit = CurrencyUnit::from_str(&sub_command_args.unit)?;
let wallet = match multi_mint_wallet
.get_wallet(&WalletKey::new(mint_url.clone(), unit.clone()))
.await
{
Some(wallet) => wallet.clone(),
None => {
let wallet = Wallet::new(&mint_url.to_string(), unit, localstore, seed, None)?;
multi_mint_wallet.add_wallet(wallet.clone()).await;
wallet
}
};
let mint_info = wallet.get_mint_info().await?.expect("Mint info not found");
let (access_token, refresh_token) =
get_device_code_token(&mint_info, &sub_command_args.client_id).await;
// Save tokens to file in work directory
if let Err(e) =
token_storage::save_tokens(work_dir, &mint_url, &access_token, &refresh_token).await
{
println!("Warning: Failed to save tokens to file: {}", e);
} else {
println!("Tokens saved to work directory");
}
// Print a cute ASCII cat
println!("\nAuthentication successful! 🎉\n");
println!("\nYour tokens:");
println!("access_token: {}", access_token);
println!("refresh_token: {}", refresh_token);
Ok(())
}
async fn get_device_code_token(mint_info: &MintInfo, client_id: &str) -> (String, String) {
let openid_discovery = mint_info
.nuts
.nut21
.clone()
.expect("Nut21 defined")
.openid_discovery;
let oidc_client = OidcClient::new(openid_discovery);
// Get the OIDC configuration
let oidc_config = oidc_client
.get_oidc_config()
.await
.expect("Failed to get OIDC config");
// Get the device authorization endpoint
let device_auth_url = oidc_config.device_authorization_endpoint;
// Make the device code request
let client = reqwest::Client::new();
let device_code_response = client
.post(device_auth_url)
.form(&[("client_id", client_id)])
.send()
.await
.expect("Failed to send device code request");
let device_code_data: serde_json::Value = device_code_response
.json()
.await
.expect("Failed to parse device code response");
let device_code = device_code_data["device_code"]
.as_str()
.expect("No device code in response");
let user_code = device_code_data["user_code"]
.as_str()
.expect("No user code in response");
let verification_uri = device_code_data["verification_uri"]
.as_str()
.expect("No verification URI in response");
let verification_uri_complete = device_code_data["verification_uri_complete"]
.as_str()
.unwrap_or(verification_uri);
let interval = device_code_data["interval"].as_u64().unwrap_or(5);
println!("\nTo login, visit: {}", verification_uri);
println!("And enter code: {}\n", user_code);
if verification_uri_complete != verification_uri {
println!(
"Or visit this URL directly: {}\n",
verification_uri_complete
);
}
// Poll for the token
let token_url = oidc_config.token_endpoint;
loop {
sleep(Duration::from_secs(interval)).await;
let token_response = client
.post(&token_url)
.form(&[
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
("device_code", device_code),
("client_id", client_id),
])
.send()
.await
.expect("Failed to send token request");
if token_response.status().is_success() {
let token_data: serde_json::Value = token_response
.json()
.await
.expect("Failed to parse token response");
let access_token = token_data["access_token"]
.as_str()
.expect("No access token in response")
.to_string();
let refresh_token = token_data["refresh_token"]
.as_str()
.expect("No refresh token in response")
.to_string();
return (access_token, refresh_token);
} else {
let error_data: serde_json::Value = token_response
.json()
.await
.expect("Failed to parse error response");
let error = error_data["error"].as_str().unwrap_or("unknown_error");
// If the user hasn't completed the flow yet, continue polling
if error == "authorization_pending" || error == "slow_down" {
if error == "slow_down" {
// If we're polling too fast, slow down
sleep(Duration::from_secs(interval + 5)).await;
}
println!("Waiting for user to complete authentication...");
continue;
} else {
// For other errors, exit with an error message
panic!("Authentication failed: {}", error);
}
}
}
}

View File

@@ -0,0 +1,140 @@
use std::path::Path;
use std::str::FromStr;
use std::sync::Arc;
use anyhow::Result;
use cdk::cdk_database::{Error, WalletDatabase};
use cdk::mint_url::MintUrl;
use cdk::nuts::{CurrencyUnit, MintInfo};
use cdk::wallet::types::WalletKey;
use cdk::wallet::{MultiMintWallet, Wallet};
use cdk::OidcClient;
use clap::Args;
use serde::{Deserialize, Serialize};
use crate::token_storage;
#[derive(Args, Serialize, Deserialize)]
pub struct CatLoginSubCommand {
/// Mint url
mint_url: MintUrl,
/// Username
username: String,
/// Password
password: String,
/// Currency unit e.g. sat
#[arg(default_value = "sat")]
#[arg(short, long)]
unit: String,
/// Client ID for OIDC authentication
#[arg(default_value = "cashu-client")]
#[arg(long)]
client_id: String,
}
pub async fn cat_login(
multi_mint_wallet: &MultiMintWallet,
seed: &[u8],
localstore: Arc<dyn WalletDatabase<Err = Error> + Sync + Send>,
sub_command_args: &CatLoginSubCommand,
work_dir: &Path,
) -> Result<()> {
let mint_url = sub_command_args.mint_url.clone();
let unit = CurrencyUnit::from_str(&sub_command_args.unit)?;
let wallet = match multi_mint_wallet
.get_wallet(&WalletKey::new(mint_url.clone(), unit.clone()))
.await
{
Some(wallet) => wallet.clone(),
None => {
let wallet = Wallet::new(&mint_url.to_string(), unit, localstore, seed, None)?;
multi_mint_wallet.add_wallet(wallet.clone()).await;
wallet
}
};
let mint_info = wallet.get_mint_info().await?.expect("Mint info not found");
let (access_token, refresh_token) = get_access_token(
&mint_info,
&sub_command_args.client_id,
&sub_command_args.username,
&sub_command_args.password,
)
.await;
// Save tokens to file in work directory
if let Err(e) =
token_storage::save_tokens(work_dir, &mint_url, &access_token, &refresh_token).await
{
println!("Warning: Failed to save tokens to file: {}", e);
} else {
println!("Tokens saved to work directory");
}
println!("\nAuthentication successful! 🎉\n");
println!("\nYour tokens:");
println!("access_token: {}", access_token);
println!("refresh_token: {}", refresh_token);
Ok(())
}
async fn get_access_token(
mint_info: &MintInfo,
client_id: &str,
user: &str,
password: &str,
) -> (String, String) {
let openid_discovery = mint_info
.nuts
.nut21
.clone()
.expect("Nut21 defined")
.openid_discovery;
let oidc_client = OidcClient::new(openid_discovery);
// Get the token endpoint from the OIDC configuration
let token_url = oidc_client
.get_oidc_config()
.await
.expect("Failed to get OIDC config")
.token_endpoint;
// Create the request parameters
let params = [
("grant_type", "password"),
("client_id", client_id),
("username", user),
("password", password),
];
// Make the token request directly
let client = reqwest::Client::new();
let response = client
.post(token_url)
.form(&params)
.send()
.await
.expect("Failed to send token request");
let token_response: serde_json::Value = response
.json()
.await
.expect("Failed to parse token response");
let access_token = token_response["access_token"]
.as_str()
.expect("No access token in response")
.to_string();
let refresh_token = token_response["refresh_token"]
.as_str()
.expect("No refresh token in response")
.to_string();
(access_token, refresh_token)
}

View File

@@ -53,6 +53,8 @@ pub async fn mint(
} }
}; };
wallet.get_mint_info().await?;
let quote_id = match &sub_command_args.quote_id { let quote_id = match &sub_command_args.quote_id {
None => { None => {
let amount = sub_command_args let amount = sub_command_args

View File

@@ -0,0 +1,209 @@
use std::path::Path;
use std::str::FromStr;
use std::sync::Arc;
use anyhow::{anyhow, Result};
use cdk::cdk_database::{Error, WalletDatabase};
use cdk::mint_url::MintUrl;
use cdk::nuts::{CurrencyUnit, MintInfo};
use cdk::wallet::types::WalletKey;
use cdk::wallet::{MultiMintWallet, Wallet};
use cdk::{Amount, OidcClient};
use clap::Args;
use serde::{Deserialize, Serialize};
use crate::token_storage;
#[derive(Args, Serialize, Deserialize)]
pub struct MintBlindAuthSubCommand {
/// Mint url
mint_url: MintUrl,
/// Amount
amount: Option<u64>,
/// Cat (access token)
#[arg(long)]
cat: Option<String>,
/// Currency unit e.g. sat
#[arg(default_value = "sat")]
#[arg(short, long)]
unit: String,
}
pub async fn mint_blind_auth(
multi_mint_wallet: &MultiMintWallet,
seed: &[u8],
localstore: Arc<dyn WalletDatabase<Err = Error> + Sync + Send>,
sub_command_args: &MintBlindAuthSubCommand,
work_dir: &Path,
) -> Result<()> {
let mint_url = sub_command_args.mint_url.clone();
let unit = CurrencyUnit::from_str(&sub_command_args.unit)?;
let wallet = match multi_mint_wallet
.get_wallet(&WalletKey::new(mint_url.clone(), unit.clone()))
.await
{
Some(wallet) => wallet.clone(),
None => {
let wallet = Wallet::new(&mint_url.to_string(), unit, localstore, seed, None)?;
multi_mint_wallet.add_wallet(wallet.clone()).await;
wallet
}
};
wallet.get_mint_info().await?;
// Try to get the token from the provided argument or from the stored file
let cat = match &sub_command_args.cat {
Some(token) => token.clone(),
None => {
// Try to load from file
match token_storage::get_token_for_mint(work_dir, &mint_url).await {
Ok(Some(token_data)) => {
println!("Using access token from cashu_tokens.json");
token_data.access_token
}
Ok(None) => {
return Err(anyhow::anyhow!(
"No access token provided and no token found in cashu_tokens.json for this mint"
));
}
Err(e) => {
return Err(anyhow::anyhow!(
"Failed to read token from cashu_tokens.json: {}",
e
));
}
}
}
};
// Try to set the access token
if let Err(err) = wallet.set_cat(cat.clone()).await {
tracing::error!("Could not set cat: {}", err);
// Try to refresh the token if we have a refresh token
if let Ok(Some(token_data)) = token_storage::get_token_for_mint(work_dir, &mint_url).await {
println!("Attempting to refresh the access token...");
// Get the mint info to access OIDC configuration
if let Some(mint_info) = wallet.get_mint_info().await? {
match refresh_access_token(&mint_info, &token_data.refresh_token).await {
Ok((new_access_token, new_refresh_token)) => {
println!("Successfully refreshed access token");
// Save the new tokens
if let Err(e) = token_storage::save_tokens(
work_dir,
&mint_url,
&new_access_token,
&new_refresh_token,
)
.await
{
println!("Warning: Failed to save refreshed tokens: {}", e);
}
// Try setting the new access token
if let Err(err) = wallet.set_cat(new_access_token).await {
tracing::error!("Could not set refreshed cat: {}", err);
return Err(anyhow::anyhow!(
"Authentication failed even after token refresh"
));
}
// Set the refresh token
wallet.set_refresh_token(new_refresh_token).await?;
}
Err(e) => {
tracing::error!("Failed to refresh token: {}", e);
return Err(anyhow::anyhow!("Failed to refresh access token: {}", e));
}
}
}
} else {
return Err(anyhow::anyhow!(
"Authentication failed and no refresh token available"
));
}
} else {
// If we have a refresh token, set it
if let Ok(Some(token_data)) = token_storage::get_token_for_mint(work_dir, &mint_url).await {
tracing::info!("Attempting to use refresh access token to refresh auth token");
wallet.set_refresh_token(token_data.refresh_token).await?;
wallet.refresh_access_token().await?;
}
}
println!("Attempting to mint blind auth");
let amount = match sub_command_args.amount {
Some(amount) => amount,
None => {
let mint_info = wallet
.get_mint_info()
.await?
.ok_or(anyhow!("Unknown mint info"))?;
mint_info
.bat_max_mint()
.ok_or(anyhow!("Unknown max bat mint"))?
}
};
let proofs = wallet.mint_blind_auth(Amount::from(amount)).await?;
println!("Received {} auth proofs for mint {mint_url}", proofs.len());
Ok(())
}
async fn refresh_access_token(
mint_info: &MintInfo,
refresh_token: &str,
) -> Result<(String, String)> {
let openid_discovery = mint_info
.nuts
.nut21
.clone()
.ok_or_else(|| anyhow::anyhow!("OIDC discovery information not available"))?
.openid_discovery;
let oidc_client = OidcClient::new(openid_discovery);
// Get the token endpoint from the OIDC configuration
let token_url = oidc_client.get_oidc_config().await?.token_endpoint;
// Create the request parameters for token refresh
let params = [
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
("client_id", "cashu-client"), // Using default client ID
];
// Make the token refresh request
let client = reqwest::Client::new();
let response = client.post(token_url).form(&params).send().await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
"Token refresh failed with status: {}",
response.status()
));
}
let token_response: serde_json::Value = response.json().await?;
let access_token = token_response["access_token"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("No access token in refresh response"))?
.to_string();
// Get the new refresh token or use the old one if not provided
let new_refresh_token = token_response["refresh_token"]
.as_str()
.unwrap_or(refresh_token)
.to_string();
Ok((access_token, new_refresh_token))
}

View File

@@ -1,6 +1,6 @@
use anyhow::Result; use anyhow::Result;
use cdk::mint_url::MintUrl; use cdk::mint_url::MintUrl;
use cdk::wallet::client::MintConnector; use cdk::wallet::MintConnector;
use cdk::HttpClient; use cdk::HttpClient;
use clap::Args; use clap::Args;
use url::Url; use url::Url;
@@ -14,7 +14,7 @@ pub async fn mint_info(proxy: Option<Url>, sub_command_args: &MintInfoSubcommand
let mint_url = sub_command_args.mint_url.clone(); let mint_url = sub_command_args.mint_url.clone();
let client = match proxy { let client = match proxy {
Some(proxy) => HttpClient::with_proxy(mint_url, proxy, None, true)?, Some(proxy) => HttpClient::with_proxy(mint_url, proxy, None, true)?,
None => HttpClient::new(mint_url), None => HttpClient::new(mint_url, None),
}; };
let info = client.get_mint_info().await?; let info = client.get_mint_info().await?;

View File

@@ -1,5 +1,7 @@
pub mod balance; pub mod balance;
pub mod burn; pub mod burn;
pub mod cat_device_login;
pub mod cat_login;
pub mod check_spent; pub mod check_spent;
pub mod create_request; pub mod create_request;
pub mod decode_request; pub mod decode_request;
@@ -7,6 +9,7 @@ pub mod decode_token;
pub mod list_mint_proofs; pub mod list_mint_proofs;
pub mod melt; pub mod melt;
pub mod mint; pub mod mint;
pub mod mint_blind_auth;
pub mod mint_info; pub mod mint_info;
pub mod pay_request; pub mod pay_request;
pub mod pending_mints; pub mod pending_mints;

View File

@@ -0,0 +1,62 @@
use std::fs::File;
use std::io::{Read, Write};
use std::path::Path;
use anyhow::Result;
use cdk::mint_url::MintUrl;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct TokenData {
pub mint_url: String,
pub access_token: String,
pub refresh_token: String,
}
/// Stores authentication tokens in the work directory
pub async fn save_tokens(
work_dir: &Path,
mint_url: &MintUrl,
access_token: &str,
refresh_token: &str,
) -> Result<()> {
let token_data = TokenData {
mint_url: mint_url.to_string(),
access_token: access_token.to_string(),
refresh_token: refresh_token.to_string(),
};
let json = serde_json::to_string_pretty(&token_data)?;
let file_path = work_dir.join(format!(
"auth_tokens_{}",
mint_url.to_string().replace("/", "_")
));
let mut file = File::create(file_path)?;
file.write_all(json.as_bytes())?;
Ok(())
}
/// Gets authentication tokens from the work directory
pub async fn get_token_for_mint(work_dir: &Path, mint_url: &MintUrl) -> Result<Option<TokenData>> {
let file_path = work_dir.join(format!(
"auth_tokens_{}",
mint_url.to_string().replace("/", "_")
));
if !file_path.exists() {
return Ok(None);
}
let mut file = File::open(file_path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let token_data: TokenData = serde_json::from_str(&contents)?;
if token_data.mint_url == mint_url.to_string() {
Ok(Some(token_data))
} else {
Ok(None)
}
}

View File

@@ -15,6 +15,7 @@ swagger = ["dep:utoipa", "cashu/swagger"]
bench = [] bench = []
wallet = ["cashu/wallet"] wallet = ["cashu/wallet"]
mint = ["cashu/mint", "dep:uuid"] mint = ["cashu/mint", "dep:uuid"]
auth = ["cashu/auth"]
[dependencies] [dependencies]
async-trait.workspace = true async-trait.workspace = true

View File

@@ -0,0 +1,72 @@
//! Mint in memory database use std::collections::HashMap;
use std::collections::HashMap;
use async_trait::async_trait;
use cashu::{AuthRequired, ProtectedEndpoint};
use crate::database::Error;
use crate::mint::MintKeySetInfo;
use crate::nuts::nut07::State;
use crate::nuts::{AuthProof, BlindSignature, Id, PublicKey};
/// Mint Database trait
#[async_trait]
pub trait MintAuthDatabase {
/// Mint Database Error
type Err: Into<Error> + From<Error>;
/// Add Active Keyset
async fn set_active_keyset(&self, id: Id) -> Result<(), Self::Err>;
/// Get Active Keyset
async fn get_active_keyset_id(&self) -> Result<Option<Id>, Self::Err>;
/// Add [`MintKeySetInfo`]
async fn add_keyset_info(&self, keyset: MintKeySetInfo) -> Result<(), Self::Err>;
/// Get [`MintKeySetInfo`]
async fn get_keyset_info(&self, id: &Id) -> Result<Option<MintKeySetInfo>, Self::Err>;
/// Get [`MintKeySetInfo`]s
async fn get_keyset_infos(&self) -> Result<Vec<MintKeySetInfo>, Self::Err>;
/// Add spent [`Proofs`]
async fn add_proof(&self, proof: AuthProof) -> Result<(), Self::Err>;
/// Get [`Proofs`] state
async fn get_proofs_states(&self, ys: &[PublicKey]) -> Result<Vec<Option<State>>, Self::Err>;
/// Get [`Proofs`] state
async fn update_proof_state(
&self,
y: &PublicKey,
proofs_state: State,
) -> Result<Option<State>, Self::Err>;
/// Add [`BlindSignature`]
async fn add_blind_signatures(
&self,
blinded_messages: &[PublicKey],
blind_signatures: &[BlindSignature],
) -> Result<(), Self::Err>;
/// Get [`BlindSignature`]s
async fn get_blind_signatures(
&self,
blinded_messages: &[PublicKey],
) -> Result<Vec<Option<BlindSignature>>, Self::Err>;
/// Add protected endpoints
async fn add_protected_endpoints(
&self,
protected_endpoints: HashMap<ProtectedEndpoint, AuthRequired>,
) -> Result<(), Self::Err>;
/// Removed Protected endpoints
async fn remove_protected_endpoints(
&self,
protected_endpoints: Vec<ProtectedEndpoint>,
) -> Result<(), Self::Err>;
/// Get auth for protected_endpoint
async fn get_auth_for_endpoint(
&self,
protected_endpoint: ProtectedEndpoint,
) -> Result<Option<AuthRequired>, Self::Err>;
/// Get protected endpoints
async fn get_auth_for_endpoints(
&self,
) -> Result<HashMap<ProtectedEndpoint, Option<AuthRequired>>, Self::Err>;
}

View File

@@ -14,6 +14,12 @@ use crate::nuts::{
Proofs, PublicKey, State, Proofs, PublicKey, State,
}; };
#[cfg(feature = "auth")]
mod auth;
#[cfg(feature = "auth")]
pub use auth::MintAuthDatabase;
/// Mint Database trait /// Mint Database trait
#[async_trait] #[async_trait]
pub trait Database { pub trait Database {

View File

@@ -7,6 +7,8 @@ mod wallet;
#[cfg(feature = "mint")] #[cfg(feature = "mint")]
pub use mint::Database as MintDatabase; pub use mint::Database as MintDatabase;
#[cfg(all(feature = "mint", feature = "auth"))]
pub use mint::MintAuthDatabase;
#[cfg(feature = "wallet")] #[cfg(feature = "wallet")]
pub use wallet::Database as WalletDatabase; pub use wallet::Database as WalletDatabase;
@@ -25,6 +27,10 @@ pub enum Error {
/// NUT02 Error /// NUT02 Error
#[error(transparent)] #[error(transparent)]
NUT02(#[from] crate::nuts::nut02::Error), NUT02(#[from] crate::nuts::nut02::Error),
/// NUT22 Error
#[error(transparent)]
#[cfg(feature = "auth")]
NUT22(#[from] crate::nuts::nut22::Error),
/// Serde Error /// Serde Error
#[error(transparent)] #[error(transparent)]
Serde(#[from] serde_json::Error), Serde(#[from] serde_json::Error),

View File

@@ -58,6 +58,36 @@ pub enum Error {
/// Multi-Part Payment not supported for unit and method /// Multi-Part Payment not supported for unit and method
#[error("Multi-Part payment is not supported for unit `{0}` and method `{1}`")] #[error("Multi-Part payment is not supported for unit `{0}` and method `{1}`")]
MppUnitMethodNotSupported(CurrencyUnit, PaymentMethod), MppUnitMethodNotSupported(CurrencyUnit, PaymentMethod),
/// Clear Auth Required
#[error("Clear Auth Required")]
ClearAuthRequired,
/// Blind Auth Required
#[error("Blind Auth Required")]
BlindAuthRequired,
/// Clear Auth Failed
#[error("Clear Auth Failed")]
ClearAuthFailed,
/// Blind Auth Failed
#[error("Blind Auth Failed")]
BlindAuthFailed,
/// Auth settings undefined
#[error("Auth settings undefined")]
AuthSettingsUndefined,
/// Mint time outside of tolerance
#[error("Mint time outside of tolerance")]
MintTimeExceedsTolerance,
/// Insufficient blind auth tokens
#[error("Insufficient blind auth tokens, must reauth")]
InsufficientBlindAuthTokens,
/// Auth localstore undefined
#[error("Auth localstore undefined")]
AuthLocalstoreUndefined,
/// Wallet cat not set
#[error("Wallet cat not set")]
CatNotSet,
/// Could not get mint info
#[error("Could not get mint info")]
CouldNotGetMintInfo,
// Mint Errors // Mint Errors
/// Minting is disabled /// Minting is disabled
@@ -126,6 +156,9 @@ pub enum Error {
/// Internal Error /// Internal Error
#[error("Internal Error")] #[error("Internal Error")]
Internal, Internal,
/// Oidc config not set
#[error("Oidc client not set")]
OidcNotSet,
// Wallet Errors // Wallet Errors
/// P2PK spending conditions not met /// P2PK spending conditions not met
@@ -156,6 +189,9 @@ pub enum Error {
/// Invalid DLEQ proof /// Invalid DLEQ proof
#[error("Could not verify DLEQ proof")] #[error("Could not verify DLEQ proof")]
CouldNotVerifyDleq, CouldNotVerifyDleq,
/// Dleq Proof not provided for signature
#[error("Dleq proof not provided for signature")]
DleqProofNotProvided,
/// Incorrect Mint /// Incorrect Mint
/// Token does not match wallet mint /// Token does not match wallet mint
#[error("Token does not match wallet mint")] #[error("Token does not match wallet mint")]
@@ -213,7 +249,7 @@ pub enum Error {
/// Http transport error /// Http transport error
#[error("Http transport error: {0}")] #[error("Http transport error: {0}")]
HttpError(String), HttpError(String),
#[cfg(feature = "wallet")]
// Crate error conversions // Crate error conversions
/// Cashu Url Error /// Cashu Url Error
#[error(transparent)] #[error(transparent)]
@@ -264,6 +300,12 @@ pub enum Error {
/// NUT20 Error /// NUT20 Error
#[error(transparent)] #[error(transparent)]
NUT20(#[from] crate::nuts::nut20::Error), NUT20(#[from] crate::nuts::nut20::Error),
/// NUTXX Error
#[error(transparent)]
NUT21(#[from] crate::nuts::nut21::Error),
/// NUTXX1 Error
#[error(transparent)]
NUT22(#[from] crate::nuts::nut22::Error),
/// Database Error /// Database Error
#[error(transparent)] #[error(transparent)]
Database(#[from] crate::database::Error), Database(#[from] crate::database::Error),
@@ -397,6 +439,26 @@ impl From<Error> for ErrorResponse {
error: Some(err.to_string()), error: Some(err.to_string()),
detail: None, detail: None,
}, },
Error::ClearAuthRequired => ErrorResponse {
code: ErrorCode::ClearAuthRequired,
error: None,
detail: None,
},
Error::ClearAuthFailed => ErrorResponse {
code: ErrorCode::ClearAuthFailed,
error: None,
detail: None,
},
Error::BlindAuthRequired => ErrorResponse {
code: ErrorCode::BlindAuthRequired,
error: None,
detail: None,
},
Error::BlindAuthFailed => ErrorResponse {
code: ErrorCode::BlindAuthFailed,
error: None,
detail: None,
},
Error::NUT20(err) => ErrorResponse { Error::NUT20(err) => ErrorResponse {
code: ErrorCode::WitnessMissingOrInvalid, code: ErrorCode::WitnessMissingOrInvalid,
error: Some(err.to_string()), error: Some(err.to_string()),
@@ -456,6 +518,8 @@ impl From<ErrorResponse> for Error {
ErrorCode::DuplicateOutputs => Self::DuplicateOutputs, ErrorCode::DuplicateOutputs => Self::DuplicateOutputs,
ErrorCode::MultipleUnits => Self::MultipleUnits, ErrorCode::MultipleUnits => Self::MultipleUnits,
ErrorCode::UnitMismatch => Self::UnitMismatch, ErrorCode::UnitMismatch => Self::UnitMismatch,
ErrorCode::ClearAuthRequired => Self::ClearAuthRequired,
ErrorCode::BlindAuthRequired => Self::BlindAuthRequired,
_ => Self::UnknownErrorResponse(err.to_string()), _ => Self::UnknownErrorResponse(err.to_string()),
} }
} }
@@ -507,6 +571,14 @@ pub enum ErrorCode {
MultipleUnits, MultipleUnits,
/// Input unit does not match output /// Input unit does not match output
UnitMismatch, UnitMismatch,
/// Clear Auth Required
ClearAuthRequired,
/// Clear Auth Failed
ClearAuthFailed,
/// Blind Auth Required
BlindAuthRequired,
/// Blind Auth Failed
BlindAuthFailed,
/// Unknown error code /// Unknown error code
Unknown(u16), Unknown(u16),
} }
@@ -536,6 +608,10 @@ impl ErrorCode {
20006 => Self::InvoiceAlreadyPaid, 20006 => Self::InvoiceAlreadyPaid,
20007 => Self::QuoteExpired, 20007 => Self::QuoteExpired,
20008 => Self::WitnessMissingOrInvalid, 20008 => Self::WitnessMissingOrInvalid,
30001 => Self::ClearAuthRequired,
30002 => Self::ClearAuthFailed,
31001 => Self::BlindAuthRequired,
31002 => Self::BlindAuthFailed,
_ => Self::Unknown(code), _ => Self::Unknown(code),
} }
} }
@@ -564,6 +640,10 @@ impl ErrorCode {
Self::InvoiceAlreadyPaid => 20006, Self::InvoiceAlreadyPaid => 20006,
Self::QuoteExpired => 20007, Self::QuoteExpired => 20007,
Self::WitnessMissingOrInvalid => 20008, Self::WitnessMissingOrInvalid => 20008,
Self::ClearAuthRequired => 30001,
Self::ClearAuthFailed => 30002,
Self::BlindAuthRequired => 31001,
Self::BlindAuthFailed => 31002,
Self::Unknown(code) => *code, Self::Unknown(code) => *code,
} }
} }

View File

@@ -16,7 +16,6 @@ pub mod payment;
pub mod pub_sub; pub mod pub_sub;
pub mod subscription; pub mod subscription;
pub mod ws; pub mod ws;
// re-exporting external crates // re-exporting external crates
pub use bitcoin; pub use bitcoin;
pub use cashu::amount::{self, Amount}; pub use cashu::amount::{self, Amount};
@@ -25,3 +24,4 @@ pub use cashu::nuts::{self, *};
#[cfg(feature = "wallet")] #[cfg(feature = "wallet")]
pub use cashu::wallet; pub use cashu::wallet;
pub use cashu::{dhke, ensure_cdk, mint_url, secret, util, SECP256K1}; pub use cashu::{dhke, ensure_cdk, mint_url, secret, util, SECP256K1};
pub use error::Error;

View File

@@ -20,7 +20,7 @@ rand.workspace = true
bip39 = { workspace = true, features = ["rand"] } bip39 = { workspace = true, features = ["rand"] }
anyhow.workspace = true anyhow.workspace = true
cashu = { path = "../cashu", features = ["mint", "wallet"] } cashu = { path = "../cashu", features = ["mint", "wallet"] }
cdk = { path = "../cdk", features = ["mint", "wallet"] } cdk = { path = "../cdk", features = ["mint", "wallet", "auth"] }
cdk-cln = { path = "../cdk-cln" } cdk-cln = { path = "../cdk-cln" }
cdk-lnd = { path = "../cdk-lnd" } cdk-lnd = { path = "../cdk-lnd" }
cdk-axum = { path = "../cdk-axum" } cdk-axum = { path = "../cdk-axum" }
@@ -42,6 +42,7 @@ tracing-subscriber.workspace = true
tokio-tungstenite.workspace = true tokio-tungstenite.workspace = true
tower-http = { workspace = true, features = ["cors"] } tower-http = { workspace = true, features = ["cors"] }
tower-service = "0.3.3" tower-service = "0.3.3"
reqwest.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tokio.workspace = true tokio.workspace = true

View File

@@ -0,0 +1,102 @@
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use anyhow::Result;
use bip39::Mnemonic;
use cashu::{AuthRequired, Method, ProtectedEndpoint, RoutePath};
use cdk::cdk_database::{self, MintAuthDatabase, MintDatabase};
use cdk::mint::{MintBuilder, MintMeltLimits};
use cdk::nuts::{CurrencyUnit, PaymentMethod};
use cdk::types::FeeReserve;
use cdk::wallet::AuthWallet;
use cdk_fake_wallet::FakeWallet;
pub async fn start_fake_mint_with_auth<D, A>(
_addr: &str,
_port: u16,
openid_discovery: String,
database: D,
auth_database: A,
) -> Result<()>
where
D: MintDatabase<Err = cdk_database::Error> + Send + Sync + 'static,
A: MintAuthDatabase<Err = cdk_database::Error> + Send + Sync + 'static,
{
let fee_reserve = FeeReserve {
min_fee_reserve: 1.into(),
percent_fee_reserve: 1.0,
};
let fake_wallet = FakeWallet::new(fee_reserve, HashMap::default(), HashSet::default(), 0);
let mut mint_builder = MintBuilder::new();
mint_builder = mint_builder.with_localstore(Arc::new(database));
mint_builder = mint_builder
.add_ln_backend(
CurrencyUnit::Sat,
PaymentMethod::Bolt11,
MintMeltLimits::new(1, 300),
Arc::new(fake_wallet),
)
.await?;
mint_builder =
mint_builder.set_clear_auth_settings(openid_discovery, "cashu-client".to_string());
mint_builder = mint_builder.set_blind_auth_settings(50);
let blind_auth_endpoints = vec![
ProtectedEndpoint::new(Method::Post, RoutePath::MintQuoteBolt11),
ProtectedEndpoint::new(Method::Post, RoutePath::MintBolt11),
ProtectedEndpoint::new(Method::Get, RoutePath::MintQuoteBolt11),
ProtectedEndpoint::new(Method::Post, RoutePath::MeltQuoteBolt11),
ProtectedEndpoint::new(Method::Get, RoutePath::MeltQuoteBolt11),
ProtectedEndpoint::new(Method::Post, RoutePath::MeltBolt11),
ProtectedEndpoint::new(Method::Post, RoutePath::Swap),
ProtectedEndpoint::new(Method::Post, RoutePath::Checkstate),
ProtectedEndpoint::new(Method::Post, RoutePath::Restore),
];
let blind_auth_endpoints =
blind_auth_endpoints
.into_iter()
.fold(HashMap::new(), |mut acc, e| {
acc.insert(e, AuthRequired::Blind);
acc
});
auth_database
.add_protected_endpoints(blind_auth_endpoints)
.await?;
let mut clear_auth_endpoint = HashMap::new();
clear_auth_endpoint.insert(
ProtectedEndpoint::new(Method::Post, RoutePath::MintBlindAuth),
AuthRequired::Clear,
);
auth_database
.add_protected_endpoints(clear_auth_endpoint)
.await?;
mint_builder = mint_builder.with_auth_localstore(Arc::new(auth_database));
let mnemonic = Mnemonic::generate(12)?;
mint_builder = mint_builder
.with_description("fake test mint".to_string())
.with_seed(mnemonic.to_seed_normalized("").to_vec());
let _mint = mint_builder.build().await?;
todo!("Need to start this a cdk mintd keeping as ref for now");
}
pub async fn top_up_blind_auth_proofs(auth_wallet: Arc<AuthWallet>, count: u64) {
let _proofs = auth_wallet
.mint_blind_auth(count.into())
.await
.expect("could not mint blind auth");
}

View File

@@ -18,11 +18,10 @@ use cdk::nuts::{
}; };
use cdk::types::{FeeReserve, QuoteTTL}; use cdk::types::{FeeReserve, QuoteTTL};
use cdk::util::unix_time; use cdk::util::unix_time;
use cdk::wallet::client::MintConnector; use cdk::wallet::{AuthWallet, MintConnector, Wallet, WalletBuilder};
use cdk::wallet::Wallet;
use cdk::{Amount, Error, Mint}; use cdk::{Amount, Error, Mint};
use cdk_fake_wallet::FakeWallet; use cdk_fake_wallet::FakeWallet;
use tokio::sync::{Mutex, Notify}; use tokio::sync::{Mutex, Notify, RwLock};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use uuid::Uuid; use uuid::Uuid;
@@ -30,11 +29,15 @@ use crate::wait_for_mint_to_be_paid;
pub struct DirectMintConnection { pub struct DirectMintConnection {
pub mint: Arc<Mint>, pub mint: Arc<Mint>,
auth_wallet: Arc<RwLock<Option<AuthWallet>>>,
} }
impl DirectMintConnection { impl DirectMintConnection {
pub fn new(mint: Arc<Mint>) -> Self { pub fn new(mint: Arc<Mint>) -> Self {
Self { mint } Self {
mint,
auth_wallet: Arc::new(RwLock::new(None)),
}
} }
} }
@@ -141,6 +144,18 @@ impl MintConnector for DirectMintConnection {
async fn post_restore(&self, request: RestoreRequest) -> Result<RestoreResponse, Error> { async fn post_restore(&self, request: RestoreRequest) -> Result<RestoreResponse, Error> {
self.mint.restore(request).await self.mint.restore(request).await
} }
/// Get the auth wallet for the client
async fn get_auth_wallet(&self) -> Option<AuthWallet> {
self.auth_wallet.read().await.clone()
}
/// Set auth wallet on client
async fn set_auth_wallet(&self, wallet: Option<AuthWallet>) {
let mut auth_wallet = self.auth_wallet.write().await;
*auth_wallet = wallet;
}
} }
pub fn setup_tracing() { pub fn setup_tracing() {
@@ -232,9 +247,14 @@ async fn create_test_wallet_for_mint(mint: Arc<Mint>) -> Result<Wallet> {
let seed = Mnemonic::generate(12)?.to_seed_normalized(""); let seed = Mnemonic::generate(12)?.to_seed_normalized("");
let unit = CurrencyUnit::Sat; let unit = CurrencyUnit::Sat;
let localstore = cdk_sqlite::wallet::memory::empty().await?; let localstore = cdk_sqlite::wallet::memory::empty().await?;
let mut wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None)?;
wallet.set_client(connector); let wallet = WalletBuilder::new()
.mint_url(mint_url.parse().unwrap())
.unit(unit)
.localstore(Arc::new(localstore))
.seed(&seed)
.client(connector)
.build()?;
Ok(wallet) Ok(wallet)
} }

View File

@@ -2,32 +2,29 @@ use std::sync::Arc;
use anyhow::{anyhow, bail, Result}; use anyhow::{anyhow, bail, Result};
use cdk::amount::{Amount, SplitTarget}; use cdk::amount::{Amount, SplitTarget};
use cdk::nuts::nut00::ProofsMethods;
use cdk::nuts::{MintQuoteState, NotificationPayload, State}; use cdk::nuts::{MintQuoteState, NotificationPayload, State};
use cdk::wallet::WalletSubscription; use cdk::wallet::WalletSubscription;
use cdk::Wallet; use cdk::Wallet;
use tokio::time::{sleep, timeout, Duration}; use tokio::time::{sleep, timeout, Duration};
pub mod init_auth_mint;
pub mod init_pure_tests; pub mod init_pure_tests;
pub mod init_regtest; pub mod init_regtest;
pub async fn wallet_mint( pub async fn fund_wallet(wallet: Arc<Wallet>, amount: Amount) {
wallet: Arc<Wallet>, let quote = wallet
amount: Amount, .mint_quote(amount, None)
split_target: SplitTarget, .await
description: Option<String>, .expect("Could not get mint quote");
) -> Result<()> {
let quote = wallet.mint_quote(amount, description).await?;
wait_for_mint_to_be_paid(&wallet, &quote.id, 60).await?; wait_for_mint_to_be_paid(&wallet, &quote.id, 60)
.await
.expect("Waiting for mint failed");
let proofs = wallet.mint(&quote.id, split_target, None).await?; let _proofs = wallet
.mint(&quote.id, SplitTarget::default(), None)
let receive_amount = proofs.total_amount()?; .await
.expect("Could not mint");
println!("Minted: {}", receive_amount);
Ok(())
} }
// Get all pending from wallet and attempt to swap // Get all pending from wallet and attempt to swap

View File

@@ -0,0 +1,39 @@
use axum::response::{IntoResponse, Response, Result};
use axum::routing::get;
use axum::{Json, Router};
use cdk::oidc_client::OidcConfig;
use jsonwebtoken::jwk::{AlgorithmParameters, Jwk, JwkSet};
use serde_json::{json, Value};
async fn crate_mock_oauth() -> Router {
let router = Router::new()
.route("/config", get(handler_get_config))
.route("/token", get(handler_get_token))
.route("/jwks", get(handler_get_jwkset));
router
}
async fn handler_get_config() -> Result<Json<OidcConfig>> {
Ok(Json(OidcConfig {
jwks_uri: "/jwks".to_string(),
issuer: "127.0.0.1".to_string(),
token_endpoint: "/token".to_string(),
}))
}
async fn handler_get_jwkset() -> Result<Json<JwkSet>> {
let jwk:Jwk = serde_json::from_value(json!({
"kty": "RSA",
"n": "yRE6rHuNR0QbHO3H3Kt2pOKGVhQqGZXInOduQNxXzuKlvQTLUTv4l4sggh5_CYYi_cvI-SXVT9kPWSKXxJXBXd_4LkvcPuUakBoAkfh-eiFVMh2VrUyWyj3MFl0HTVF9KwRXLAcwkREiS3npThHRyIxuy0ZMeZfxVL5arMhw1SRELB8HoGfG_AtH89BIE9jDBHZ9dLelK9a184zAf8LwoPLxvJb3Il5nncqPcSfKDDodMFBIMc4lQzDKL5gvmiXLXB1AGLm8KBjfE8s3L5xqi-yUod-j8MtvIj812dkS4QMiRVN_by2h3ZY8LYVGrqZXZTcgn2ujn8uKjXLZVD5TdQ",
"e": "AQAB",
"kid": "rsa01",
"alg": "RS256",
"use": "sig"
})).unwrap();
Ok(Json(JwkSet { keys: vec![jwk] }))
}
async fn handler_get_token() -> Result<Json<Value>> {
Ok(Json(json!({"access_token": ""})))
}

View File

@@ -0,0 +1,866 @@
use std::env;
use std::str::FromStr;
use std::sync::Arc;
use bip39::Mnemonic;
use cashu::{MintAuthRequest, MintInfo};
use cdk::amount::{Amount, SplitTarget};
use cdk::mint_url::MintUrl;
use cdk::nuts::nut00::ProofsMethods;
use cdk::nuts::{
AuthProof, AuthToken, BlindAuthToken, CheckStateRequest, CurrencyUnit, MeltBolt11Request,
MeltQuoteBolt11Request, MeltQuoteState, MintBolt11Request, MintQuoteBolt11Request,
RestoreRequest, State, SwapRequest,
};
use cdk::wallet::{AuthHttpClient, AuthMintConnector, HttpClient, MintConnector, WalletBuilder};
use cdk::{Error, OidcClient};
use cdk_fake_wallet::create_fake_invoice;
use cdk_integration_tests::{fund_wallet, wait_for_mint_to_be_paid};
use cdk_sqlite::wallet::memory;
const MINT_URL: &str = "http://127.0.0.1:8087";
const ENV_OIDC_USER: &str = "CDK_TEST_OIDC_USER";
const ENV_OIDC_PASSWORD: &str = "CDK_TEST_OIDC_PASSWORD";
fn get_oidc_credentials() -> (String, String) {
let user = env::var(ENV_OIDC_USER).unwrap_or_else(|_| "test".to_string());
let password = env::var(ENV_OIDC_PASSWORD).unwrap_or_else(|_| "test".to_string());
(user, password)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_invalid_credentials() {
let db = Arc::new(memory::empty().await.unwrap());
let wallet = WalletBuilder::new()
.mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url"))
.unit(CurrencyUnit::Sat)
.localstore(db.clone())
.seed(&Mnemonic::generate(12).unwrap().to_seed_normalized(""))
.build()
.expect("Wallet");
let mint_info = wallet
.get_mint_info()
.await
.expect("mint info")
.expect("could not get mint info");
// Try to get a token with invalid credentials
let token_result =
get_custom_access_token(&mint_info, "invalid_user", "invalid_password").await;
// Should fail with an error
assert!(
token_result.is_err(),
"Expected authentication to fail with invalid credentials"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_quote_status_without_auth() {
let client = HttpClient::new(MintUrl::from_str(MINT_URL).expect("Valid mint url"), None);
// Test mint quote status
{
let quote_res = client
.get_mint_quote_status("123e4567-e89b-12d3-a456-426614174000")
.await;
assert!(
matches!(quote_res, Err(Error::BlindAuthRequired)),
"Expected AuthRequired error, got {:?}",
quote_res
);
}
// Test melt quote status
{
let quote_res = client
.get_melt_quote_status("123e4567-e89b-12d3-a456-426614174000")
.await;
assert!(
matches!(quote_res, Err(Error::BlindAuthRequired)),
"Expected AuthRequired error, got {:?}",
quote_res
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_mint_without_auth() {
let client = HttpClient::new(MintUrl::from_str(MINT_URL).expect("Valid mint url"), None);
{
let request = MintQuoteBolt11Request {
unit: CurrencyUnit::Sat,
amount: 10.into(),
description: None,
pubkey: None,
};
let quote_res = client.post_mint_quote(request).await;
assert!(
matches!(quote_res, Err(Error::BlindAuthRequired)),
"Expected AuthRequired error, got {:?}",
quote_res
);
}
{
let request = MintBolt11Request {
quote: "123e4567-e89b-12d3-a456-426614174000".to_string(),
outputs: vec![],
signature: None,
};
let mint_res = client.post_mint(request).await;
assert!(
matches!(mint_res, Err(Error::BlindAuthRequired)),
"Expected AuthRequired error, got {:?}",
mint_res
);
}
{
let mint_res = client
.get_mint_quote_status("123e4567-e89b-12d3-a456-426614174000")
.await;
assert!(
matches!(mint_res, Err(Error::BlindAuthRequired)),
"Expected AuthRequired error, got {:?}",
mint_res
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_mint_bat_without_cat() {
let client = AuthHttpClient::new(MintUrl::from_str(MINT_URL).expect("valid mint url"), None);
let res = client
.post_mint_blind_auth(MintAuthRequest { outputs: vec![] })
.await;
assert!(
matches!(res, Err(Error::ClearAuthRequired)),
"Expected AuthRequired error, got {:?}",
res
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_swap_without_auth() {
let client = HttpClient::new(MintUrl::from_str(MINT_URL).expect("Valid mint url"), None);
let request = SwapRequest {
inputs: vec![],
outputs: vec![],
};
let quote_res = client.post_swap(request).await;
assert!(
matches!(quote_res, Err(Error::BlindAuthRequired)),
"Expected AuthRequired error, got {:?}",
quote_res
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_melt_without_auth() {
let client = HttpClient::new(MintUrl::from_str(MINT_URL).expect("Valid mint url"), None);
// Test melt quote request
{
let request = MeltQuoteBolt11Request {
request: create_fake_invoice(100, "".to_string()),
unit: CurrencyUnit::Sat,
options: None,
};
let quote_res = client.post_melt_quote(request).await;
assert!(
matches!(quote_res, Err(Error::BlindAuthRequired)),
"Expected AuthRequired error, got {:?}",
quote_res
);
}
// Test melt quote
{
let request = MeltQuoteBolt11Request {
request: create_fake_invoice(100, "".to_string()),
unit: CurrencyUnit::Sat,
options: None,
};
let quote_res = client.post_melt_quote(request).await;
assert!(
matches!(quote_res, Err(Error::BlindAuthRequired)),
"Expected AuthRequired error, got {:?}",
quote_res
);
}
// Test melt
{
let request = MeltBolt11Request {
inputs: vec![],
outputs: None,
quote: "123e4567-e89b-12d3-a456-426614174000".to_string(),
};
let melt_res = client.post_melt(request).await;
assert!(
matches!(melt_res, Err(Error::BlindAuthRequired)),
"Expected AuthRequired error, got {:?}",
melt_res
);
}
// Check melt quote state
{
let melt_res = client
.get_melt_quote_status("123e4567-e89b-12d3-a456-426614174000")
.await;
assert!(
matches!(melt_res, Err(Error::BlindAuthRequired)),
"Expected AuthRequired error, got {:?}",
melt_res
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_check_without_auth() {
let client = HttpClient::new(MintUrl::from_str(MINT_URL).expect("Valid mint url"), None);
let request = CheckStateRequest { ys: vec![] };
let quote_res = client.post_check_state(request).await;
assert!(
matches!(quote_res, Err(Error::BlindAuthRequired)),
"Expected AuthRequired error, got {:?}",
quote_res
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_restore_without_auth() {
let client = HttpClient::new(MintUrl::from_str(MINT_URL).expect("Valid mint url"), None);
let request = RestoreRequest { outputs: vec![] };
let restore_res = client.post_restore(request).await;
assert!(
matches!(restore_res, Err(Error::BlindAuthRequired)),
"Expected AuthRequired error, got {:?}",
restore_res
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_mint_blind_auth() {
let db = Arc::new(memory::empty().await.unwrap());
let wallet = WalletBuilder::new()
.mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url"))
.unit(CurrencyUnit::Sat)
.localstore(db.clone())
.seed(&Mnemonic::generate(12).unwrap().to_seed_normalized(""))
.build()
.expect("Wallet");
let mint_info = wallet.get_mint_info().await.unwrap().unwrap();
let (access_token, _) = get_access_token(&mint_info).await;
wallet.set_cat(access_token).await.unwrap();
wallet
.mint_blind_auth(10.into())
.await
.expect("Could not mint blind auth");
let proofs = wallet
.get_unspent_auth_proofs()
.await
.expect("Could not get auth proofs");
assert!(proofs.len() == 10)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_mint_with_auth() {
let db = Arc::new(memory::empty().await.unwrap());
let wallet = WalletBuilder::new()
.mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url"))
.unit(CurrencyUnit::Sat)
.localstore(db.clone())
.seed(&Mnemonic::generate(12).unwrap().to_seed_normalized(""))
.build()
.expect("Wallet");
let mint_info = wallet
.get_mint_info()
.await
.expect("mint info")
.expect("could not get mint info");
let (access_token, _) = get_access_token(&mint_info).await;
println!("st{}", access_token);
wallet.set_cat(access_token).await.unwrap();
wallet
.mint_blind_auth(10.into())
.await
.expect("Could not mint blind auth");
let wallet = Arc::new(wallet);
let mint_amount: Amount = 100.into();
let mint_quote = wallet
.mint_quote(mint_amount, None)
.await
.expect("failed to get mint quote");
wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60)
.await
.expect("failed to wait for payment");
let proofs = wallet
.mint(&mint_quote.id, SplitTarget::default(), None)
.await
.expect("could not mint");
assert!(proofs.total_amount().expect("Could not get proofs amount") == mint_amount);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_swap_with_auth() {
let db = Arc::new(memory::empty().await.unwrap());
let wallet = WalletBuilder::new()
.mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url"))
.unit(CurrencyUnit::Sat)
.localstore(db.clone())
.seed(&Mnemonic::generate(12).unwrap().to_seed_normalized(""))
.build()
.expect("Wallet");
let mint_info = wallet.get_mint_info().await.unwrap().unwrap();
let (access_token, _) = get_access_token(&mint_info).await;
wallet.set_cat(access_token).await.unwrap();
let wallet = Arc::new(wallet);
wallet.mint_blind_auth(10.into()).await.unwrap();
fund_wallet(wallet.clone(), 100.into()).await;
let proofs = wallet
.get_unspent_proofs()
.await
.expect("Could not get proofs");
let swapped_proofs = wallet
.swap(
Some(proofs.total_amount().unwrap()),
SplitTarget::default(),
proofs.clone(),
None,
false,
)
.await
.expect("Could not swap")
.expect("Could not swap");
let check_spent = wallet
.check_proofs_spent(proofs.clone())
.await
.expect("Could not check proofs");
for state in check_spent {
if state.state != State::Spent {
panic!("Input proofs should be spent");
}
}
assert!(swapped_proofs.total_amount().unwrap() == proofs.total_amount().unwrap())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_melt_with_auth() {
let db = Arc::new(memory::empty().await.unwrap());
let wallet = WalletBuilder::new()
.mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url"))
.unit(CurrencyUnit::Sat)
.localstore(db.clone())
.seed(&Mnemonic::generate(12).unwrap().to_seed_normalized(""))
.build()
.expect("Wallet");
let mint_info = wallet
.get_mint_info()
.await
.expect("Mint info not found")
.expect("Mint info not found");
let (access_token, _) = get_access_token(&mint_info).await;
wallet.set_cat(access_token).await.unwrap();
let wallet = Arc::new(wallet);
wallet.mint_blind_auth(10.into()).await.unwrap();
fund_wallet(wallet.clone(), 100.into()).await;
let bolt11 = create_fake_invoice(2_000, "".to_string());
let melt_quote = wallet
.melt_quote(bolt11.to_string(), None)
.await
.expect("Could not get melt quote");
let after_melt = wallet.melt(&melt_quote.id).await.expect("Could not melt");
assert!(after_melt.state == MeltQuoteState::Paid);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_mint_auth_over_max() {
let db = Arc::new(memory::empty().await.unwrap());
let wallet = WalletBuilder::new()
.mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url"))
.unit(CurrencyUnit::Sat)
.localstore(db.clone())
.seed(&Mnemonic::generate(12).unwrap().to_seed_normalized(""))
.build()
.expect("Wallet");
let wallet = Arc::new(wallet);
let mint_info = wallet
.get_mint_info()
.await
.expect("Mint info not found")
.expect("Mint info not found");
let (access_token, _) = get_access_token(&mint_info).await;
wallet.set_cat(access_token).await.unwrap();
let auth_proofs = wallet
.mint_blind_auth((mint_info.nuts.nut22.expect("Auth enabled").bat_max_mint + 1).into())
.await;
assert!(
matches!(
auth_proofs,
Err(Error::AmountOutofLimitRange(
Amount::ZERO,
Amount::ZERO,
Amount::ZERO,
))
),
"Expected amount out of limit error, got {:?}",
auth_proofs
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_reuse_auth_proof() {
let db = Arc::new(memory::empty().await.unwrap());
let wallet = WalletBuilder::new()
.mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url"))
.unit(CurrencyUnit::Sat)
.localstore(db.clone())
.seed(&Mnemonic::generate(12).unwrap().to_seed_normalized(""))
.build()
.expect("Wallet");
let mint_info = wallet.get_mint_info().await.unwrap().unwrap();
let (access_token, _) = get_access_token(&mint_info).await;
wallet.set_cat(access_token).await.unwrap();
wallet.mint_blind_auth(1.into()).await.unwrap();
let proofs = wallet
.localstore
.get_proofs(None, Some(CurrencyUnit::Auth), None, None)
.await
.unwrap();
assert!(proofs.len() == 1);
{
let quote = wallet
.mint_quote(10.into(), None)
.await
.expect("Quote should be allowed");
assert!(quote.amount == 10.into());
}
wallet
.localstore
.update_proofs(proofs, vec![])
.await
.unwrap();
{
let quote_res = wallet.mint_quote(10.into(), None).await;
assert!(
matches!(quote_res, Err(Error::TokenAlreadySpent)),
"Expected AuthRequired error, got {:?}",
quote_res
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_melt_with_invalid_auth() {
let db = Arc::new(memory::empty().await.unwrap());
let wallet = WalletBuilder::new()
.mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url"))
.unit(CurrencyUnit::Sat)
.localstore(db.clone())
.seed(&Mnemonic::generate(12).unwrap().to_seed_normalized(""))
.build()
.expect("Wallet");
let mint_info = wallet.get_mint_info().await.unwrap().unwrap();
let (access_token, _) = get_access_token(&mint_info).await;
wallet.set_cat(access_token).await.unwrap();
wallet.mint_blind_auth(10.into()).await.unwrap();
fund_wallet(Arc::new(wallet.clone()), 1.into()).await;
let proofs = wallet
.get_unspent_proofs()
.await
.expect("wallet has proofs");
println!("{:#?}", proofs);
let proof = proofs.first().expect("wallet has one proof");
let client = HttpClient::new(MintUrl::from_str(MINT_URL).expect("Valid mint url"), None);
{
let invalid_auth_proof = AuthProof {
keyset_id: proof.keyset_id,
secret: proof.secret.clone(),
c: proof.c,
dleq: proof.dleq.clone().unwrap(),
};
let _auth_token = AuthToken::BlindAuth(BlindAuthToken::new(invalid_auth_proof));
let request = MintQuoteBolt11Request {
unit: CurrencyUnit::Sat,
amount: 10.into(),
description: None,
pubkey: None,
};
let quote_res = client.post_mint_quote(request).await;
assert!(
matches!(quote_res, Err(Error::BlindAuthRequired)),
"Expected AuthRequired error, got {:?}",
quote_res
);
}
{
let (access_token, _) = get_access_token(&mint_info).await;
wallet.set_cat(access_token).await.unwrap();
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_refresh_access_token() {
let db = Arc::new(memory::empty().await.unwrap());
let wallet = WalletBuilder::new()
.mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url"))
.unit(CurrencyUnit::Sat)
.localstore(db.clone())
.seed(&Mnemonic::generate(12).unwrap().to_seed_normalized(""))
.build()
.expect("Wallet");
let mint_info = wallet
.get_mint_info()
.await
.expect("mint info")
.expect("could not get mint info");
let (access_token, refresh_token) = get_access_token(&mint_info).await;
// Set the initial access token and refresh token
wallet.set_cat(access_token.clone()).await.unwrap();
wallet
.set_refresh_token(refresh_token.clone())
.await
.unwrap();
// Mint some blind auth tokens with the initial access token
wallet.mint_blind_auth(5.into()).await.unwrap();
// Refresh the access token
wallet.refresh_access_token().await.unwrap();
// Verify we can still perform operations with the refreshed token
let mint_amount: Amount = 10.into();
// Try to mint more blind auth tokens with the refreshed token
let auth_proofs = wallet.mint_blind_auth(5.into()).await.unwrap();
assert_eq!(auth_proofs.len(), 5);
let total_auth_proofs = wallet.get_unspent_auth_proofs().await.unwrap();
assert_eq!(total_auth_proofs.len(), 10); // 5 from before refresh + 5 after refresh
// Try to get a mint quote with the refreshed token
let mint_quote = wallet
.mint_quote(mint_amount, None)
.await
.expect("failed to get mint quote with refreshed token");
assert_eq!(mint_quote.amount, mint_amount);
// Verify the total number of auth tokens
let total_auth_proofs = wallet.get_unspent_auth_proofs().await.unwrap();
assert_eq!(total_auth_proofs.len(), 9); // 5 from before refresh + 5 after refresh - 1 for the quote
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_invalid_refresh_token() {
let db = Arc::new(memory::empty().await.unwrap());
let wallet = WalletBuilder::new()
.mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url"))
.unit(CurrencyUnit::Sat)
.localstore(db.clone())
.seed(&Mnemonic::generate(12).unwrap().to_seed_normalized(""))
.build()
.expect("Wallet");
let mint_info = wallet
.get_mint_info()
.await
.expect("mint info")
.expect("could not get mint info");
let (access_token, _) = get_access_token(&mint_info).await;
// Set the initial access token
wallet.set_cat(access_token.clone()).await.unwrap();
// Set an invalid refresh token
wallet
.set_refresh_token("invalid_refresh_token".to_string())
.await
.unwrap();
// Attempt to refresh the access token with an invalid refresh token
let refresh_result = wallet.refresh_access_token().await;
// Should fail with an error
assert!(refresh_result.is_err(), "Expected refresh token error");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn test_auth_token_spending_order() {
let db = Arc::new(memory::empty().await.unwrap());
let wallet = WalletBuilder::new()
.mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url"))
.unit(CurrencyUnit::Sat)
.localstore(db.clone())
.seed(&Mnemonic::generate(12).unwrap().to_seed_normalized(""))
.build()
.expect("Wallet");
let mint_info = wallet
.get_mint_info()
.await
.expect("mint info")
.expect("could not get mint info");
let (access_token, _) = get_access_token(&mint_info).await;
wallet.set_cat(access_token).await.unwrap();
// Mint auth tokens in two batches to test ordering
wallet.mint_blind_auth(2.into()).await.unwrap();
// Get the first batch of auth proofs
let first_batch = wallet.get_unspent_auth_proofs().await.unwrap();
assert_eq!(first_batch.len(), 2);
// Mint a second batch
wallet.mint_blind_auth(3.into()).await.unwrap();
// Get all auth proofs
let all_proofs = wallet.get_unspent_auth_proofs().await.unwrap();
assert_eq!(all_proofs.len(), 5);
// Use tokens and verify they're used in the expected order (FIFO)
for i in 0..3 {
let mint_quote = wallet
.mint_quote(10.into(), None)
.await
.expect("failed to get mint quote");
assert_eq!(mint_quote.amount, 10.into());
// Check remaining tokens after each operation
let remaining = wallet.get_unspent_auth_proofs().await.unwrap();
assert_eq!(
remaining.len(),
5 - (i + 1),
"Expected {} remaining auth tokens after {} operations",
5 - (i + 1),
i + 1
);
}
}
async fn get_access_token(mint_info: &MintInfo) -> (String, String) {
let openid_discovery = mint_info
.nuts
.nut21
.clone()
.expect("Nutxx defined")
.openid_discovery;
let oidc_client = OidcClient::new(openid_discovery);
// Get the token endpoint from the OIDC configuration
let token_url = oidc_client
.get_oidc_config()
.await
.expect("Failed to get OIDC config")
.token_endpoint;
// Create the request parameters
let (user, password) = get_oidc_credentials();
let params = [
("grant_type", "password"),
("client_id", "cashu-client"),
("username", &user),
("password", &password),
];
// Make the token request directly
let client = reqwest::Client::new();
let response = client
.post(token_url)
.form(&params)
.send()
.await
.expect("Failed to send token request");
let token_response: serde_json::Value = response
.json()
.await
.expect("Failed to parse token response");
let access_token = token_response["access_token"]
.as_str()
.expect("No access token in response")
.to_string();
let refresh_token = token_response["refresh_token"]
.as_str()
.expect("No access token in response")
.to_string();
(access_token, refresh_token)
}
/// Get a new access token with custom credentials
async fn get_custom_access_token(
mint_info: &MintInfo,
username: &str,
password: &str,
) -> Result<(String, String), Error> {
let openid_discovery = mint_info
.nuts
.nut21
.clone()
.expect("Nutxx defined")
.openid_discovery;
let oidc_client = OidcClient::new(openid_discovery);
// Get the token endpoint from the OIDC configuration
let token_url = oidc_client
.get_oidc_config()
.await
.map_err(|_| Error::Custom("Failed to get OIDC config".to_string()))?
.token_endpoint;
// Create the request parameters
let params = [
("grant_type", "password"),
("client_id", "cashu-client"),
("username", username),
("password", password),
];
// Make the token request directly
let client = reqwest::Client::new();
let response = client
.post(token_url)
.form(&params)
.send()
.await
.map_err(|_| Error::Custom("Failed to send token request".to_string()))?;
if !response.status().is_success() {
return Err(Error::Custom(format!(
"Token request failed with status: {}",
response.status()
)));
}
let token_response: serde_json::Value = response
.json()
.await
.map_err(|_| Error::Custom("Failed to parse token response".to_string()))?;
let access_token = token_response["access_token"]
.as_str()
.ok_or_else(|| Error::Custom("No access token in response".to_string()))?
.to_string();
let refresh_token = token_response["refresh_token"]
.as_str()
.ok_or_else(|| Error::Custom("No refresh token in response".to_string()))?
.to_string();
Ok((access_token, refresh_token))
}

View File

@@ -8,8 +8,7 @@ use cdk::nuts::{
CurrencyUnit, MeltBolt11Request, MeltQuoteState, MintBolt11Request, PreMintSecrets, Proofs, CurrencyUnit, MeltBolt11Request, MeltQuoteState, MintBolt11Request, PreMintSecrets, Proofs,
SecretKey, State, SwapRequest, SecretKey, State, SwapRequest,
}; };
use cdk::wallet::client::{HttpClient, MintConnector}; use cdk::wallet::{HttpClient, MintConnector, Wallet};
use cdk::wallet::Wallet;
use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription}; use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription};
use cdk_integration_tests::{attempt_to_swap_pending, wait_for_mint_to_be_paid}; use cdk_integration_tests::{attempt_to_swap_pending, wait_for_mint_to_be_paid};
use cdk_sqlite::wallet::memory; use cdk_sqlite::wallet::memory;
@@ -354,7 +353,7 @@ async fn test_fake_melt_change_in_quote() -> Result<()> {
let premint_secrets = PreMintSecrets::random(keyset.id, 100.into(), &SplitTarget::default())?; let premint_secrets = PreMintSecrets::random(keyset.id, 100.into(), &SplitTarget::default())?;
let client = HttpClient::new(MINT_URL.parse()?); let client = HttpClient::new(MINT_URL.parse()?, None);
let melt_request = MeltBolt11Request { let melt_request = MeltBolt11Request {
quote: melt_quote.id.clone(), quote: melt_quote.id.clone(),
@@ -450,7 +449,7 @@ async fn test_fake_mint_without_witness() -> Result<()> {
wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?; wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?;
let http_client = HttpClient::new(MINT_URL.parse()?); let http_client = HttpClient::new(MINT_URL.parse()?, None);
let active_keyset_id = wallet.get_active_mint_keyset().await?.id; let active_keyset_id = wallet.get_active_mint_keyset().await?.id;
@@ -486,7 +485,7 @@ async fn test_fake_mint_with_wrong_witness() -> Result<()> {
wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?; wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?;
let http_client = HttpClient::new(MINT_URL.parse()?); let http_client = HttpClient::new(MINT_URL.parse()?, None);
let active_keyset_id = wallet.get_active_mint_keyset().await?.id; let active_keyset_id = wallet.get_active_mint_keyset().await?.id;
@@ -545,7 +544,7 @@ async fn test_fake_mint_inflated() -> Result<()> {
if let Some(secret_key) = quote_info.secret_key { if let Some(secret_key) = quote_info.secret_key {
mint_request.sign(secret_key)?; mint_request.sign(secret_key)?;
} }
let http_client = HttpClient::new(MINT_URL.parse()?); let http_client = HttpClient::new(MINT_URL.parse()?, None);
let response = http_client.post_mint(mint_request.clone()).await; let response = http_client.post_mint(mint_request.clone()).await;
@@ -615,7 +614,7 @@ async fn test_fake_mint_multiple_units() -> Result<()> {
if let Some(secret_key) = quote_info.secret_key { if let Some(secret_key) = quote_info.secret_key {
mint_request.sign(secret_key)?; mint_request.sign(secret_key)?;
} }
let http_client = HttpClient::new(MINT_URL.parse()?); let http_client = HttpClient::new(MINT_URL.parse()?, None);
let response = http_client.post_mint(mint_request.clone()).await; let response = http_client.post_mint(mint_request.clone()).await;
@@ -682,7 +681,7 @@ async fn test_fake_mint_multiple_unit_swap() -> Result<()> {
outputs: pre_mint.blinded_messages(), outputs: pre_mint.blinded_messages(),
}; };
let http_client = HttpClient::new(MINT_URL.parse()?); let http_client = HttpClient::new(MINT_URL.parse()?, None);
let response = http_client.post_swap(swap_request.clone()).await; let response = http_client.post_swap(swap_request.clone()).await;
match response { match response {
@@ -719,7 +718,7 @@ async fn test_fake_mint_multiple_unit_swap() -> Result<()> {
outputs: usd_outputs, outputs: usd_outputs,
}; };
let http_client = HttpClient::new(MINT_URL.parse()?); let http_client = HttpClient::new(MINT_URL.parse()?, None);
let response = http_client.post_swap(swap_request.clone()).await; let response = http_client.post_swap(swap_request.clone()).await;
match response { match response {
@@ -793,7 +792,7 @@ async fn test_fake_mint_multiple_unit_melt() -> Result<()> {
outputs: None, outputs: None,
}; };
let http_client = HttpClient::new(MINT_URL.parse()?); let http_client = HttpClient::new(MINT_URL.parse()?, None);
let response = http_client.post_melt(melt_request.clone()).await; let response = http_client.post_melt(melt_request.clone()).await;
match response { match response {
@@ -837,7 +836,7 @@ async fn test_fake_mint_multiple_unit_melt() -> Result<()> {
outputs: Some(usd_outputs), outputs: Some(usd_outputs),
}; };
let http_client = HttpClient::new(MINT_URL.parse()?); let http_client = HttpClient::new(MINT_URL.parse()?, None);
let response = http_client.post_melt(melt_request.clone()).await; let response = http_client.post_melt(melt_request.clone()).await;
@@ -896,7 +895,7 @@ async fn test_fake_mint_input_output_mismatch() -> Result<()> {
outputs: pre_mint.blinded_messages(), outputs: pre_mint.blinded_messages(),
}; };
let http_client = HttpClient::new(MINT_URL.parse()?); let http_client = HttpClient::new(MINT_URL.parse()?, None);
let response = http_client.post_swap(swap_request.clone()).await; let response = http_client.post_swap(swap_request.clone()).await;
match response { match response {
@@ -936,7 +935,7 @@ async fn test_fake_mint_swap_inflated() -> Result<()> {
outputs: pre_mint.blinded_messages(), outputs: pre_mint.blinded_messages(),
}; };
let http_client = HttpClient::new(MINT_URL.parse()?); let http_client = HttpClient::new(MINT_URL.parse()?, None);
let response = http_client.post_swap(swap_request.clone()).await; let response = http_client.post_swap(swap_request.clone()).await;
match response { match response {
@@ -979,7 +978,7 @@ async fn test_fake_mint_swap_spend_after_fail() -> Result<()> {
outputs: pre_mint.blinded_messages(), outputs: pre_mint.blinded_messages(),
}; };
let http_client = HttpClient::new(MINT_URL.parse()?); let http_client = HttpClient::new(MINT_URL.parse()?, None);
let response = http_client.post_swap(swap_request.clone()).await; let response = http_client.post_swap(swap_request.clone()).await;
assert!(response.is_ok()); assert!(response.is_ok());
@@ -991,7 +990,7 @@ async fn test_fake_mint_swap_spend_after_fail() -> Result<()> {
outputs: pre_mint.blinded_messages(), outputs: pre_mint.blinded_messages(),
}; };
let http_client = HttpClient::new(MINT_URL.parse()?); let http_client = HttpClient::new(MINT_URL.parse()?, None);
let response = http_client.post_swap(swap_request.clone()).await; let response = http_client.post_swap(swap_request.clone()).await;
match response { match response {
@@ -1009,7 +1008,7 @@ async fn test_fake_mint_swap_spend_after_fail() -> Result<()> {
outputs: pre_mint.blinded_messages(), outputs: pre_mint.blinded_messages(),
}; };
let http_client = HttpClient::new(MINT_URL.parse()?); let http_client = HttpClient::new(MINT_URL.parse()?, None);
let response = http_client.post_swap(swap_request.clone()).await; let response = http_client.post_swap(swap_request.clone()).await;
match response { match response {
@@ -1052,7 +1051,7 @@ async fn test_fake_mint_melt_spend_after_fail() -> Result<()> {
outputs: pre_mint.blinded_messages(), outputs: pre_mint.blinded_messages(),
}; };
let http_client = HttpClient::new(MINT_URL.parse()?); let http_client = HttpClient::new(MINT_URL.parse()?, None);
let response = http_client.post_swap(swap_request.clone()).await; let response = http_client.post_swap(swap_request.clone()).await;
assert!(response.is_ok()); assert!(response.is_ok());
@@ -1064,7 +1063,7 @@ async fn test_fake_mint_melt_spend_after_fail() -> Result<()> {
outputs: pre_mint.blinded_messages(), outputs: pre_mint.blinded_messages(),
}; };
let http_client = HttpClient::new(MINT_URL.parse()?); let http_client = HttpClient::new(MINT_URL.parse()?, None);
let response = http_client.post_swap(swap_request.clone()).await; let response = http_client.post_swap(swap_request.clone()).await;
match response { match response {
@@ -1085,7 +1084,7 @@ async fn test_fake_mint_melt_spend_after_fail() -> Result<()> {
outputs: None, outputs: None,
}; };
let http_client = HttpClient::new(MINT_URL.parse()?); let http_client = HttpClient::new(MINT_URL.parse()?, None);
let response = http_client.post_melt(melt_request.clone()).await; let response = http_client.post_melt(melt_request.clone()).await;
match response { match response {
@@ -1132,7 +1131,7 @@ async fn test_fake_mint_duplicate_proofs_swap() -> Result<()> {
outputs: pre_mint.blinded_messages(), outputs: pre_mint.blinded_messages(),
}; };
let http_client = HttpClient::new(MINT_URL.parse()?); let http_client = HttpClient::new(MINT_URL.parse()?, None);
let response = http_client.post_swap(swap_request.clone()).await; let response = http_client.post_swap(swap_request.clone()).await;
match response { match response {
@@ -1156,7 +1155,7 @@ async fn test_fake_mint_duplicate_proofs_swap() -> Result<()> {
let swap_request = SwapRequest { inputs, outputs }; let swap_request = SwapRequest { inputs, outputs };
let http_client = HttpClient::new(MINT_URL.parse()?); let http_client = HttpClient::new(MINT_URL.parse()?, None);
let response = http_client.post_swap(swap_request.clone()).await; let response = http_client.post_swap(swap_request.clone()).await;
match response { match response {
@@ -1206,7 +1205,7 @@ async fn test_fake_mint_duplicate_proofs_melt() -> Result<()> {
outputs: None, outputs: None,
}; };
let http_client = HttpClient::new(MINT_URL.parse()?); let http_client = HttpClient::new(MINT_URL.parse()?, None);
let response = http_client.post_melt(melt_request.clone()).await; let response = http_client.post_melt(melt_request.clone()).await;
match response { match response {

View File

@@ -12,9 +12,7 @@ use cdk::nuts::{
CurrencyUnit, MeltQuoteState, MintBolt11Request, MintQuoteState, NotificationPayload, CurrencyUnit, MeltQuoteState, MintBolt11Request, MintQuoteState, NotificationPayload,
PreMintSecrets, State, PreMintSecrets, State,
}; };
use cdk::wallet::client::{HttpClient, MintConnector}; use cdk::wallet::{HttpClient, MintConnector, Wallet, WalletSubscription};
use cdk::wallet::Wallet;
use cdk::WalletSubscription;
use cdk_integration_tests::init_regtest::{ use cdk_integration_tests::init_regtest::{
get_cln_dir, get_lnd_cert_file_path, get_lnd_dir, get_lnd_macaroon_path, get_mint_port, get_cln_dir, get_lnd_cert_file_path, get_lnd_dir, get_lnd_macaroon_path, get_mint_port,
get_mint_url, get_mint_ws_url, LND_RPC_ADDR, LND_TWO_RPC_ADDR, get_mint_url, get_mint_ws_url, LND_RPC_ADDR, LND_TWO_RPC_ADDR,
@@ -442,7 +440,7 @@ async fn test_cached_mint() -> Result<()> {
wait_for_mint_to_be_paid(&wallet, &quote.id, 60).await?; wait_for_mint_to_be_paid(&wallet, &quote.id, 60).await?;
let active_keyset_id = wallet.get_active_mint_keyset().await?.id; let active_keyset_id = wallet.get_active_mint_keyset().await?.id;
let http_client = HttpClient::new(get_mint_url("0").as_str().parse()?); let http_client = HttpClient::new(get_mint_url("0").as_str().parse()?, None);
let premint_secrets = let premint_secrets =
PreMintSecrets::random(active_keyset_id, 100.into(), &SplitTarget::default()).unwrap(); PreMintSecrets::random(active_keyset_id, 100.into(), &SplitTarget::default()).unwrap();

View File

@@ -10,7 +10,7 @@ description = "CDK mint binary"
rust-version = "1.75.0" rust-version = "1.75.0"
[features] [features]
default = ["management-rpc", "cln", "lnd", "lnbits", "fakewallet", "grpc-processor"] default = ["management-rpc", "cln", "lnd", "lnbits", "fakewallet", "grpc-processor", "auth"]
# Ensure at least one lightning backend is enabled # Ensure at least one lightning backend is enabled
management-rpc = ["cdk-mint-rpc"] management-rpc = ["cdk-mint-rpc"]
cln = ["dep:cdk-cln"] cln = ["dep:cdk-cln"]
@@ -23,6 +23,7 @@ sqlcipher = ["cdk-sqlite/sqlcipher"]
redb = ["dep:cdk-redb"] redb = ["dep:cdk-redb"]
swagger = ["cdk-axum/swagger", "dep:utoipa", "dep:utoipa-swagger-ui"] swagger = ["cdk-axum/swagger", "dep:utoipa", "dep:utoipa-swagger-ui"]
redis = ["cdk-axum/redis"] redis = ["cdk-axum/redis"]
auth = ["cdk/auth", "cdk-sqlite/auth"]
[dependencies] [dependencies]
anyhow.workspace = true anyhow.workspace = true
@@ -33,6 +34,7 @@ cdk = { workspace = true, features = [
] } ] }
cdk-redb = { workspace = true, features = [ cdk-redb = { workspace = true, features = [
"mint", "mint",
"auth"
], optional = true } ], optional = true }
cdk-sqlite = { workspace = true, features = [ cdk-sqlite = { workspace = true, features = [
"mint", "mint",

View File

@@ -98,3 +98,14 @@ reserve_fee_min = 4
# addr = "127.0.0.1" # addr = "127.0.0.1"
# port = 50051 # port = 50051
# tls_dir = "/path/to/tls" # tls_dir = "/path/to/tls"
# [auth]
# openid_discovery = "http://127.0.0.1:8080/realms/cdk-test-realm/.well-known/openid-configuration"
# openid_client_id = "cashu-client"
# mint_max_bat=50
# enabled_mint=true
# enabled_melt=true
# enabled_swap=true
# enabled_check_mint_quote=true
# enabled_check_melt_quote=true
# enabled_restore=true

View File

@@ -202,6 +202,30 @@ pub struct Database {
pub engine: DatabaseEngine, pub engine: DatabaseEngine,
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Auth {
pub openid_discovery: String,
pub openid_client_id: String,
pub mint_max_bat: u64,
#[serde(default = "default_true")]
pub enabled_mint: bool,
#[serde(default = "default_true")]
pub enabled_melt: bool,
#[serde(default = "default_true")]
pub enabled_swap: bool,
#[serde(default = "default_true")]
pub enabled_check_mint_quote: bool,
#[serde(default = "default_true")]
pub enabled_check_melt_quote: bool,
#[serde(default = "default_true")]
pub enabled_restore: bool,
#[serde(default = "default_true")]
pub enabled_check_proof_state: bool,
}
fn default_true() -> bool {
true
}
/// CDK settings, derived from `config.toml` /// CDK settings, derived from `config.toml`
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Settings { pub struct Settings {
@@ -220,6 +244,7 @@ pub struct Settings {
pub database: Database, pub database: Database,
#[cfg(feature = "management-rpc")] #[cfg(feature = "management-rpc")]
pub mint_management_rpc: Option<MintManagementRpc>, pub mint_management_rpc: Option<MintManagementRpc>,
pub auth: Option<Auth>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]

View File

@@ -0,0 +1,78 @@
//! Auth env
use std::env;
use crate::config::Auth;
pub const ENV_AUTH_OPENID_DISCOVERY: &str = "CDK_MINTD_AUTH_OPENID_DISCOVERY";
pub const ENV_AUTH_OPENID_CLIENT_ID: &str = "CDK_MINTD_AUTH_OPENID_CLIENT_ID";
pub const ENV_AUTH_MINT_MAX_BAT: &str = "CDK_MINTD_AUTH_MINT_MAX_BAT";
pub const ENV_AUTH_ENABLED_MINT: &str = "CDK_MINTD_AUTH_ENABLED_MINT";
pub const ENV_AUTH_ENABLED_MELT: &str = "CDK_MINTD_AUTH_ENABLED_MELT";
pub const ENV_AUTH_ENABLED_SWAP: &str = "CDK_MINTD_AUTH_ENABLED_SWAP";
pub const ENV_AUTH_ENABLED_CHECK_MINT_QUOTE: &str = "CDK_MINTD_AUTH_ENABLED_CHECK_MINT_QUOTE";
pub const ENV_AUTH_ENABLED_CHECK_MELT_QUOTE: &str = "CDK_MINTD_AUTH_ENABLED_CHECK_MELT_QUOTE";
pub const ENV_AUTH_ENABLED_RESTORE: &str = "CDK_MINTD_AUTH_ENABLED_RESTORE";
pub const ENV_AUTH_ENABLED_CHECK_PROOF_STATE: &str = "CDK_MINTD_AUTH_ENABLED_CHECK_PROOF_STATE";
impl Auth {
pub fn from_env(mut self) -> Self {
if let Ok(discovery) = env::var(ENV_AUTH_OPENID_DISCOVERY) {
self.openid_discovery = discovery;
}
if let Ok(client_id) = env::var(ENV_AUTH_OPENID_CLIENT_ID) {
self.openid_client_id = client_id;
}
if let Ok(max_bat_str) = env::var(ENV_AUTH_MINT_MAX_BAT) {
if let Ok(max_bat) = max_bat_str.parse() {
self.mint_max_bat = max_bat;
}
}
if let Ok(enabled_mint_str) = env::var(ENV_AUTH_ENABLED_MINT) {
if let Ok(enabled) = enabled_mint_str.parse() {
self.enabled_mint = enabled;
}
}
if let Ok(enabled_melt_str) = env::var(ENV_AUTH_ENABLED_MELT) {
if let Ok(enabled) = enabled_melt_str.parse() {
self.enabled_melt = enabled;
}
}
if let Ok(enabled_swap_str) = env::var(ENV_AUTH_ENABLED_SWAP) {
if let Ok(enabled) = enabled_swap_str.parse() {
self.enabled_swap = enabled;
}
}
if let Ok(enabled_check_mint_str) = env::var(ENV_AUTH_ENABLED_CHECK_MINT_QUOTE) {
if let Ok(enabled) = enabled_check_mint_str.parse() {
self.enabled_check_mint_quote = enabled;
}
}
if let Ok(enabled_check_melt_str) = env::var(ENV_AUTH_ENABLED_CHECK_MELT_QUOTE) {
if let Ok(enabled) = enabled_check_melt_str.parse() {
self.enabled_check_melt_quote = enabled;
}
}
if let Ok(enabled_restore_str) = env::var(ENV_AUTH_ENABLED_RESTORE) {
if let Ok(enabled) = enabled_restore_str.parse() {
self.enabled_restore = enabled;
}
}
if let Ok(enabled_check_proof_str) = env::var(ENV_AUTH_ENABLED_CHECK_PROOF_STATE) {
if let Ok(enabled) = enabled_check_proof_str.parse() {
self.enabled_check_proof_state = enabled;
}
}
self
}
}

View File

@@ -8,6 +8,8 @@ mod info;
mod ln; mod ln;
mod mint_info; mod mint_info;
#[cfg(feature = "auth")]
mod auth;
#[cfg(feature = "cln")] #[cfg(feature = "cln")]
mod cln; mod cln;
#[cfg(feature = "fakewallet")] #[cfg(feature = "fakewallet")]
@@ -25,6 +27,8 @@ use std::env;
use std::str::FromStr; use std::str::FromStr;
use anyhow::{anyhow, bail, Result}; use anyhow::{anyhow, bail, Result};
#[cfg(feature = "auth")]
pub use auth::*;
#[cfg(feature = "cln")] #[cfg(feature = "cln")]
pub use cln::*; pub use cln::*;
pub use common::*; pub use common::*;
@@ -54,6 +58,25 @@ impl Settings {
self.mint_info = self.mint_info.clone().from_env(); self.mint_info = self.mint_info.clone().from_env();
self.ln = self.ln.clone().from_env(); self.ln = self.ln.clone().from_env();
#[cfg(feature = "auth")]
{
// Check env vars for auth config even if None
let auth = self.auth.clone().unwrap_or_default().from_env();
// Only set auth if env vars are present and have non-default values
if auth.openid_discovery != String::default()
|| auth.openid_client_id != String::default()
|| auth.mint_max_bat != 0
|| auth.enabled_mint
|| auth.enabled_melt
|| auth.enabled_swap
{
self.auth = Some(auth);
} else {
self.auth = None;
}
}
#[cfg(feature = "management-rpc")] #[cfg(feature = "management-rpc")]
{ {
self.mint_management_rpc = Some( self.mint_management_rpc = Some(

View File

@@ -1,8 +1,8 @@
//! CDK Mint Server //! CDK MINTD
#![warn(missing_docs)] #![warn(missing_docs)]
#![warn(rustdoc::bare_urls)] #![warn(rustdoc::bare_urls)]
use std::collections::HashMap;
use std::env; use std::env;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::PathBuf; use std::path::PathBuf;
@@ -12,7 +12,7 @@ use std::sync::Arc;
use anyhow::{anyhow, bail, Result}; use anyhow::{anyhow, bail, Result};
use axum::Router; use axum::Router;
use bip39::Mnemonic; use bip39::Mnemonic;
use cdk::cdk_database::{self, MintDatabase}; use cdk::cdk_database::{self, MintAuthDatabase, MintDatabase};
use cdk::mint::{MintBuilder, MintMeltLimits}; use cdk::mint::{MintBuilder, MintMeltLimits};
// Feature-gated imports // Feature-gated imports
#[cfg(any( #[cfg(any(
@@ -31,7 +31,9 @@ use cdk::nuts::nut19::{CachedEndpoint, Method as NUT19Method, Path as NUT19Path}
feature = "fakewallet" feature = "fakewallet"
))] ))]
use cdk::nuts::CurrencyUnit; use cdk::nuts::CurrencyUnit;
use cdk::nuts::{ContactInfo, MintVersion, PaymentMethod}; use cdk::nuts::{
AuthRequired, ContactInfo, MintVersion, PaymentMethod, ProtectedEndpoint, RoutePath,
};
use cdk::types::QuoteTTL; use cdk::types::QuoteTTL;
use cdk_axum::cache::HttpCache; use cdk_axum::cache::HttpCache;
#[cfg(feature = "management-rpc")] #[cfg(feature = "management-rpc")]
@@ -41,7 +43,10 @@ use cdk_mintd::config::{self, DatabaseEngine, LnBackend};
use cdk_mintd::env_vars::ENV_WORK_DIR; use cdk_mintd::env_vars::ENV_WORK_DIR;
use cdk_mintd::setup::LnBackendSetup; use cdk_mintd::setup::LnBackendSetup;
#[cfg(feature = "redb")] #[cfg(feature = "redb")]
use cdk_redb::mint::MintRedbAuthDatabase;
#[cfg(feature = "redb")]
use cdk_redb::MintRedbDatabase; use cdk_redb::MintRedbDatabase;
use cdk_sqlite::mint::MintSqliteAuthDatabase;
use cdk_sqlite::MintSqliteDatabase; use cdk_sqlite::MintSqliteDatabase;
use clap::Parser; use clap::Parser;
use tokio::sync::Notify; use tokio::sync::Notify;
@@ -361,6 +366,157 @@ async fn main() -> anyhow::Result<()> {
mint_builder = mint_builder.add_cache(Some(cache.ttl.as_secs()), cached_endpoints); mint_builder = mint_builder.add_cache(Some(cache.ttl.as_secs()), cached_endpoints);
// Add auth to mint
if let Some(auth_settings) = settings.auth {
tracing::info!("Auth settings are defined. {:?}", auth_settings);
let auth_localstore: Arc<dyn MintAuthDatabase<Err = cdk_database::Error> + Send + Sync> =
match settings.database.engine {
DatabaseEngine::Sqlite => {
let sql_db_path = work_dir.join("cdk-mintd-auth.sqlite");
let sqlite_db = MintSqliteAuthDatabase::new(&sql_db_path).await?;
sqlite_db.migrate().await;
Arc::new(sqlite_db)
}
#[cfg(feature = "redb")]
DatabaseEngine::Redb => {
let redb_path = work_dir.join("cdk-mintd-auth.redb");
Arc::new(MintRedbAuthDatabase::new(&redb_path)?)
}
};
mint_builder = mint_builder.with_auth_localstore(auth_localstore.clone());
let mint_blind_auth_endpoint =
ProtectedEndpoint::new(cdk::nuts::Method::Post, RoutePath::MintBlindAuth);
mint_builder = mint_builder.set_clear_auth_settings(
auth_settings.openid_discovery,
auth_settings.openid_client_id,
);
let mut protected_endpoints = HashMap::new();
protected_endpoints.insert(mint_blind_auth_endpoint, AuthRequired::Clear);
let mut blind_auth_endpoints = vec![];
let mut unprotected_endpoints = vec![];
{
let mint_quote_protected_endpoint = ProtectedEndpoint::new(
cdk::nuts::Method::Post,
cdk::nuts::RoutePath::MintQuoteBolt11,
);
let mint_protected_endpoint =
ProtectedEndpoint::new(cdk::nuts::Method::Post, cdk::nuts::RoutePath::MintBolt11);
if auth_settings.enabled_mint {
protected_endpoints.insert(mint_quote_protected_endpoint, AuthRequired::Blind);
protected_endpoints.insert(mint_protected_endpoint, AuthRequired::Blind);
blind_auth_endpoints.push(mint_quote_protected_endpoint);
blind_auth_endpoints.push(mint_protected_endpoint);
} else {
unprotected_endpoints.push(mint_protected_endpoint);
unprotected_endpoints.push(mint_quote_protected_endpoint);
}
}
{
let melt_quote_protected_endpoint = ProtectedEndpoint::new(
cdk::nuts::Method::Post,
cdk::nuts::RoutePath::MeltQuoteBolt11,
);
let melt_protected_endpoint =
ProtectedEndpoint::new(cdk::nuts::Method::Post, cdk::nuts::RoutePath::MeltBolt11);
if auth_settings.enabled_melt {
protected_endpoints.insert(melt_quote_protected_endpoint, AuthRequired::Blind);
protected_endpoints.insert(melt_protected_endpoint, AuthRequired::Blind);
blind_auth_endpoints.push(melt_quote_protected_endpoint);
blind_auth_endpoints.push(melt_protected_endpoint);
} else {
unprotected_endpoints.push(melt_quote_protected_endpoint);
unprotected_endpoints.push(melt_protected_endpoint);
}
}
{
let swap_protected_endpoint =
ProtectedEndpoint::new(cdk::nuts::Method::Post, cdk::nuts::RoutePath::Swap);
if auth_settings.enabled_swap {
protected_endpoints.insert(swap_protected_endpoint, AuthRequired::Blind);
blind_auth_endpoints.push(swap_protected_endpoint);
} else {
unprotected_endpoints.push(swap_protected_endpoint);
}
}
{
let check_mint_protected_endpoint = ProtectedEndpoint::new(
cdk::nuts::Method::Get,
cdk::nuts::RoutePath::MintQuoteBolt11,
);
if auth_settings.enabled_check_mint_quote {
protected_endpoints.insert(check_mint_protected_endpoint, AuthRequired::Blind);
blind_auth_endpoints.push(check_mint_protected_endpoint);
} else {
unprotected_endpoints.push(check_mint_protected_endpoint);
}
}
{
let check_melt_protected_endpoint = ProtectedEndpoint::new(
cdk::nuts::Method::Get,
cdk::nuts::RoutePath::MeltQuoteBolt11,
);
if auth_settings.enabled_check_melt_quote {
protected_endpoints.insert(check_melt_protected_endpoint, AuthRequired::Blind);
blind_auth_endpoints.push(check_melt_protected_endpoint);
} else {
unprotected_endpoints.push(check_melt_protected_endpoint);
}
}
{
let restore_protected_endpoint =
ProtectedEndpoint::new(cdk::nuts::Method::Post, cdk::nuts::RoutePath::Restore);
if auth_settings.enabled_restore {
protected_endpoints.insert(restore_protected_endpoint, AuthRequired::Blind);
blind_auth_endpoints.push(restore_protected_endpoint);
} else {
unprotected_endpoints.push(restore_protected_endpoint);
}
}
{
let state_protected_endpoint =
ProtectedEndpoint::new(cdk::nuts::Method::Post, cdk::nuts::RoutePath::Checkstate);
if auth_settings.enabled_check_proof_state {
protected_endpoints.insert(state_protected_endpoint, AuthRequired::Blind);
blind_auth_endpoints.push(state_protected_endpoint);
} else {
unprotected_endpoints.push(state_protected_endpoint);
}
}
mint_builder = mint_builder.set_blind_auth_settings(auth_settings.mint_max_bat);
auth_localstore
.remove_protected_endpoints(unprotected_endpoints)
.await?;
auth_localstore
.add_protected_endpoints(protected_endpoints)
.await?;
}
let mint = mint_builder.build().await?; let mint = mint_builder.build().await?;
tracing::debug!("Mint built from builder."); tracing::debug!("Mint built from builder.");

View File

@@ -11,9 +11,10 @@ rust-version = "1.81.0" # MSRV
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[features] [features]
default = ["mint", "wallet"] default = ["mint", "wallet", "auth"]
mint = ["cdk-common/mint"] mint = ["cdk-common/mint"]
wallet = ["cdk-common/wallet"] wallet = ["cdk-common/wallet"]
auth = ["cdk-common/auth"]
[dependencies] [dependencies]
async-trait.workspace = true async-trait.workspace = true

View File

@@ -0,0 +1,391 @@
use std::cmp::Ordering;
use std::collections::HashMap;
use std::path::Path;
use std::str::FromStr;
use std::sync::Arc;
use async_trait::async_trait;
use cdk_common::database::{self, MintAuthDatabase};
use cdk_common::dhke::hash_to_curve;
use cdk_common::mint::MintKeySetInfo;
use cdk_common::nuts::{AuthProof, BlindSignature, Id, PublicKey, State};
use cdk_common::{AuthRequired, ProtectedEndpoint};
use redb::{Database, ReadableTable, TableDefinition};
use crate::error::Error;
const CONFIG_TABLE: TableDefinition<&str, &str> = TableDefinition::new("config");
const ACTIVE_KEYSET_TABLE: TableDefinition<&str, &str> = TableDefinition::new("active_keyset");
const KEYSETS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("keysets");
const PROOFS_TABLE: TableDefinition<[u8; 33], &str> = TableDefinition::new("proofs");
const PROOFS_STATE_TABLE: TableDefinition<[u8; 33], &str> = TableDefinition::new("proofs_state");
// Key is hex blinded_message B_ value is blinded_signature
const BLINDED_SIGNATURES: TableDefinition<[u8; 33], &str> =
TableDefinition::new("blinded_signatures");
const ENDPOINTS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("endpoints");
/// Mint Redbdatabase
#[derive(Debug, Clone)]
pub struct MintRedbAuthDatabase {
db: Arc<Database>,
}
const DATABASE_VERSION: u32 = 0;
impl MintRedbAuthDatabase {
/// Create new [`MintRedbDatabase`]
pub fn new(path: &Path) -> Result<Self, Error> {
{
// Check database version
let db = Arc::new(Database::create(path)?);
// Check database version
let read_txn = db.begin_read()?;
let table = read_txn.open_table(CONFIG_TABLE);
let db_version = match table {
Ok(table) => table.get("db_version")?.map(|v| v.value().to_owned()),
Err(_) => None,
};
match db_version {
Some(db_version) => {
let current_file_version = u32::from_str(&db_version)?;
match current_file_version.cmp(&DATABASE_VERSION) {
Ordering::Less => {
tracing::info!(
"Database needs to be upgraded at {} current is {}",
current_file_version,
DATABASE_VERSION
);
}
Ordering::Equal => {
tracing::info!("Database is at current version {}", DATABASE_VERSION);
}
Ordering::Greater => {
tracing::warn!(
"Database upgrade did not complete at {} current is {}",
current_file_version,
DATABASE_VERSION
);
return Err(Error::UnknownDatabaseVersion);
}
}
}
None => {
let write_txn = db.begin_write()?;
{
let mut table = write_txn.open_table(CONFIG_TABLE)?;
// Open all tables to init a new db
let _ = write_txn.open_table(ACTIVE_KEYSET_TABLE)?;
let _ = write_txn.open_table(KEYSETS_TABLE)?;
let _ = write_txn.open_table(PROOFS_TABLE)?;
let _ = write_txn.open_table(PROOFS_STATE_TABLE)?;
let _ = write_txn.open_table(BLINDED_SIGNATURES)?;
table.insert("db_version", DATABASE_VERSION.to_string().as_str())?;
}
write_txn.commit()?;
}
}
drop(db);
}
let db = Database::create(path)?;
Ok(Self { db: Arc::new(db) })
}
}
#[async_trait]
impl MintAuthDatabase for MintRedbAuthDatabase {
type Err = database::Error;
async fn set_active_keyset(&self, id: Id) -> Result<(), Self::Err> {
let write_txn = self.db.begin_write().map_err(Error::from)?;
{
let mut table = write_txn
.open_table(ACTIVE_KEYSET_TABLE)
.map_err(Error::from)?;
table
.insert("active_keyset_id", id.to_string().as_str())
.map_err(Error::from)?;
}
write_txn.commit().map_err(Error::from)?;
Ok(())
}
async fn get_active_keyset_id(&self) -> Result<Option<Id>, Self::Err> {
let read_txn = self.db.begin_read().map_err(Error::from)?;
let table = read_txn
.open_table(ACTIVE_KEYSET_TABLE)
.map_err(Error::from)?;
if let Some(id) = table.get("active_keyset_id").map_err(Error::from)? {
return Ok(Some(Id::from_str(id.value()).map_err(Error::from)?));
}
Ok(None)
}
async fn add_keyset_info(&self, keyset: MintKeySetInfo) -> Result<(), Self::Err> {
let write_txn = self.db.begin_write().map_err(Error::from)?;
{
let mut table = write_txn.open_table(KEYSETS_TABLE).map_err(Error::from)?;
table
.insert(
keyset.id.to_string().as_str(),
serde_json::to_string(&keyset)
.map_err(Error::from)?
.as_str(),
)
.map_err(Error::from)?;
}
write_txn.commit().map_err(Error::from)?;
Ok(())
}
async fn get_keyset_info(&self, keyset_id: &Id) -> Result<Option<MintKeySetInfo>, Self::Err> {
let read_txn = self.db.begin_read().map_err(Error::from)?;
let table = read_txn.open_table(KEYSETS_TABLE).map_err(Error::from)?;
match table
.get(keyset_id.to_string().as_str())
.map_err(Error::from)?
{
Some(keyset) => Ok(serde_json::from_str(keyset.value()).map_err(Error::from)?),
None => Ok(None),
}
}
async fn get_keyset_infos(&self) -> Result<Vec<MintKeySetInfo>, Self::Err> {
let read_txn = self.db.begin_read().map_err(Error::from)?;
let table = read_txn.open_table(KEYSETS_TABLE).map_err(Error::from)?;
let mut keysets = Vec::new();
for (_id, keyset) in (table.iter().map_err(Error::from)?).flatten() {
let keyset = serde_json::from_str(keyset.value()).map_err(Error::from)?;
keysets.push(keyset)
}
Ok(keysets)
}
async fn add_proof(&self, proof: AuthProof) -> Result<(), Self::Err> {
let write_txn = self.db.begin_write().map_err(Error::from)?;
{
let mut table = write_txn.open_table(PROOFS_TABLE).map_err(Error::from)?;
let y: PublicKey = hash_to_curve(&proof.secret.to_bytes()).map_err(Error::from)?;
let y = y.to_bytes();
if table.get(y).map_err(Error::from)?.is_none() {
table
.insert(
y,
serde_json::to_string(&proof).map_err(Error::from)?.as_str(),
)
.map_err(Error::from)?;
}
}
write_txn.commit().map_err(Error::from)?;
Ok(())
}
async fn update_proof_state(
&self,
y: &PublicKey,
proof_state: State,
) -> Result<Option<State>, Self::Err> {
let write_txn = self.db.begin_write().map_err(Error::from)?;
let state_str = serde_json::to_string(&proof_state).map_err(Error::from)?;
let current_state;
{
let mut table = write_txn
.open_table(PROOFS_STATE_TABLE)
.map_err(Error::from)?;
{
match table.get(y.to_bytes()).map_err(Error::from)? {
Some(state) => {
current_state =
Some(serde_json::from_str(state.value()).map_err(Error::from)?)
}
None => current_state = None,
}
}
if current_state != Some(State::Spent) {
table
.insert(y.to_bytes(), state_str.as_str())
.map_err(Error::from)?;
}
}
write_txn.commit().map_err(Error::from)?;
Ok(current_state)
}
async fn get_proofs_states(&self, ys: &[PublicKey]) -> Result<Vec<Option<State>>, Self::Err> {
let read_txn = self.db.begin_read().map_err(Error::from)?;
let table = read_txn
.open_table(PROOFS_STATE_TABLE)
.map_err(Error::from)?;
let mut states = Vec::with_capacity(ys.len());
for y in ys {
match table.get(y.to_bytes()).map_err(Error::from)? {
Some(state) => states.push(Some(
serde_json::from_str(state.value()).map_err(Error::from)?,
)),
None => states.push(None),
}
}
Ok(states)
}
async fn add_blind_signatures(
&self,
blinded_messages: &[PublicKey],
blind_signatures: &[BlindSignature],
) -> Result<(), Self::Err> {
let write_txn = self.db.begin_write().map_err(Error::from)?;
{
let mut table = write_txn
.open_table(BLINDED_SIGNATURES)
.map_err(Error::from)?;
for (blinded_message, blind_signature) in blinded_messages.iter().zip(blind_signatures)
{
let blind_sig = serde_json::to_string(&blind_signature).map_err(Error::from)?;
table
.insert(blinded_message.to_bytes(), blind_sig.as_str())
.map_err(Error::from)?;
}
}
write_txn.commit().map_err(Error::from)?;
Ok(())
}
async fn get_blind_signatures(
&self,
blinded_messages: &[PublicKey],
) -> Result<Vec<Option<BlindSignature>>, Self::Err> {
let read_txn = self.db.begin_read().map_err(Error::from)?;
let table = read_txn
.open_table(BLINDED_SIGNATURES)
.map_err(Error::from)?;
let mut signatures = Vec::with_capacity(blinded_messages.len());
for blinded_message in blinded_messages {
match table.get(blinded_message.to_bytes()).map_err(Error::from)? {
Some(blind_signature) => signatures.push(Some(
serde_json::from_str(blind_signature.value()).map_err(Error::from)?,
)),
None => signatures.push(None),
}
}
Ok(signatures)
}
async fn add_protected_endpoints(
&self,
protected_endpoints: HashMap<ProtectedEndpoint, AuthRequired>,
) -> Result<(), Self::Err> {
let write_txn = self.db.begin_write().map_err(Error::from)?;
{
let mut table = write_txn.open_table(ENDPOINTS_TABLE).map_err(Error::from)?;
for (endpoint, auth) in protected_endpoints.iter() {
table
.insert(
serde_json::to_string(endpoint)
.map_err(Error::from)?
.as_str(),
serde_json::to_string(&auth).map_err(Error::from)?.as_str(),
)
.map_err(Error::from)?;
}
}
write_txn.commit().map_err(Error::from)?;
Ok(())
}
async fn remove_protected_endpoints(
&self,
protected_endpoints: Vec<ProtectedEndpoint>,
) -> Result<(), Self::Err> {
let write_txn = self.db.begin_write().map_err(Error::from)?;
{
let mut table = write_txn.open_table(ENDPOINTS_TABLE).map_err(Error::from)?;
for endpoint in protected_endpoints.iter() {
table
.remove(
serde_json::to_string(endpoint)
.map_err(Error::from)?
.as_str(),
)
.map_err(Error::from)?;
}
}
write_txn.commit().map_err(Error::from)?;
Ok(())
}
async fn get_auth_for_endpoint(
&self,
protected_endpoint: ProtectedEndpoint,
) -> Result<Option<AuthRequired>, Self::Err> {
let read_txn = self.db.begin_read().map_err(Error::from)?;
let table = read_txn.open_table(ENDPOINTS_TABLE).map_err(Error::from)?;
match table
.get(
serde_json::to_string(&protected_endpoint)
.map_err(Error::from)?
.as_str(),
)
.map_err(Error::from)?
{
Some(auth) => Ok(serde_json::from_str(auth.value()).map_err(Error::from)?),
None => Ok(None),
}
}
async fn get_auth_for_endpoints(
&self,
) -> Result<HashMap<ProtectedEndpoint, Option<AuthRequired>>, Self::Err> {
let read_txn = self.db.begin_read().map_err(Error::from)?;
let table = read_txn.open_table(ENDPOINTS_TABLE).map_err(Error::from)?;
let mut protected = HashMap::new();
for (endpoint, auth) in (table.iter().map_err(Error::from)?).flatten() {
let endpoint: ProtectedEndpoint =
serde_json::from_str(endpoint.value()).map_err(Error::from)?;
let auth: AuthRequired = serde_json::from_str(auth.value()).map_err(Error::from)?;
protected.insert(endpoint, Some(auth));
}
Ok(protected)
}
}

View File

@@ -24,8 +24,13 @@ use super::error::Error;
use crate::migrations::migrate_00_to_01; use crate::migrations::migrate_00_to_01;
use crate::mint::migrations::{migrate_02_to_03, migrate_03_to_04}; use crate::mint::migrations::{migrate_02_to_03, migrate_03_to_04};
#[cfg(feature = "auth")]
mod auth;
mod migrations; mod migrations;
#[cfg(feature = "auth")]
pub use auth::MintRedbAuthDatabase;
const ACTIVE_KEYSETS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("active_keysets"); const ACTIVE_KEYSETS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("active_keysets");
const KEYSETS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("keysets"); const KEYSETS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("keysets");
const MINT_QUOTES_TABLE: TableDefinition<[u8; 16], &str> = TableDefinition::new("mint_quotes"); const MINT_QUOTES_TABLE: TableDefinition<[u8; 16], &str> = TableDefinition::new("mint_quotes");
@@ -133,8 +138,8 @@ impl MintRedbDatabase {
None => { None => {
let write_txn = db.begin_write()?; let write_txn = db.begin_write()?;
{ {
let mut table = write_txn.open_table(CONFIG_TABLE)?;
// Open all tables to init a new db // Open all tables to init a new db
let mut table = write_txn.open_table(CONFIG_TABLE)?;
let _ = write_txn.open_table(ACTIVE_KEYSETS_TABLE)?; let _ = write_txn.open_table(ACTIVE_KEYSETS_TABLE)?;
let _ = write_txn.open_table(KEYSETS_TABLE)?; let _ = write_txn.open_table(KEYSETS_TABLE)?;
let _ = write_txn.open_table(MINT_QUOTES_TABLE)?; let _ = write_txn.open_table(MINT_QUOTES_TABLE)?;
@@ -965,7 +970,7 @@ mod tests {
let proofs = vec![ let proofs = vec![
Proof { Proof {
amount: Amount::from(100), amount: Amount::from(100),
keyset_id: keyset_id.clone(), keyset_id,
secret: Secret::generate(), secret: Secret::generate(),
c: SecretKey::generate().public_key(), c: SecretKey::generate().public_key(),
witness: None, witness: None,
@@ -973,7 +978,7 @@ mod tests {
}, },
Proof { Proof {
amount: Amount::from(200), amount: Amount::from(200),
keyset_id: keyset_id.clone(), keyset_id,
secret: Secret::generate(), secret: Secret::generate(),
c: SecretKey::generate().public_key(), c: SecretKey::generate().public_key(),
witness: None, witness: None,
@@ -1026,7 +1031,7 @@ mod tests {
let proofs = vec![ let proofs = vec![
Proof { Proof {
amount: Amount::from(100), amount: Amount::from(100),
keyset_id: keyset_id.clone(), keyset_id,
secret: Secret::generate(), secret: Secret::generate(),
c: SecretKey::generate().public_key(), c: SecretKey::generate().public_key(),
witness: None, witness: None,
@@ -1034,7 +1039,7 @@ mod tests {
}, },
Proof { Proof {
amount: Amount::from(200), amount: Amount::from(200),
keyset_id: keyset_id.clone(), keyset_id,
secret: Secret::generate(), secret: Secret::generate(),
c: SecretKey::generate().public_key(), c: SecretKey::generate().public_key(),
witness: None, witness: None,

View File

@@ -11,9 +11,10 @@ rust-version = "1.75.0" # MSRV
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[features] [features]
default = ["mint", "wallet"] default = ["mint", "wallet", "auth"]
mint = ["cdk-common/mint"] mint = ["cdk-common/mint"]
wallet = ["cdk-common/wallet"] wallet = ["cdk-common/wallet"]
auth = ["cdk-common/auth"]
sqlcipher = ["libsqlite3-sys"] sqlcipher = ["libsqlite3-sys"]
[dependencies] [dependencies]

View File

@@ -0,0 +1,44 @@
CREATE TABLE IF NOT EXISTS proof (
y BLOB PRIMARY KEY,
keyset_id TEXT NOT NULL,
secret TEXT NOT NULL,
c BLOB NOT NULL,
state TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS state_index ON proof(state);
CREATE INDEX IF NOT EXISTS secret_index ON proof(secret);
-- Keysets Table
CREATE TABLE IF NOT EXISTS keyset (
id TEXT PRIMARY KEY,
unit TEXT NOT NULL,
active BOOL NOT NULL,
valid_from INTEGER NOT NULL,
valid_to INTEGER,
derivation_path TEXT NOT NULL,
max_order INTEGER NOT NULL,
derivation_path_index INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS unit_index ON keyset(unit);
CREATE INDEX IF NOT EXISTS active_index ON keyset(active);
CREATE TABLE IF NOT EXISTS blind_signature (
y BLOB PRIMARY KEY,
amount INTEGER NOT NULL,
keyset_id TEXT NOT NULL,
c BLOB NOT NULL
);
CREATE INDEX IF NOT EXISTS keyset_id_index ON blind_signature(keyset_id);
CREATE TABLE IF NOT EXISTS protected_endpoints (
endpoint TEXT PRIMARY KEY,
auth TEXT NOT NULL
);

View File

@@ -0,0 +1,539 @@
//! SQLite Mint Auth
use std::collections::HashMap;
use std::path::Path;
use std::str::FromStr;
use std::time::Duration;
use async_trait::async_trait;
use cdk_common::database::{self, MintAuthDatabase};
use cdk_common::mint::MintKeySetInfo;
use cdk_common::nuts::{AuthProof, BlindSignature, Id, PublicKey, State};
use cdk_common::{AuthRequired, ProtectedEndpoint};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
use sqlx::Row;
use tracing::instrument;
use super::{sqlite_row_to_blind_signature, sqlite_row_to_keyset_info};
use crate::mint::Error;
/// Mint SQLite Database
#[derive(Debug, Clone)]
pub struct MintSqliteAuthDatabase {
pool: SqlitePool,
}
impl MintSqliteAuthDatabase {
/// Create new [`MintSqliteDatabase`]
pub async fn new(path: &Path) -> Result<Self, Error> {
let path = path.to_str().ok_or(Error::InvalidDbPath)?;
let db_options = SqliteConnectOptions::from_str(path)?
.busy_timeout(Duration::from_secs(5))
.read_only(false)
.create_if_missing(true)
.auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::Full);
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(db_options)
.await?;
Ok(Self { pool })
}
/// Migrate [`MintSqliteDatabase`]
pub async fn migrate(&self) {
sqlx::migrate!("./src/mint/auth/migrations")
.run(&self.pool)
.await
.expect("Could not run migrations");
}
}
#[async_trait]
impl MintAuthDatabase for MintSqliteAuthDatabase {
type Err = database::Error;
#[instrument(skip(self))]
async fn set_active_keyset(&self, id: Id) -> Result<(), Self::Err> {
tracing::info!("Setting auth keyset {id} active");
let mut transaction = self.pool.begin().await.map_err(Error::from)?;
let update_res = sqlx::query(
r#"
UPDATE keyset
SET active = CASE
WHEN id = ? THEN TRUE
ELSE FALSE
END;
"#,
)
.bind(id.to_string())
.execute(&mut *transaction)
.await;
match update_res {
Ok(_) => {
transaction.commit().await.map_err(Error::from)?;
Ok(())
}
Err(err) => {
tracing::error!("SQLite Could not update keyset");
if let Err(err) = transaction.rollback().await {
tracing::error!("Could not rollback sql transaction: {}", err);
}
Err(Error::from(err).into())
}
}
}
async fn get_active_keyset_id(&self) -> Result<Option<Id>, Self::Err> {
let mut transaction = self.pool.begin().await.map_err(Error::from)?;
let rec = sqlx::query(
r#"
SELECT id
FROM keyset
WHERE active = 1;
"#,
)
.fetch_one(&mut *transaction)
.await;
let rec = match rec {
Ok(rec) => {
transaction.commit().await.map_err(Error::from)?;
rec
}
Err(err) => match err {
sqlx::Error::RowNotFound => {
transaction.commit().await.map_err(Error::from)?;
return Ok(None);
}
_ => {
return {
if let Err(err) = transaction.rollback().await {
tracing::error!("Could not rollback sql transaction: {}", err);
}
Err(Error::SQLX(err).into())
}
}
},
};
Ok(Some(
Id::from_str(rec.try_get("id").map_err(Error::from)?).map_err(Error::from)?,
))
}
async fn add_keyset_info(&self, keyset: MintKeySetInfo) -> Result<(), Self::Err> {
let mut transaction = self.pool.begin().await.map_err(Error::from)?;
let res = sqlx::query(
r#"
INSERT OR REPLACE INTO keyset
(id, unit, active, valid_from, valid_to, derivation_path, max_order, derivation_path_index)
VALUES (?, ?, ?, ?, ?, ?, ?, ?);
"#,
)
.bind(keyset.id.to_string())
.bind(keyset.unit.to_string())
.bind(keyset.active)
.bind(keyset.valid_from as i64)
.bind(keyset.valid_to.map(|v| v as i64))
.bind(keyset.derivation_path.to_string())
.bind(keyset.max_order)
.bind(keyset.derivation_path_index)
.execute(&mut *transaction)
.await;
match res {
Ok(_) => {
transaction.commit().await.map_err(Error::from)?;
Ok(())
}
Err(err) => {
tracing::error!("SQLite could not add keyset info");
if let Err(err) = transaction.rollback().await {
tracing::error!("Could not rollback sql transaction: {}", err);
}
Err(Error::from(err).into())
}
}
}
async fn get_keyset_info(&self, id: &Id) -> Result<Option<MintKeySetInfo>, Self::Err> {
let mut transaction = self.pool.begin().await.map_err(Error::from)?;
let rec = sqlx::query(
r#"
SELECT *
FROM keyset
WHERE id=?;
"#,
)
.bind(id.to_string())
.fetch_one(&mut *transaction)
.await;
match rec {
Ok(rec) => {
transaction.commit().await.map_err(Error::from)?;
Ok(Some(sqlite_row_to_keyset_info(rec)?))
}
Err(err) => match err {
sqlx::Error::RowNotFound => {
transaction.commit().await.map_err(Error::from)?;
return Ok(None);
}
_ => {
tracing::error!("SQLite could not get keyset info");
if let Err(err) = transaction.rollback().await {
tracing::error!("Could not rollback sql transaction: {}", err);
}
return Err(Error::SQLX(err).into());
}
},
}
}
async fn get_keyset_infos(&self) -> Result<Vec<MintKeySetInfo>, Self::Err> {
let mut transaction = self.pool.begin().await.map_err(Error::from)?;
let recs = sqlx::query(
r#"
SELECT *
FROM keyset;
"#,
)
.fetch_all(&mut *transaction)
.await
.map_err(Error::from);
match recs {
Ok(recs) => {
transaction.commit().await.map_err(Error::from)?;
Ok(recs
.into_iter()
.map(sqlite_row_to_keyset_info)
.collect::<Result<_, _>>()?)
}
Err(err) => {
tracing::error!("SQLite could not get keyset info");
if let Err(err) = transaction.rollback().await {
tracing::error!("Could not rollback sql transaction: {}", err);
}
Err(err.into())
}
}
}
async fn add_proof(&self, proof: AuthProof) -> Result<(), Self::Err> {
let mut transaction = self.pool.begin().await.map_err(Error::from)?;
if let Err(err) = sqlx::query(
r#"
INSERT INTO proof
(y, keyset_id, secret, c, state)
VALUES (?, ?, ?, ?, ?);
"#,
)
.bind(proof.y()?.to_bytes().to_vec())
.bind(proof.keyset_id.to_string())
.bind(proof.secret.to_string())
.bind(proof.c.to_bytes().to_vec())
.bind("UNSPENT")
.execute(&mut *transaction)
.await
.map_err(Error::from)
{
tracing::debug!("Attempting to add known proof. Skipping.... {:?}", err);
}
transaction.commit().await.map_err(Error::from)?;
Ok(())
}
async fn get_proofs_states(&self, ys: &[PublicKey]) -> Result<Vec<Option<State>>, Self::Err> {
let mut transaction = self.pool.begin().await.map_err(Error::from)?;
let sql = format!(
"SELECT y, state FROM proof WHERE y IN ({})",
"?,".repeat(ys.len()).trim_end_matches(',')
);
let mut current_states = ys
.iter()
.fold(sqlx::query(&sql), |query, y| {
query.bind(y.to_bytes().to_vec())
})
.fetch_all(&mut *transaction)
.await
.map_err(|err| {
tracing::error!("SQLite could not get state of proof: {err:?}");
Error::SQLX(err)
})?
.into_iter()
.map(|row| {
PublicKey::from_slice(row.get("y"))
.map_err(Error::from)
.and_then(|y| {
let state: String = row.get("state");
State::from_str(&state)
.map_err(Error::from)
.map(|state| (y, state))
})
})
.collect::<Result<HashMap<_, _>, _>>()?;
Ok(ys.iter().map(|y| current_states.remove(y)).collect())
}
async fn update_proof_state(
&self,
y: &PublicKey,
proofs_state: State,
) -> Result<Option<State>, Self::Err> {
let mut transaction = self.pool.begin().await.map_err(Error::from)?;
// Get current state for single y
let current_state = sqlx::query("SELECT state FROM proof WHERE y = ?")
.bind(y.to_bytes().to_vec())
.fetch_optional(&mut *transaction)
.await
.map_err(|err| {
tracing::error!("SQLite could not get state of proof: {err:?}");
Error::SQLX(err)
})?
.map(|row| {
let state: String = row.get("state");
State::from_str(&state).map_err(Error::from)
})
.transpose()?;
// Update state for single y
sqlx::query("UPDATE proof SET state = ? WHERE state != ? AND y = ?")
.bind(proofs_state.to_string())
.bind(State::Spent.to_string())
.bind(y.to_bytes().to_vec())
.execute(&mut *transaction)
.await
.map_err(|err| {
tracing::error!("SQLite could not update proof state: {err:?}");
Error::SQLX(err)
})?;
transaction.commit().await.map_err(Error::from)?;
Ok(current_state)
}
async fn add_blind_signatures(
&self,
blinded_messages: &[PublicKey],
blind_signatures: &[BlindSignature],
) -> Result<(), Self::Err> {
let mut transaction = self.pool.begin().await.map_err(Error::from)?;
for (message, signature) in blinded_messages.iter().zip(blind_signatures) {
let res = sqlx::query(
r#"
INSERT INTO blind_signature
(y, amount, keyset_id, c)
VALUES (?, ?, ?, ?);
"#,
)
.bind(message.to_bytes().to_vec())
.bind(u64::from(signature.amount) as i64)
.bind(signature.keyset_id.to_string())
.bind(signature.c.to_bytes().to_vec())
.execute(&mut *transaction)
.await;
if let Err(err) = res {
tracing::error!("SQLite could not add blind signature");
if let Err(err) = transaction.rollback().await {
tracing::error!("Could not rollback sql transaction: {}", err);
}
return Err(Error::SQLX(err).into());
}
}
transaction.commit().await.map_err(Error::from)?;
Ok(())
}
async fn get_blind_signatures(
&self,
blinded_messages: &[PublicKey],
) -> Result<Vec<Option<BlindSignature>>, Self::Err> {
let mut transaction = self.pool.begin().await.map_err(Error::from)?;
let sql = format!(
"SELECT * FROM blind_signature WHERE y IN ({})",
"?,".repeat(blinded_messages.len()).trim_end_matches(',')
);
let mut blinded_signatures = blinded_messages
.iter()
.fold(sqlx::query(&sql), |query, y| {
query.bind(y.to_bytes().to_vec())
})
.fetch_all(&mut *transaction)
.await
.map_err(|err| {
tracing::error!("SQLite could not get state of proof: {err:?}");
Error::SQLX(err)
})?
.into_iter()
.map(|row| {
PublicKey::from_slice(row.get("y"))
.map_err(Error::from)
.and_then(|y| sqlite_row_to_blind_signature(row).map(|blinded| (y, blinded)))
})
.collect::<Result<HashMap<_, _>, _>>()?;
Ok(blinded_messages
.iter()
.map(|y| blinded_signatures.remove(y))
.collect())
}
async fn add_protected_endpoints(
&self,
protected_endpoints: HashMap<ProtectedEndpoint, AuthRequired>,
) -> Result<(), Self::Err> {
let mut transaction = self.pool.begin().await.map_err(Error::from)?;
for (endpoint, auth) in protected_endpoints.iter() {
if let Err(err) = sqlx::query(
r#"
INSERT OR REPLACE INTO protected_endpoints
(endpoint, auth)
VALUES (?, ?);
"#,
)
.bind(serde_json::to_string(endpoint)?)
.bind(serde_json::to_string(auth)?)
.execute(&mut *transaction)
.await
.map_err(Error::from)
{
tracing::debug!(
"Attempting to add protected endpoint. Skipping.... {:?}",
err
);
}
}
transaction.commit().await.map_err(Error::from)?;
Ok(())
}
async fn remove_protected_endpoints(
&self,
protected_endpoints: Vec<ProtectedEndpoint>,
) -> Result<(), Self::Err> {
let mut transaction = self.pool.begin().await.map_err(Error::from)?;
let sql = format!(
"DELETE FROM protected_endpoints WHERE endpoint IN ({})",
std::iter::repeat("?")
.take(protected_endpoints.len())
.collect::<Vec<_>>()
.join(",")
);
let endpoints = protected_endpoints
.iter()
.map(serde_json::to_string)
.collect::<Result<Vec<_>, _>>()?;
endpoints
.iter()
.fold(sqlx::query(&sql), |query, endpoint| query.bind(endpoint))
.execute(&mut *transaction)
.await
.map_err(Error::from)?;
transaction.commit().await.map_err(Error::from)?;
Ok(())
}
async fn get_auth_for_endpoint(
&self,
protected_endpoint: ProtectedEndpoint,
) -> Result<Option<AuthRequired>, Self::Err> {
let mut transaction = self.pool.begin().await.map_err(Error::from)?;
let rec = sqlx::query(
r#"
SELECT *
FROM protected_endpoints
WHERE endpoint=?;
"#,
)
.bind(serde_json::to_string(&protected_endpoint)?)
.fetch_one(&mut *transaction)
.await;
match rec {
Ok(rec) => {
transaction.commit().await.map_err(Error::from)?;
let auth: String = rec.try_get("auth").map_err(Error::from)?;
Ok(Some(serde_json::from_str(&auth)?))
}
Err(err) => match err {
sqlx::Error::RowNotFound => {
transaction.commit().await.map_err(Error::from)?;
return Ok(None);
}
_ => {
return {
if let Err(err) = transaction.rollback().await {
tracing::error!("Could not rollback sql transaction: {}", err);
}
Err(Error::SQLX(err).into())
}
}
},
}
}
async fn get_auth_for_endpoints(
&self,
) -> Result<HashMap<ProtectedEndpoint, Option<AuthRequired>>, Self::Err> {
let mut transaction = self.pool.begin().await.map_err(Error::from)?;
let recs = sqlx::query(
r#"
SELECT *
FROM protected_endpoints
"#,
)
.fetch_all(&mut *transaction)
.await;
match recs {
Ok(recs) => {
transaction.commit().await.map_err(Error::from)?;
let mut endpoints = HashMap::new();
for rec in recs {
let auth: String = rec.try_get("auth").map_err(Error::from)?;
let endpoint: String = rec.try_get("endpoint").map_err(Error::from)?;
let endpoint: ProtectedEndpoint = serde_json::from_str(&endpoint)?;
let auth: AuthRequired = serde_json::from_str(&auth)?;
endpoints.insert(endpoint, Some(auth));
}
Ok(endpoints)
}
Err(err) => {
tracing::error!("SQLite could not get protected endpoints");
if let Err(err) = transaction.rollback().await {
tracing::error!("Could not rollback sql transaction: {}", err);
}
Err(Error::from(err).into())
}
}
}
}

View File

@@ -26,9 +26,14 @@ use uuid::Uuid;
use crate::common::create_sqlite_pool; use crate::common::create_sqlite_pool;
#[cfg(feature = "auth")]
mod auth;
pub mod error; pub mod error;
pub mod memory; pub mod memory;
#[cfg(feature = "auth")]
pub use auth::MintSqliteAuthDatabase;
/// Mint SQLite Database /// Mint SQLite Database
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct MintSqliteDatabase { pub struct MintSqliteDatabase {
@@ -1565,7 +1570,7 @@ fn sqlite_row_to_keyset_info(row: SqliteRow) -> Result<MintKeySetInfo, Error> {
let row_valid_to: Option<i64> = row.try_get("valid_to").map_err(Error::from)?; let row_valid_to: Option<i64> = row.try_get("valid_to").map_err(Error::from)?;
let row_derivation_path: String = row.try_get("derivation_path").map_err(Error::from)?; let row_derivation_path: String = row.try_get("derivation_path").map_err(Error::from)?;
let row_max_order: u8 = row.try_get("max_order").map_err(Error::from)?; let row_max_order: u8 = row.try_get("max_order").map_err(Error::from)?;
let row_keyset_ppk: Option<i64> = row.try_get("input_fee_ppk").map_err(Error::from)?; let row_keyset_ppk: Option<i64> = row.try_get("input_fee_ppk").ok();
let row_derivation_path_index: Option<i64> = let row_derivation_path_index: Option<i64> =
row.try_get("derivation_path_index").map_err(Error::from)?; row.try_get("derivation_path_index").map_err(Error::from)?;
@@ -1748,7 +1753,7 @@ mod tests {
// Create a keyset and add it to the database // Create a keyset and add it to the database
let keyset_id = Id::from_str("00916bbf7ef91a36").unwrap(); let keyset_id = Id::from_str("00916bbf7ef91a36").unwrap();
let keyset_info = MintKeySetInfo { let keyset_info = MintKeySetInfo {
id: keyset_id.clone(), id: keyset_id,
unit: CurrencyUnit::Sat, unit: CurrencyUnit::Sat,
active: true, active: true,
valid_from: 0, valid_from: 0,
@@ -1763,7 +1768,7 @@ mod tests {
let proofs = vec![ let proofs = vec![
Proof { Proof {
amount: Amount::from(100), amount: Amount::from(100),
keyset_id: keyset_id.clone(), keyset_id,
secret: Secret::generate(), secret: Secret::generate(),
c: SecretKey::generate().public_key(), c: SecretKey::generate().public_key(),
witness: None, witness: None,
@@ -1771,7 +1776,7 @@ mod tests {
}, },
Proof { Proof {
amount: Amount::from(200), amount: Amount::from(200),
keyset_id: keyset_id.clone(), keyset_id,
secret: Secret::generate(), secret: Secret::generate(),
c: SecretKey::generate().public_key(), c: SecretKey::generate().public_key(),
witness: None, witness: None,
@@ -1816,7 +1821,7 @@ mod tests {
// Create a keyset and add it to the database // Create a keyset and add it to the database
let keyset_id = Id::from_str("00916bbf7ef91a36").unwrap(); let keyset_id = Id::from_str("00916bbf7ef91a36").unwrap();
let keyset_info = MintKeySetInfo { let keyset_info = MintKeySetInfo {
id: keyset_id.clone(), id: keyset_id,
unit: CurrencyUnit::Sat, unit: CurrencyUnit::Sat,
active: true, active: true,
valid_from: 0, valid_from: 0,
@@ -1831,7 +1836,7 @@ mod tests {
let proofs = vec![ let proofs = vec![
Proof { Proof {
amount: Amount::from(100), amount: Amount::from(100),
keyset_id: keyset_id.clone(), keyset_id,
secret: Secret::generate(), secret: Secret::generate(),
c: SecretKey::generate().public_key(), c: SecretKey::generate().public_key(),
witness: None, witness: None,
@@ -1839,7 +1844,7 @@ mod tests {
}, },
Proof { Proof {
amount: Amount::from(200), amount: Amount::from(200),
keyset_id: keyset_id.clone(), keyset_id,
secret: Secret::generate(), secret: Secret::generate(),
c: SecretKey::generate().public_key(), c: SecretKey::generate().public_key(),
witness: None, witness: None,

View File

@@ -11,11 +11,12 @@ license = "MIT"
[features] [features]
default = ["mint", "wallet"] default = ["mint", "wallet", "auth"]
mint = ["dep:futures", "cdk-common/mint"] wallet = ["dep:reqwest", "cdk-common/wallet"]
mint = ["dep:futures", "dep:reqwest", "cdk-common/mint"]
auth = ["dep:jsonwebtoken", "cdk-common/auth", "cdk-common/auth"]
# We do not commit to a MSRV with swagger enabled # We do not commit to a MSRV with swagger enabled
swagger = ["mint", "dep:utoipa", "cdk-common/swagger"] swagger = ["mint", "dep:utoipa", "cdk-common/swagger"]
wallet = ["dep:reqwest", "cdk-common/wallet"]
bench = [] bench = []
http_subscription = [] http_subscription = []
@@ -28,7 +29,7 @@ anyhow.workspace = true
bitcoin.workspace = true bitcoin.workspace = true
ciborium.workspace = true ciborium.workspace = true
lightning-invoice.workspace = true lightning-invoice.workspace = true
regex = "1" regex.workspace = true
reqwest = { workspace = true, optional = true } reqwest = { workspace = true, optional = true }
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
@@ -39,6 +40,7 @@ futures = { workspace = true, optional = true, features = ["alloc"] }
url.workspace = true url.workspace = true
utoipa = { workspace = true, optional = true } utoipa = { workspace = true, optional = true }
uuid.workspace = true uuid.workspace = true
jsonwebtoken = { version = "9", optional = true }
# -Z minimal-versions # -Z minimal-versions
sync_wrapper = "0.1.2" sync_wrapper = "0.1.2"
@@ -78,12 +80,18 @@ required-features = ["wallet"]
name = "proof-selection" name = "proof-selection"
required-features = ["wallet"] required-features = ["wallet"]
[[example]]
name = "auth_wallet"
required-features = ["wallet", "auth"]
[dev-dependencies] [dev-dependencies]
rand = "0.8.5" rand.workspace = true
cdk-sqlite.workspace = true cdk-sqlite.workspace = true
bip39.workspace = true bip39.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
criterion = "0.5.1" criterion = "0.5.1"
reqwest = { workspace = true }
[[bench]] [[bench]]
name = "dhke_benchmarks" name = "dhke_benchmarks"

View File

@@ -44,7 +44,7 @@ async fn main() {
let localstore = memory::empty().await.unwrap(); let localstore = memory::empty().await.unwrap();
let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed); let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None, None);
let quote = wallet.mint_quote(amount).await.unwrap(); let quote = wallet.mint_quote(amount).await.unwrap();

View File

@@ -0,0 +1,150 @@
use std::sync::Arc;
use cdk::amount::SplitTarget;
use cdk::error::Error;
use cdk::nuts::{CurrencyUnit, MintQuoteState, NotificationPayload};
use cdk::wallet::{SendOptions, Wallet, WalletSubscription};
use cdk::{Amount, OidcClient};
use cdk_common::{MintInfo, ProofsMethods};
use cdk_sqlite::wallet::memory;
use rand::Rng;
use tracing_subscriber::EnvFilter;
const TEST_USERNAME: &str = "cdk-test";
const TEST_PASSWORD: &str = "cdkpassword";
#[tokio::main]
async fn main() -> Result<(), Error> {
// Set up logging
let default_filter = "debug";
let sqlx_filter = "sqlx=warn,hyper_util=warn,reqwest=warn,rustls=warn";
let env_filter = EnvFilter::new(format!("{},{}", default_filter, sqlx_filter));
tracing_subscriber::fmt().with_env_filter(env_filter).init();
// Initialize the memory store for the wallet
let localstore = memory::empty().await?;
// Generate a random seed for the wallet
let seed = rand::thread_rng().gen::<[u8; 32]>();
// Define the mint URL and currency unit
let mint_url = "http://127.0.0.1:8085";
let unit = CurrencyUnit::Sat;
let amount = Amount::from(50);
// Create a new wallet
let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None)?;
let mint_info = wallet
.get_mint_info()
.await
.expect("mint info")
.expect("could not get mint info");
// Request a mint quote from the wallet
let quote = wallet.mint_quote(amount, None).await;
println!("Minting nuts ... {:?}", quote);
// Getting the CAT token is not inscope of cdk and expected to be handled by the implemntor
// We just use this helper fn with password auth for testing
let access_token = get_access_token(&mint_info).await;
wallet.set_cat(access_token).await.unwrap();
wallet
.mint_blind_auth(10.into())
.await
.expect("Could not mint blind auth");
// Request a mint quote from the wallet
let quote = wallet.mint_quote(amount, None).await?;
// Subscribe to updates on the mint quote state
let mut subscription = wallet
.subscribe(WalletSubscription::Bolt11MintQuoteState(vec![quote
.id
.clone()]))
.await;
// Wait for the mint quote to be paid
while let Some(msg) = subscription.recv().await {
if let NotificationPayload::MintQuoteBolt11Response(response) = msg {
if response.state == MintQuoteState::Paid {
break;
}
}
}
// Mint the received amount
let receive_amount = wallet.mint(&quote.id, SplitTarget::default(), None).await?;
println!("Received: {}", receive_amount.total_amount()?);
// Get the total balance of the wallet
let balance = wallet.total_balance().await?;
println!("Wallet balance: {}", balance);
let prepared_send = wallet
.prepare_send(10.into(), SendOptions::default())
.await?;
let token = wallet.send(prepared_send, None).await?;
println!("Created token: {}", token);
let remaining_blind_auth = wallet.get_unspent_auth_proofs().await?.len();
// We started with 10 blind tokens we expect 8 ath this point
// 1 is used for the mint quote + 1 used for the mint
// The swap is not expected to use one as it will be offline or we have "/swap" as an unprotected endpoint in the mint config
assert_eq!(remaining_blind_auth, 8);
println!("Remaining blind auth: {}", remaining_blind_auth);
Ok(())
}
async fn get_access_token(mint_info: &MintInfo) -> String {
let openid_discovery = mint_info
.nuts
.nut21
.clone()
.expect("Nut21 defined")
.openid_discovery;
let oidc_client = OidcClient::new(openid_discovery);
// Get the token endpoint from the OIDC configuration
let token_url = oidc_client
.get_oidc_config()
.await
.expect("Failed to get OIDC config")
.token_endpoint;
// Create the request parameters
let params = [
("grant_type", "password"),
("client_id", "cashu-client"),
("username", TEST_USERNAME),
("password", TEST_PASSWORD),
];
// Make the token request directly
let client = reqwest::Client::new();
let response = client
.post(token_url)
.form(&params)
.send()
.await
.expect("Failed to send token request");
let token_response: serde_json::Value = response
.json()
.await
.expect("Failed to parse token response");
token_response["access_token"]
.as_str()
.expect("No access token in response")
.to_string()
}

View File

@@ -32,7 +32,7 @@ async fn main() -> Result<(), Error> {
let amount = Amount::from(100); let amount = Amount::from(100);
// Create a new wallet // Create a new wallet
let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, Some(1))?; let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap();
// Request a mint quote from the wallet // Request a mint quote from the wallet
let quote = wallet.mint_quote(amount, None).await?; let quote = wallet.mint_quote(amount, None).await?;

View File

@@ -24,7 +24,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let localstore = memory::empty().await?; let localstore = memory::empty().await?;
// Create a new wallet // Create a new wallet
let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None)?; let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap();
// Amount to mint // Amount to mint
for amount in [64] { for amount in [64] {

View File

@@ -24,7 +24,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let localstore = memory::empty().await?; let localstore = memory::empty().await?;
// Create a new wallet // Create a new wallet
let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None)?; let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap();
// Request a mint quote from the wallet // Request a mint quote from the wallet
let quote = wallet.mint_quote(amount, None).await?; let quote = wallet.mint_quote(amount, None).await?;

View File

@@ -6,6 +6,8 @@
pub mod cdk_database { pub mod cdk_database {
//! CDK Database //! CDK Database
pub use cdk_common::database::Error; pub use cdk_common::database::Error;
#[cfg(all(feature = "mint", feature = "auth"))]
pub use cdk_common::database::MintAuthDatabase;
#[cfg(feature = "mint")] #[cfg(feature = "mint")]
pub use cdk_common::database::MintDatabase; pub use cdk_common::database::MintDatabase;
#[cfg(feature = "wallet")] #[cfg(feature = "wallet")]
@@ -17,6 +19,12 @@ pub mod mint;
#[cfg(feature = "wallet")] #[cfg(feature = "wallet")]
pub mod wallet; pub mod wallet;
#[cfg(all(any(feature = "wallet", feature = "mint"), feature = "auth"))]
mod oidc_client;
#[cfg(all(any(feature = "wallet", feature = "mint"), feature = "auth"))]
pub use oidc_client::OidcClient;
pub mod pub_sub; pub mod pub_sub;
/// Re-export amount type /// Re-export amount type
@@ -45,7 +53,7 @@ pub use wallet::{Wallet, WalletSubscription};
pub use self::util::SECP256K1; pub use self::util::SECP256K1;
#[cfg(feature = "wallet")] #[cfg(feature = "wallet")]
#[doc(hidden)] #[doc(hidden)]
pub use self::wallet::client::HttpClient; pub use self::wallet::HttpClient;
/// Result /// Result
#[doc(hidden)] #[doc(hidden)]

View File

@@ -0,0 +1,413 @@
use cdk_common::{CurrencyUnit, MintKeySet};
use tracing::instrument;
use super::nut21::ProtectedEndpoint;
use super::{
AuthProof, AuthRequired, AuthToken, BlindAuthToken, BlindSignature, BlindedMessage, Error, Id,
Mint, State,
};
use crate::dhke::{sign_message, verify_message};
use crate::Amount;
impl Mint {
/// Check if and what kind of auth is required for a method
#[instrument(skip(self), fields(endpoint = ?method))]
pub async fn is_protected(
&self,
method: &ProtectedEndpoint,
) -> Result<Option<AuthRequired>, Error> {
if let Some(auth_db) = self.auth_localstore.as_ref() {
Ok(auth_db.get_auth_for_endpoint(*method).await?)
} else {
Ok(None)
}
}
/// Verify Clear auth
#[instrument(skip_all, fields(token_len = token.len()))]
pub async fn verify_clear_auth(&self, token: String) -> Result<(), Error> {
Ok(self
.oidc_client
.as_ref()
.ok_or(Error::OidcNotSet)?
.verify_cat(&token)
.await?)
}
/// Ensure Keyset is loaded in mint
#[instrument(skip(self))]
pub async fn ensure_blind_auth_keyset_loaded(&self, id: &Id) -> Result<MintKeySet, Error> {
{
if let Some(keyset) = self.keysets.read().await.get(id) {
return Ok(keyset.clone());
}
}
tracing::info!(
"Keyset {:?} not found in memory, attempting to load from storage",
id
);
let mut keysets = self.keysets.write().await;
// Get auth_localstore reference
let auth_localstore = match self.auth_localstore.as_ref() {
Some(store) => store,
None => {
tracing::error!("Auth localstore is not configured");
return Err(Error::AmountKey);
}
};
// Get keyset info from storage
let keyset_info = match auth_localstore.get_keyset_info(id).await {
Ok(Some(info)) => {
tracing::debug!("Found keyset info in storage for ID {:?}", id);
info
}
Ok(None) => {
tracing::error!("Keyset with ID {:?} not found in storage", id);
return Err(Error::KeysetUnknown(*id));
}
Err(e) => {
tracing::error!("Error retrieving keyset info from storage: {:?}", e);
return Err(e.into());
}
};
let id = keyset_info.id;
tracing::info!("Generating and inserting keyset {:?} into memory", id);
let keyset = self.generate_keyset(keyset_info);
keysets.insert(id, keyset.clone());
tracing::debug!("Keyset {:?} successfully loaded", id);
Ok(keyset)
}
/// Verify Blind auth
#[instrument(skip(self, token))]
pub async fn verify_blind_auth(&self, token: &BlindAuthToken) -> Result<(), Error> {
let proof = &token.auth_proof;
let keyset_id = proof.keyset_id;
tracing::trace!(
"Starting blind auth verification for keyset ID: {:?}",
keyset_id
);
// Ensure the keyset is loaded
let keyset = self
.ensure_blind_auth_keyset_loaded(&keyset_id)
.await
.map_err(|err| {
tracing::error!("Failed to load keyset: {:?}", err);
err
})?;
// Verify keyset is for auth
if keyset.unit != CurrencyUnit::Auth {
tracing::warn!(
"Blind auth attempted with non-auth keyset. Found unit: {:?}",
keyset.unit
);
return Err(Error::BlindAuthFailed);
}
// Get the keypair for amount 1
let keypair = match keyset.keys.get(&Amount::from(1)) {
Some(key_pair) => key_pair,
None => {
tracing::error!("No keypair found for amount 1 in keyset {:?}", keyset_id);
return Err(Error::AmountKey);
}
};
// Verify the message
match verify_message(&keypair.secret_key, proof.c, proof.secret.as_bytes()) {
Ok(_) => {
tracing::trace!(
"Blind signature verification successful for keyset ID: {:?}",
keyset_id
);
}
Err(e) => {
tracing::error!("Blind signature verification failed: {:?}", e);
return Err(e.into());
}
}
Ok(())
}
/// Verify Auth
///
/// If it is a blind auth this will also burn the proof
#[instrument(skip_all)]
pub async fn verify_auth(
&self,
auth_token: Option<AuthToken>,
endpoint: &ProtectedEndpoint,
) -> Result<(), Error> {
if let Some(auth_required) = self.is_protected(endpoint).await? {
tracing::info!(
"Auth required for endpoint: {:?}, type: {:?}",
endpoint,
auth_required
);
let auth_token = match auth_token {
Some(token) => token,
None => match auth_required {
AuthRequired::Clear => {
tracing::warn!(
"No auth token provided for protected endpoint: {:?}, expected clear auth.",
endpoint
);
return Err(Error::ClearAuthRequired);
}
AuthRequired::Blind => {
tracing::warn!(
"No auth token provided for protected endpoint: {:?}, expected blind auth.",
endpoint
);
return Err(Error::BlindAuthRequired);
}
},
};
match (auth_required, auth_token) {
(AuthRequired::Clear, AuthToken::ClearAuth(token)) => {
tracing::debug!("Verifying clear auth token");
match self.verify_clear_auth(token.clone()).await {
Ok(_) => tracing::info!("Clear auth verification successful"),
Err(e) => {
tracing::error!("Clear auth verification failed: {:?}", e);
return Err(e);
}
}
}
(AuthRequired::Blind, AuthToken::BlindAuth(token)) => {
tracing::debug!(
"Verifying blind auth token with keyset_id: {:?}",
token.auth_proof.keyset_id
);
match self.verify_blind_auth(&token).await {
Ok(_) => tracing::debug!("Blind auth signature verification successful"),
Err(e) => {
tracing::error!("Blind auth verification failed: {:?}", e);
return Err(e);
}
}
let auth_proof = token.auth_proof;
self.check_blind_auth_proof_spendable(auth_proof)
.await
.map_err(|err| {
tracing::error!("Failed to spend blind auth proof: {:?}", err);
err
})?;
}
(AuthRequired::Blind, other) => {
tracing::warn!(
"Blind auth required but received different auth type: {:?}",
other
);
return Err(Error::BlindAuthRequired);
}
(AuthRequired::Clear, other) => {
tracing::warn!(
"Clear auth required but received different auth type: {:?}",
other
);
return Err(Error::ClearAuthRequired);
}
}
} else {
tracing::debug!("No auth required for endpoint: {:?}", endpoint);
}
tracing::debug!("Auth verification completed successfully");
Ok(())
}
/// Check state of blind auth proof and mark it as spent
#[instrument(skip_all)]
pub async fn check_blind_auth_proof_spendable(&self, proof: AuthProof) -> Result<(), Error> {
tracing::trace!(
"Checking if blind auth proof is spendable for keyset ID: {:?}",
proof.keyset_id
);
// Get auth_localstore reference
let auth_localstore = match self.auth_localstore.as_ref() {
Some(store) => store,
None => {
tracing::error!("Auth localstore is not configured");
return Err(Error::AmountKey);
}
};
// Calculate the Y value for the proof
let y = proof.y().map_err(|err| {
tracing::error!("Failed to calculate Y value for proof: {:?}", err);
err
})?;
// Add proof to the database
auth_localstore
.add_proof(proof.clone())
.await
.map_err(|err| {
tracing::error!("Failed to add proof to database: {:?}", err);
err
})?;
// Update proof state to spent
let state = match auth_localstore.update_proof_state(&y, State::Spent).await {
Ok(state) => {
tracing::debug!(
"Successfully updated proof state to SPENT, previous state: {:?}",
state
);
state
}
Err(e) => {
tracing::error!("Failed to update proof state: {:?}", e);
return Err(e.into());
}
};
// Check previous state
match state {
Some(State::Spent) => {
tracing::warn!("Token already spent: {:?}", y);
return Err(Error::TokenAlreadySpent);
}
Some(State::Pending) => {
tracing::warn!("Token is pending: {:?}", y);
return Err(Error::TokenPending);
}
Some(other_state) => {
tracing::trace!("Token was in state {:?}, now marked as spent", other_state);
}
None => {
tracing::trace!("Token was in state None, now marked as spent");
}
};
Ok(())
}
/// Blind Sign
#[instrument(skip_all)]
pub async fn auth_blind_sign(
&self,
blinded_message: &BlindedMessage,
) -> Result<BlindSignature, Error> {
let BlindedMessage {
amount,
blinded_secret,
keyset_id,
..
} = blinded_message;
// Ensure the keyset is loaded
let keyset = match self.ensure_blind_auth_keyset_loaded(keyset_id).await {
Ok(keyset) => keyset,
Err(e) => {
tracing::error!("Failed to load keyset: {:?}", e);
return Err(e);
}
};
// Get auth_localstore reference
let auth_localstore = match self.auth_localstore.as_ref() {
Some(store) => store,
None => {
tracing::error!("Auth localstore is not configured");
return Err(Error::AuthSettingsUndefined);
}
};
// Get keyset info
let keyset_info = match auth_localstore.get_keyset_info(keyset_id).await {
Ok(Some(info)) => info,
Ok(None) => {
tracing::error!("Keyset with ID {:?} not found in storage", keyset_id);
return Err(Error::UnknownKeySet);
}
Err(e) => {
tracing::error!("Error retrieving keyset info from storage: {:?}", e);
return Err(e.into());
}
};
// Get active keyset ID
let active = match auth_localstore.get_active_keyset_id().await {
Ok(Some(id)) => id,
Ok(None) => {
tracing::error!("No active keyset found");
return Err(Error::InactiveKeyset);
}
Err(e) => {
tracing::error!("Error retrieving active keyset ID: {:?}", e);
return Err(e.into());
}
};
// Check that the keyset is active and should be used to sign
if keyset_info.id.ne(&active) {
tracing::warn!(
"Keyset {:?} is not active. Active keyset is {:?}",
keyset_info.id,
active
);
return Err(Error::InactiveKeyset);
}
// Get the keypair for the specified amount
let key_pair = match keyset.keys.get(amount) {
Some(key_pair) => key_pair,
None => {
tracing::error!(
"No keypair found for amount {:?} in keyset {:?}",
amount,
keyset_id
);
return Err(Error::AmountKey);
}
};
// Sign the message
let c = match sign_message(&key_pair.secret_key, blinded_secret) {
Ok(signature) => signature,
Err(e) => {
tracing::error!("Failed to sign message: {:?}", e);
return Err(e.into());
}
};
// Create blinded signature
let blinded_signature = match BlindSignature::new(
*amount,
c,
keyset_info.id,
&blinded_message.blinded_secret,
key_pair.secret_key.clone(),
) {
Ok(sig) => sig,
Err(e) => {
tracing::error!("Failed to create blinded signature: {:?}", e);
return Err(e.into());
}
};
tracing::trace!(
"Blind signing completed successfully for keyset ID: {:?}",
keyset_id
);
Ok(blinded_signature)
}
}

View File

@@ -8,11 +8,16 @@ use bitcoin::bip32::DerivationPath;
use cdk_common::database::{self, MintDatabase}; use cdk_common::database::{self, MintDatabase};
use cdk_common::error::Error; use cdk_common::error::Error;
use cdk_common::payment::Bolt11Settings; use cdk_common::payment::Bolt11Settings;
use cdk_common::{nut21, nut22};
use super::nut17::SupportedMethods; use super::nut17::SupportedMethods;
use super::nut19::{self, CachedEndpoint}; use super::nut19::{self, CachedEndpoint};
#[cfg(feature = "auth")]
use super::MintAuthDatabase;
use super::Nuts; use super::Nuts;
use crate::amount::Amount; use crate::amount::Amount;
#[cfg(feature = "auth")]
use crate::cdk_database;
use crate::cdk_payment::{self, MintPayment}; use crate::cdk_payment::{self, MintPayment};
use crate::mint::Mint; use crate::mint::Mint;
use crate::nuts::{ use crate::nuts::{
@@ -28,6 +33,9 @@ pub struct MintBuilder {
pub mint_info: MintInfo, pub mint_info: MintInfo,
/// Mint Storage backend /// Mint Storage backend
localstore: Option<Arc<dyn MintDatabase<Err = database::Error> + Send + Sync>>, localstore: Option<Arc<dyn MintDatabase<Err = database::Error> + Send + Sync>>,
/// Mint Storage backend
#[cfg(feature = "auth")]
auth_localstore: Option<Arc<dyn MintAuthDatabase<Err = cdk_database::Error> + Send + Sync>>,
/// Ln backends for mint /// Ln backends for mint
ln: Option< ln: Option<
HashMap<PaymentProcessorKey, Arc<dyn MintPayment<Err = cdk_payment::Error> + Send + Sync>>, HashMap<PaymentProcessorKey, Arc<dyn MintPayment<Err = cdk_payment::Error> + Send + Sync>>,
@@ -35,6 +43,8 @@ pub struct MintBuilder {
seed: Option<Vec<u8>>, seed: Option<Vec<u8>>,
supported_units: HashMap<CurrencyUnit, (u64, u8)>, supported_units: HashMap<CurrencyUnit, (u64, u8)>,
custom_paths: HashMap<CurrencyUnit, DerivationPath>, custom_paths: HashMap<CurrencyUnit, DerivationPath>,
// protected_endpoints: HashMap<ProtectedEndpoint, AuthRequired>,
openid_discovery: Option<String>,
} }
impl MintBuilder { impl MintBuilder {
@@ -66,6 +76,22 @@ impl MintBuilder {
self self
} }
/// Set auth localstore
#[cfg(feature = "auth")]
pub fn with_auth_localstore(
mut self,
localstore: Arc<dyn MintAuthDatabase<Err = cdk_database::Error> + Send + Sync>,
) -> MintBuilder {
self.auth_localstore = Some(localstore);
self
}
/// Set Openid discovery url
pub fn with_openid_discovery(mut self, openid_discovery: String) -> Self {
self.openid_discovery = Some(openid_discovery);
self
}
/// Set seed /// Set seed
pub fn with_seed(mut self, seed: Vec<u8>) -> Self { pub fn with_seed(mut self, seed: Vec<u8>) -> Self {
self.seed = Some(seed); self.seed = Some(seed);
@@ -141,6 +167,9 @@ impl MintBuilder {
method: method.clone(), method: method.clone(),
}; };
tracing::debug!("Adding ln backed for {}, {}", unit, method);
tracing::debug!("with limits {:?}", limits);
let mut ln = self.ln.unwrap_or_default(); let mut ln = self.ln.unwrap_or_default();
let settings = ln_backend.get_settings().await?; let settings = ln_backend.get_settings().await?;
@@ -235,17 +264,73 @@ impl MintBuilder {
self self
} }
/// Set clear auth settings
pub fn set_clear_auth_settings(mut self, openid_discovery: String, client_id: String) -> Self {
let mut nuts = self.mint_info.nuts;
nuts.nut21 = Some(nut21::Settings::new(
openid_discovery.clone(),
client_id,
vec![],
));
self.openid_discovery = Some(openid_discovery);
self.mint_info.nuts = nuts;
self
}
/// Set blind auth settings
pub fn set_blind_auth_settings(mut self, bat_max_mint: u64) -> Self {
let mut nuts = self.mint_info.nuts;
nuts.nut22 = Some(nut22::Settings::new(bat_max_mint, vec![]));
self.mint_info.nuts = nuts;
self
}
/// Build mint /// Build mint
pub async fn build(&self) -> anyhow::Result<Mint> { pub async fn build(&self) -> anyhow::Result<Mint> {
let localstore = self let localstore = self
.localstore .localstore
.clone() .clone()
.ok_or(anyhow!("Localstore not set"))?; .ok_or(anyhow!("Localstore not set"))?;
let seed = self.seed.as_ref().ok_or(anyhow!("Mint seed not set"))?;
let ln = self.ln.clone().ok_or(anyhow!("Ln backends not set"))?;
#[cfg(feature = "auth")]
if let Some(openid_discovery) = &self.openid_discovery {
let auth_localstore = self
.auth_localstore
.clone()
.ok_or(anyhow!("Auth localstore not set"))?;
return Ok(Mint::new_with_auth(
seed,
localstore,
auth_localstore,
ln,
self.supported_units.clone(),
self.custom_paths.clone(),
openid_discovery.clone(),
)
.await?);
}
#[cfg(not(feature = "auth"))]
if self.openid_discovery.is_some() {
return Err(anyhow!(
"OpenID discovery URL provided but auth feature is not enabled"
));
}
Ok(Mint::new( Ok(Mint::new(
self.seed.as_ref().ok_or(anyhow!("Mint seed not set"))?, seed,
localstore, localstore,
self.ln.clone().ok_or(anyhow!("Ln backends not set"))?, ln,
self.supported_units.clone(), self.supported_units.clone(),
self.custom_paths.clone(), self.custom_paths.clone(),
) )

View File

@@ -0,0 +1,54 @@
use tracing::instrument;
use crate::mint::nut22::MintAuthRequest;
use crate::mint::{AuthToken, MintBolt11Response};
use crate::{Amount, Error, Mint};
impl Mint {
/// Mint Auth Proofs
#[instrument(skip_all)]
pub async fn mint_blind_auth(
&self,
auth_token: AuthToken,
mint_auth_request: MintAuthRequest,
) -> Result<MintBolt11Response, Error> {
let cat = if let AuthToken::ClearAuth(cat) = auth_token {
cat
} else {
tracing::debug!("Received blind auth mint without cat");
return Err(Error::ClearAuthRequired);
};
self.verify_clear_auth(cat).await?;
let auth_settings = self
.mint_info()
.await?
.nuts
.nut22
.ok_or(Error::AuthSettingsUndefined)?;
if mint_auth_request.amount() > auth_settings.bat_max_mint {
return Err(Error::AmountOutofLimitRange(
1.into(),
auth_settings.bat_max_mint.into(),
mint_auth_request.amount().into(),
));
}
let mut blind_signatures = Vec::with_capacity(mint_auth_request.outputs.len());
for blinded_message in mint_auth_request.outputs.iter() {
if blinded_message.amount != Amount::from(1) {
return Err(Error::AmountKey);
}
let blind_signature = self.auth_blind_sign(blinded_message).await?;
blind_signatures.push(blind_signature);
}
Ok(MintBolt11Response {
signatures: blind_signatures,
})
}
}

View File

@@ -2,15 +2,14 @@ use cdk_common::payment::Bolt11Settings;
use tracing::instrument; use tracing::instrument;
use uuid::Uuid; use uuid::Uuid;
use super::verification::Verification; use crate::mint::{
use super::{ CurrencyUnit, MintBolt11Request, MintBolt11Response, MintQuote, MintQuoteBolt11Request,
nut04, CurrencyUnit, Mint, MintQuote, MintQuoteBolt11Request, MintQuoteBolt11Response, MintQuoteBolt11Response, MintQuoteState, NotificationPayload, PublicKey, Verification,
NotificationPayload, PaymentMethod, PublicKey,
}; };
use crate::nuts::MintQuoteState; use crate::nuts::PaymentMethod;
use crate::types::PaymentProcessorKey; use crate::types::PaymentProcessorKey;
use crate::util::unix_time; use crate::util::unix_time;
use crate::{ensure_cdk, Amount, Error}; use crate::{ensure_cdk, Amount, Error, Mint};
impl Mint { impl Mint {
/// Checks that minting is enabled, request is supported unit and within range /// Checks that minting is enabled, request is supported unit and within range
@@ -260,8 +259,8 @@ impl Mint {
#[instrument(skip_all)] #[instrument(skip_all)]
pub async fn process_mint_request( pub async fn process_mint_request(
&self, &self,
mint_request: nut04::MintBolt11Request<Uuid>, mint_request: MintBolt11Request<Uuid>,
) -> Result<nut04::MintBolt11Response, Error> { ) -> Result<MintBolt11Response, Error> {
let mint_quote = self let mint_quote = self
.localstore .localstore
.get_mint_quote(&mint_request.quote) .get_mint_quote(&mint_request.quote)
@@ -356,7 +355,7 @@ impl Mint {
self.pubsub_manager self.pubsub_manager
.mint_quote_bolt11_status(mint_quote, MintQuoteState::Issued); .mint_quote_bolt11_status(mint_quote, MintQuoteState::Issued);
Ok(nut04::MintBolt11Response { Ok(MintBolt11Response {
signatures: blind_signatures, signatures: blind_signatures,
}) })
} }

View File

@@ -0,0 +1,3 @@
#[cfg(feature = "auth")]
mod auth;
mod issue_nut04;

View File

@@ -0,0 +1,65 @@
//! Auth keyset functions
use tracing::instrument;
use crate::mint::{CurrencyUnit, Id, KeySetInfo, KeysResponse, KeysetResponse};
use crate::{Error, Mint};
impl Mint {
/// Retrieve the auth public keys of the active keyset for distribution to wallet
/// clients
#[instrument(skip_all)]
pub async fn auth_pubkeys(&self) -> Result<KeysResponse, Error> {
let active_keyset_id = self
.auth_localstore
.as_ref()
.ok_or(Error::AuthLocalstoreUndefined)?
.get_active_keyset_id()
.await?
.ok_or(Error::AmountKey)?;
self.ensure_blind_auth_keyset_loaded(&active_keyset_id)
.await?;
let keysets = self.keysets.read().await;
Ok(KeysResponse {
keysets: vec![keysets
.get(&active_keyset_id)
.ok_or(Error::KeysetUnknown(active_keyset_id))?
.clone()
.into()],
})
}
/// Return a list of auth keysets
#[instrument(skip_all)]
pub async fn auth_keysets(&self) -> Result<KeysetResponse, Error> {
let keysets = self
.auth_localstore
.clone()
.ok_or(Error::AuthLocalstoreUndefined)?
.get_keyset_infos()
.await?;
let active_keysets: Id = self
.auth_localstore
.as_ref()
.ok_or(Error::AuthLocalstoreUndefined)?
.get_active_keyset_id()
.await?
.ok_or(Error::NoActiveKeyset)?;
let keysets = keysets
.into_iter()
.filter(|k| k.unit == CurrencyUnit::Auth)
.map(|k| KeySetInfo {
id: k.id,
unit: k.unit,
active: active_keysets == k.id,
input_fee_ppk: k.input_fee_ppk,
})
.collect();
Ok(KeysetResponse { keysets })
}
}

View File

@@ -13,6 +13,9 @@ use super::{
}; };
use crate::Error; use crate::Error;
#[cfg(feature = "auth")]
mod auth;
impl Mint { impl Mint {
/// Initialize keysets and returns a [`Result`] with a tuple of the following: /// Initialize keysets and returns a [`Result`] with a tuple of the following:
/// * a [`HashMap`] mapping each active keyset `Id` to `MintKeySet` /// * a [`HashMap`] mapping each active keyset `Id` to `MintKeySet`
@@ -138,7 +141,10 @@ impl Mint {
/// clients /// clients
#[instrument(skip_all)] #[instrument(skip_all)]
pub async fn pubkeys(&self) -> Result<KeysResponse, Error> { pub async fn pubkeys(&self) -> Result<KeysResponse, Error> {
let active_keysets = self.localstore.get_active_keysets().await?; let mut active_keysets = self.localstore.get_active_keysets().await?;
// We don't want to return auth keys here even though in the db we treat them the same
active_keysets.remove(&CurrencyUnit::Auth);
let active_keysets: HashSet<&Id> = active_keysets.values().collect(); let active_keysets: HashSet<&Id> = active_keysets.values().collect();
@@ -174,6 +180,7 @@ impl Mint {
let keysets = keysets let keysets = keysets
.into_iter() .into_iter()
.filter(|k| k.unit != CurrencyUnit::Auth)
.map(|k| KeySetInfo { .map(|k| KeySetInfo {
id: k.id, id: k.id,
unit: k.unit, unit: k.unit,

View File

@@ -6,9 +6,13 @@ use std::sync::Arc;
use bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv}; use bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv};
use bitcoin::secp256k1::{self, Secp256k1}; use bitcoin::secp256k1::{self, Secp256k1};
use cdk_common::common::{PaymentProcessorKey, QuoteTTL}; use cdk_common::common::{PaymentProcessorKey, QuoteTTL};
#[cfg(feature = "auth")]
use cdk_common::database::MintAuthDatabase;
use cdk_common::database::{self, MintDatabase}; use cdk_common::database::{self, MintDatabase};
use cdk_common::mint::MintKeySetInfo; use cdk_common::mint::MintKeySetInfo;
use futures::StreamExt; use futures::StreamExt;
#[cfg(feature = "auth")]
use nut21::ProtectedEndpoint;
use subscription::PubSubManager; use subscription::PubSubManager;
use tokio::sync::{Notify, RwLock}; use tokio::sync::{Notify, RwLock};
use tokio::task::JoinSet; use tokio::task::JoinSet;
@@ -21,14 +25,18 @@ use crate::error::Error;
use crate::fees::calculate_fee; use crate::fees::calculate_fee;
use crate::nuts::*; use crate::nuts::*;
use crate::util::unix_time; use crate::util::unix_time;
#[cfg(feature = "auth")]
use crate::OidcClient;
use crate::{ensure_cdk, Amount}; use crate::{ensure_cdk, Amount};
#[cfg(feature = "auth")]
pub(crate) mod auth;
mod builder; mod builder;
mod check_spendable; mod check_spendable;
mod issue;
mod keysets; mod keysets;
mod ln; mod ln;
mod melt; mod melt;
mod mint_nut04;
mod start_up_check; mod start_up_check;
pub mod subscription; pub mod subscription;
mod swap; mod swap;
@@ -36,17 +44,23 @@ mod verification;
pub use builder::{MintBuilder, MintMeltLimits}; pub use builder::{MintBuilder, MintMeltLimits};
pub use cdk_common::mint::{MeltQuote, MintQuote}; pub use cdk_common::mint::{MeltQuote, MintQuote};
pub use verification::Verification;
/// Cashu Mint /// Cashu Mint
#[derive(Clone)] #[derive(Clone)]
pub struct Mint { pub struct Mint {
/// Mint Storage backend /// Mint Storage backend
pub localstore: Arc<dyn MintDatabase<Err = database::Error> + Send + Sync>, pub localstore: Arc<dyn MintDatabase<Err = database::Error> + Send + Sync>,
/// Auth Storage backend (only available with auth feature)
#[cfg(feature = "auth")]
pub auth_localstore: Option<Arc<dyn MintAuthDatabase<Err = database::Error> + Send + Sync>>,
/// Ln backends for mint /// Ln backends for mint
pub ln: pub ln:
HashMap<PaymentProcessorKey, Arc<dyn MintPayment<Err = cdk_payment::Error> + Send + Sync>>, HashMap<PaymentProcessorKey, Arc<dyn MintPayment<Err = cdk_payment::Error> + Send + Sync>>,
/// Subscription manager /// Subscription manager
pub pubsub_manager: Arc<PubSubManager>, pub pubsub_manager: Arc<PubSubManager>,
#[cfg(feature = "auth")]
oidc_client: Option<OidcClient>,
secp_ctx: Secp256k1<secp256k1::All>, secp_ctx: Secp256k1<secp256k1::All>,
xpriv: Xpriv, xpriv: Xpriv,
keysets: Arc<RwLock<HashMap<Id, MintKeySet>>>, keysets: Arc<RwLock<HashMap<Id, MintKeySet>>>,
@@ -54,8 +68,7 @@ pub struct Mint {
} }
impl Mint { impl Mint {
/// Create new [`Mint`] /// Create new [`Mint`] without authentication
#[allow(clippy::too_many_arguments)]
pub async fn new( pub async fn new(
seed: &[u8], seed: &[u8],
localstore: Arc<dyn MintDatabase<Err = database::Error> + Send + Sync>, localstore: Arc<dyn MintDatabase<Err = database::Error> + Send + Sync>,
@@ -63,9 +76,63 @@ impl Mint {
PaymentProcessorKey, PaymentProcessorKey,
Arc<dyn MintPayment<Err = cdk_payment::Error> + Send + Sync>, Arc<dyn MintPayment<Err = cdk_payment::Error> + Send + Sync>,
>, >,
// Hashmap where the key is the unit and value is (input fee ppk, max_order)
supported_units: HashMap<CurrencyUnit, (u64, u8)>, supported_units: HashMap<CurrencyUnit, (u64, u8)>,
custom_paths: HashMap<CurrencyUnit, DerivationPath>, custom_paths: HashMap<CurrencyUnit, DerivationPath>,
) -> Result<Self, Error> {
Self::new_internal(
seed,
localstore,
#[cfg(feature = "auth")]
None,
ln,
supported_units,
custom_paths,
#[cfg(feature = "auth")]
None,
)
.await
}
/// Create new [`Mint`] with authentication support
#[cfg(feature = "auth")]
pub async fn new_with_auth(
seed: &[u8],
localstore: Arc<dyn MintDatabase<Err = database::Error> + Send + Sync>,
auth_localstore: Arc<dyn MintAuthDatabase<Err = database::Error> + Send + Sync>,
ln: HashMap<
PaymentProcessorKey,
Arc<dyn MintPayment<Err = cdk_payment::Error> + Send + Sync>,
>,
supported_units: HashMap<CurrencyUnit, (u64, u8)>,
custom_paths: HashMap<CurrencyUnit, DerivationPath>,
open_id_discovery: String,
) -> Result<Self, Error> {
Self::new_internal(
seed,
localstore,
Some(auth_localstore),
ln,
supported_units,
custom_paths,
Some(open_id_discovery),
)
.await
}
/// Internal function to create a new [`Mint`] with shared logic
async fn new_internal(
seed: &[u8],
localstore: Arc<dyn MintDatabase<Err = database::Error> + Send + Sync>,
#[cfg(feature = "auth")] auth_localstore: Option<
Arc<dyn database::MintAuthDatabase<Err = database::Error> + Send + Sync>,
>,
ln: HashMap<
PaymentProcessorKey,
Arc<dyn MintPayment<Err = cdk_payment::Error> + Send + Sync>,
>,
supported_units: HashMap<CurrencyUnit, (u64, u8)>,
custom_paths: HashMap<CurrencyUnit, DerivationPath>,
#[cfg(feature = "auth")] open_id_discovery: Option<String>,
) -> Result<Self, Error> { ) -> Result<Self, Error> {
let secp_ctx = Secp256k1::new(); let secp_ctx = Secp256k1::new();
let xpriv = Xpriv::new_master(bitcoin::Network::Bitcoin, seed).expect("RNG busted"); let xpriv = Xpriv::new_master(bitcoin::Network::Bitcoin, seed).expect("RNG busted");
@@ -106,6 +173,49 @@ impl Mint {
} }
} }
#[cfg(feature = "auth")]
let oidc_client = if let Some(openid_discovery) = open_id_discovery {
{
tracing::info!("Auth enabled creating auth keysets");
let auth_localstore = auth_localstore
.as_ref()
.ok_or(Error::AuthSettingsUndefined)?;
let derivation_path = match custom_paths.get(&CurrencyUnit::Auth) {
Some(path) => path.clone(),
None => derivation_path_from_unit(CurrencyUnit::Auth, 0)
.ok_or(Error::UnsupportedUnit)?,
};
let (keyset, keyset_info) = create_new_keyset(
&secp_ctx,
xpriv,
derivation_path,
Some(0),
CurrencyUnit::Auth,
1,
0,
);
let id = keyset_info.id;
auth_localstore.add_keyset_info(keyset_info).await?;
auth_localstore.set_active_keyset(id).await?;
active_keysets.insert(id, keyset);
Some(OidcClient::new(openid_discovery.clone()))
}
#[cfg(not(feature = "auth"))]
{
tracing::error!("CDK must be compiled with auth feature to be used with auth.");
return Err(Error::Custom(
"Openid passed but cdk compiled without auth.".to_string(),
));
}
} else {
None
};
let keysets = Arc::new(RwLock::new(active_keysets)); let keysets = Arc::new(RwLock::new(active_keysets));
Ok(Self { Ok(Self {
@@ -113,16 +223,56 @@ impl Mint {
secp_ctx, secp_ctx,
xpriv, xpriv,
localstore, localstore,
#[cfg(feature = "auth")]
oidc_client,
ln, ln,
keysets,
custom_paths, custom_paths,
#[cfg(feature = "auth")]
auth_localstore,
keysets,
}) })
} }
/// Get mint info /// Get mint info
#[instrument(skip_all)] #[instrument(skip_all)]
pub async fn mint_info(&self) -> Result<MintInfo, Error> { pub async fn mint_info(&self) -> Result<MintInfo, Error> {
Ok(self.localstore.get_mint_info().await?) let mint_info = self.localstore.get_mint_info().await?;
#[cfg(feature = "auth")]
let mint_info = if let Some(auth_db) = self.auth_localstore.as_ref() {
let mut mint_info = mint_info;
let auth_endpoints = auth_db.get_auth_for_endpoints().await?;
let mut clear_auth_endpoints: Vec<ProtectedEndpoint> = vec![];
let mut blind_auth_endpoints: Vec<ProtectedEndpoint> = vec![];
for (endpoint, auth) in auth_endpoints {
match auth {
Some(AuthRequired::Clear) => {
clear_auth_endpoints.push(endpoint);
}
Some(AuthRequired::Blind) => {
blind_auth_endpoints.push(endpoint);
}
None => (),
}
}
mint_info.nuts.nut21 = mint_info.nuts.nut21.map(|mut a| {
a.protected_endpoints = clear_auth_endpoints;
a
});
mint_info.nuts.nut22 = mint_info.nuts.nut22.map(|mut a| {
a.protected_endpoints = blind_auth_endpoints;
a
});
mint_info
} else {
mint_info
};
Ok(mint_info)
} }
/// Set mint info /// Set mint info

View File

@@ -5,9 +5,12 @@ use tracing::instrument;
use super::{Error, Mint}; use super::{Error, Mint};
/// Verification result
#[derive(Debug, Clone, Hash, PartialEq, Eq)] #[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct Verification { pub struct Verification {
/// Value in request
pub amount: Amount, pub amount: Amount,
/// Unit of request
pub unit: Option<CurrencyUnit>, pub unit: Option<CurrencyUnit>,
} }

View File

@@ -0,0 +1,240 @@
//! Open Id Connect
use std::collections::HashMap;
use std::ops::Deref;
use std::sync::Arc;
use jsonwebtoken::jwk::{AlgorithmParameters, JwkSet};
use jsonwebtoken::{decode, decode_header, DecodingKey, Validation};
use reqwest::Client;
use serde::Deserialize;
#[cfg(feature = "wallet")]
use serde::Serialize;
use thiserror::Error;
use tokio::sync::RwLock;
use tracing::instrument;
/// OIDC Error
#[derive(Debug, Error)]
pub enum Error {
/// From Reqwest error
#[error(transparent)]
Reqwest(#[from] reqwest::Error),
/// From Reqwest error
#[error(transparent)]
Jwt(#[from] jsonwebtoken::errors::Error),
/// Missing kid header
#[error("Missing kid header")]
MissingKidHeader,
/// Missing jwk header
#[error("Missing jwk")]
MissingJwkHeader,
/// Unsupported Algo
#[error("Unsupported signing algo")]
UnsupportedSigningAlgo,
/// Access token not returned
#[error("Error getting access token")]
AccessTokenMissing,
}
impl From<Error> for cdk_common::error::Error {
fn from(value: Error) -> Self {
tracing::debug!("Clear auth verification failed: {}", value);
cdk_common::error::Error::ClearAuthFailed
}
}
/// Open Id Config
#[derive(Debug, Clone, Deserialize)]
pub struct OidcConfig {
pub jwks_uri: String,
pub issuer: String,
pub token_endpoint: String,
pub device_authorization_endpoint: String,
}
/// Http Client
#[derive(Debug, Clone)]
pub struct OidcClient {
client: Client,
openid_discovery: String,
oidc_config: Arc<RwLock<Option<OidcConfig>>>,
jwks_set: Arc<RwLock<Option<JwkSet>>>,
}
#[cfg(feature = "wallet")]
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GrantType {
RefreshToken,
}
#[cfg(feature = "wallet")]
#[derive(Debug, Clone, Serialize)]
pub struct AccessTokenRequest {
pub grant_type: GrantType,
pub client_id: String,
pub username: String,
pub password: String,
}
#[cfg(feature = "wallet")]
#[derive(Debug, Clone, Serialize)]
pub struct RefreshTokenRequest {
pub grant_type: GrantType,
pub client_id: String,
pub refresh_token: String,
}
#[cfg(feature = "wallet")]
#[derive(Debug, Clone, Deserialize)]
pub struct TokenResponse {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_in: Option<i64>,
pub token_type: String,
}
impl OidcClient {
/// Create new [`OidcClient`]
pub fn new(openid_discovery: String) -> Self {
Self {
client: Client::new(),
openid_discovery,
oidc_config: Arc::new(RwLock::new(None)),
jwks_set: Arc::new(RwLock::new(None)),
}
}
/// Get config from oidc server
#[instrument(skip(self))]
pub async fn get_oidc_config(&self) -> Result<OidcConfig, Error> {
tracing::debug!("Getting oidc config");
let oidc_config = self
.client
.get(&self.openid_discovery)
.send()
.await?
.json::<OidcConfig>()
.await?;
let mut current_config = self.oidc_config.write().await;
*current_config = Some(oidc_config.clone());
Ok(oidc_config)
}
/// Get jwk set
#[instrument(skip(self))]
pub async fn get_jwkset(&self, jwks_uri: &str) -> Result<JwkSet, Error> {
tracing::debug!("Getting jwks set");
let jwks_set = self
.client
.get(jwks_uri)
.send()
.await?
.json::<JwkSet>()
.await?;
let mut current_set = self.jwks_set.write().await;
*current_set = Some(jwks_set.clone());
Ok(jwks_set)
}
/// Verify cat token
#[instrument(skip_all)]
pub async fn verify_cat(&self, cat_jwt: &str) -> Result<(), Error> {
tracing::debug!("Verifying cat");
let header = decode_header(cat_jwt)?;
let kid = header.kid.ok_or(Error::MissingKidHeader)?;
let oidc_config = {
let locked = self.oidc_config.read().await;
match locked.deref() {
Some(config) => config.clone(),
None => {
drop(locked);
self.get_oidc_config().await?
}
}
};
let jwks = {
let locked = self.jwks_set.read().await;
match locked.deref() {
Some(set) => set.clone(),
None => {
drop(locked);
self.get_jwkset(&oidc_config.jwks_uri).await?
}
}
};
let jwk = match jwks.find(&kid) {
Some(jwk) => jwk.clone(),
None => {
let refreshed_jwks = self.get_jwkset(&oidc_config.jwks_uri).await?;
refreshed_jwks
.find(&kid)
.ok_or(Error::MissingKidHeader)?
.clone()
}
};
let decoding_key = match &jwk.algorithm {
AlgorithmParameters::RSA(rsa) => DecodingKey::from_rsa_components(&rsa.n, &rsa.e)?,
AlgorithmParameters::EllipticCurve(ecdsa) => {
DecodingKey::from_ec_components(&ecdsa.x, &ecdsa.y)?
}
_ => return Err(Error::UnsupportedSigningAlgo),
};
let validation = {
let mut validation = Validation::new(header.alg);
validation.validate_exp = true;
validation.validate_aud = false;
validation.set_issuer(&[oidc_config.issuer]);
validation
};
if let Err(err) =
decode::<HashMap<String, serde_json::Value>>(cat_jwt, &decoding_key, &validation)
{
tracing::debug!("Could not verify cat: {}", err);
return Err(err.into());
}
Ok(())
}
/// Get new access token using refresh token
#[cfg(feature = "wallet")]
pub async fn refresh_access_token(
&self,
client_id: String,
refresh_token: String,
) -> Result<TokenResponse, Error> {
let token_url = self.get_oidc_config().await?.token_endpoint;
let request = RefreshTokenRequest {
grant_type: GrantType::RefreshToken,
client_id,
refresh_token,
};
let response = self
.client
.post(token_url)
.form(&request)
.send()
.await?
.json::<TokenResponse>()
.await?;
Ok(response)
}
}

View File

@@ -0,0 +1,30 @@
use std::fmt::Debug;
use async_trait::async_trait;
use cdk_common::{AuthToken, MintInfo};
use super::Error;
use crate::nuts::{Id, KeySet, KeysetResponse, MintAuthRequest, MintBolt11Response};
/// Interface that connects a wallet to a mint. Typically represents an [HttpClient].
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
pub trait AuthMintConnector: Debug {
/// Get the current auth token
async fn get_auth_token(&self) -> Result<AuthToken, Error>;
/// Set a new auth token
async fn set_auth_token(&self, token: AuthToken) -> Result<(), Error>;
/// Get Mint Info [NUT-06]
async fn get_mint_info(&self) -> Result<MintInfo, Error>;
/// Get Blind Auth Keyset
async fn get_mint_blind_auth_keyset(&self, keyset_id: Id) -> Result<KeySet, Error>;
/// Get Blind Auth keysets
async fn get_mint_blind_auth_keysets(&self) -> Result<KeysetResponse, Error>;
/// Post mint blind auth
async fn post_mint_blind_auth(
&self,
request: MintAuthRequest,
) -> Result<MintBolt11Response, Error>;
}

View File

@@ -0,0 +1,427 @@
use std::collections::HashMap;
use std::sync::Arc;
use cdk_common::database::{self, WalletDatabase};
use cdk_common::mint_url::MintUrl;
use cdk_common::{AuthProof, Id, Keys, MintInfo};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use tracing::instrument;
use super::AuthMintConnector;
use crate::amount::SplitTarget;
use crate::dhke::construct_proofs;
use crate::nuts::nut22::MintAuthRequest;
use crate::nuts::{
nut12, AuthRequired, AuthToken, BlindAuthToken, CurrencyUnit, KeySetInfo, PreMintSecrets,
Proofs, ProtectedEndpoint, State,
};
use crate::types::ProofInfo;
use crate::wallet::mint_connector::AuthHttpClient;
use crate::{Amount, Error, OidcClient};
/// JWT Claims structure for decoding tokens
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
/// Subject
sub: Option<String>,
/// Expiration time (as UTC timestamp)
exp: Option<u64>,
/// Issued at (as UTC timestamp)
iat: Option<u64>,
}
/// CDK Auth Wallet
///
/// A [`AuthWallet`] is for auth operations with a single mint.
#[derive(Debug, Clone)]
pub struct AuthWallet {
/// Mint Url
pub mint_url: MintUrl,
/// Storage backend
pub localstore: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
/// Protected methods
pub protected_endpoints: Arc<RwLock<HashMap<ProtectedEndpoint, AuthRequired>>>,
/// Refresh token for auth
refresh_token: Arc<RwLock<Option<String>>>,
client: Arc<dyn AuthMintConnector + Send + Sync>,
/// OIDC client for authentication
oidc_client: Arc<RwLock<Option<OidcClient>>>,
}
impl AuthWallet {
/// Create a new [`AuthWallet`] instance
pub fn new(
mint_url: MintUrl,
cat: Option<AuthToken>,
localstore: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
protected_endpoints: HashMap<ProtectedEndpoint, AuthRequired>,
oidc_client: Option<OidcClient>,
) -> Self {
let http_client = Arc::new(AuthHttpClient::new(mint_url.clone(), cat));
Self {
mint_url,
localstore,
protected_endpoints: Arc::new(RwLock::new(protected_endpoints)),
refresh_token: Arc::new(RwLock::new(None)),
client: http_client,
oidc_client: Arc::new(RwLock::new(oidc_client)),
}
}
/// Get the current auth token
#[instrument(skip(self))]
pub async fn get_auth_token(&self) -> Result<AuthToken, Error> {
self.client.get_auth_token().await
}
/// Set a new auth token
#[instrument(skip_all)]
pub async fn verify_cat(&self, token: AuthToken) -> Result<(), Error> {
match &token {
AuthToken::ClearAuth(clear_token) => {
if let Some(oidc) = self.oidc_client.read().await.as_ref() {
oidc.verify_cat(clear_token).await?;
}
Ok(())
}
AuthToken::BlindAuth(_) => Err(Error::Custom(
"Cannot set blind auth token directly".to_string(),
)),
}
}
/// Set a new auth token
#[instrument(skip_all)]
pub async fn set_auth_token(&self, token: AuthToken) -> Result<(), Error> {
match &token {
AuthToken::ClearAuth(clear_token) => {
if let Some(oidc) = self.oidc_client.read().await.as_ref() {
oidc.verify_cat(clear_token).await?;
}
self.client.set_auth_token(token).await
}
AuthToken::BlindAuth(_) => Err(Error::Custom(
"Cannot set blind auth token directly".to_string(),
)),
}
}
/// Get the current refresh token if one exists
#[instrument(skip(self))]
pub async fn get_refresh_token(&self) -> Option<String> {
self.refresh_token.read().await.clone()
}
/// Set a new refresh token
#[instrument(skip(self))]
pub async fn set_refresh_token(&self, token: Option<String>) {
*self.refresh_token.write().await = token;
}
/// Get the OIDC client if one exists
#[instrument(skip(self))]
pub async fn get_oidc_client(&self) -> Option<OidcClient> {
self.oidc_client.read().await.clone()
}
/// Set a new OIDC client
#[instrument(skip(self))]
pub async fn set_oidc_client(&self, client: Option<OidcClient>) {
*self.oidc_client.write().await = client;
}
/// Refresh the access token using the stored refresh token
#[instrument(skip(self))]
pub async fn refresh_access_token(&self) -> Result<(), Error> {
if let Some(oidc) = self.oidc_client.read().await.as_ref() {
if let Some(refresh_token) = self.get_refresh_token().await {
let mint_info = self
.get_mint_info()
.await?
.ok_or(Error::CouldNotGetMintInfo)?;
let token_response = oidc
.refresh_access_token(
mint_info.client_id().ok_or(Error::CouldNotGetMintInfo)?,
refresh_token,
)
.await?;
// Store new refresh token if provided
self.set_refresh_token(token_response.refresh_token).await;
// Set new access token
self.set_auth_token(AuthToken::ClearAuth(token_response.access_token))
.await?;
return Ok(());
}
}
Err(Error::Custom(
"No refresh token or OIDC client available".to_string(),
))
}
/// Query mint for current mint information
#[instrument(skip(self))]
pub async fn get_mint_info(&self) -> Result<Option<MintInfo>, Error> {
self.client.get_mint_info().await.map(Some).or(Ok(None))
}
/// Get keys for mint keyset
///
/// Selected keys from localstore if they are already known
/// If they are not known queries mint for keyset id and stores the [`Keys`]
#[instrument(skip(self))]
pub async fn get_keyset_keys(&self, keyset_id: Id) -> Result<Keys, Error> {
let keys = if let Some(keys) = self.localstore.get_keys(&keyset_id).await? {
keys
} else {
let keys = self.client.get_mint_blind_auth_keyset(keyset_id).await?;
keys.verify_id()?;
self.localstore.add_keys(keys.keys.clone()).await?;
keys.keys
};
Ok(keys)
}
/// Get active keyset for mint
///
/// Queries mint for current keysets then gets [`Keys`] for any unknown
/// keysets
#[instrument(skip(self))]
pub async fn get_active_mint_blind_auth_keysets(&self) -> Result<Vec<KeySetInfo>, Error> {
let keysets = self.client.get_mint_blind_auth_keysets().await?;
let keysets = keysets.keysets;
self.localstore
.add_mint_keysets(self.mint_url.clone(), keysets.clone())
.await?;
let active_keysets = keysets
.clone()
.into_iter()
.filter(|k| k.unit == CurrencyUnit::Auth)
.collect::<Vec<KeySetInfo>>();
match self
.localstore
.get_mint_keysets(self.mint_url.clone())
.await?
{
Some(known_keysets) => {
let unknown_keysets: Vec<&KeySetInfo> = keysets
.iter()
.filter(|k| known_keysets.contains(k))
.collect();
for keyset in unknown_keysets {
self.get_keyset_keys(keyset.id).await?;
}
}
None => {
for keyset in keysets {
self.get_keyset_keys(keyset.id).await?;
}
}
}
Ok(active_keysets)
}
/// Get active keyset for mint
///
/// Queries mint for current keysets then gets [`Keys`] for any unknown
/// keysets
#[instrument(skip(self))]
pub async fn get_active_mint_blind_auth_keyset(&self) -> Result<KeySetInfo, Error> {
let active_keysets = self.get_active_mint_blind_auth_keysets().await?;
let keyset = active_keysets.first().ok_or(Error::NoActiveKeyset)?;
Ok(keyset.clone())
}
/// Get unspent proofs for mint
#[instrument(skip(self))]
pub async fn get_unspent_auth_proofs(&self) -> Result<Vec<AuthProof>, Error> {
Ok(self
.localstore
.get_proofs(
Some(self.mint_url.clone()),
Some(CurrencyUnit::Auth),
Some(vec![State::Unspent]),
None,
)
.await?
.into_iter()
.map(|p| p.proof.try_into())
.collect::<Result<Vec<AuthProof>, _>>()?)
}
/// Check if and what kind of auth is required for a method
#[instrument(skip(self))]
pub async fn is_protected(&self, method: &ProtectedEndpoint) -> Option<AuthRequired> {
let protected_endpoints = self.protected_endpoints.read().await;
protected_endpoints.get(method).copied()
}
/// Get Auth Token
#[instrument(skip(self))]
pub async fn get_blind_auth_token(&self) -> Result<Option<AuthToken>, Error> {
let unspent = self.get_unspent_auth_proofs().await?;
let auth_proof = match unspent.first() {
Some(proof) => {
self.localstore
.update_proofs(vec![], vec![proof.y()?])
.await?;
proof
}
None => return Ok(None),
};
Ok(Some(AuthToken::BlindAuth(BlindAuthToken {
auth_proof: auth_proof.clone(),
})))
}
/// Auth for request
#[instrument(skip(self))]
pub async fn get_auth_for_request(
&self,
method: &ProtectedEndpoint,
) -> Result<Option<AuthToken>, Error> {
match self.is_protected(method).await {
Some(auth) => match auth {
AuthRequired::Clear => self.client.get_auth_token().await.map(Some),
AuthRequired::Blind => {
let proof = self
.get_blind_auth_token()
.await?
.ok_or(Error::InsufficientBlindAuthTokens)?;
Ok(Some(proof))
}
},
None => Ok(None),
}
}
/// Mint blind auth
#[instrument(skip(self))]
pub async fn mint_blind_auth(&self, amount: Amount) -> Result<Proofs, Error> {
tracing::debug!("Minting {} blind auth proofs", amount);
// Check that mint is in store of mints
if self
.localstore
.get_mint(self.mint_url.clone())
.await?
.is_none()
{
self.get_mint_info().await?;
}
let auth_token = self.client.get_auth_token().await?;
let active_keyset_id = self.get_active_mint_blind_auth_keysets().await?;
tracing::debug!("Active ketset: {:?}", active_keyset_id);
match &auth_token {
AuthToken::ClearAuth(cat) => {
if cat.is_empty() {
tracing::warn!("Auth Cat is not set");
return Err(Error::ClearAuthRequired);
}
if let Err(err) = self.verify_cat(auth_token).await {
tracing::warn!("Current cat is invalid {}", err);
}
let has_refresh;
{
has_refresh = self.refresh_token.read().await.is_some();
}
if has_refresh {
tracing::info!("Attempting to refresh using refresh token");
self.refresh_access_token().await?;
} else {
tracing::warn!(
"Wallet cat is invalid and there is no refresh token please reauth"
);
}
}
AuthToken::BlindAuth(_) => {
tracing::error!("Blind auth set as client cat");
return Err(Error::ClearAuthFailed);
}
}
let active_keyset_id = self.get_active_mint_blind_auth_keyset().await?.id;
let premint_secrets =
PreMintSecrets::random(active_keyset_id, amount, &SplitTarget::Value(1.into()))?;
let request = MintAuthRequest {
outputs: premint_secrets.blinded_messages(),
};
let mint_res = self.client.post_mint_blind_auth(request).await?;
let keys = self.get_keyset_keys(active_keyset_id).await?;
// Verify the signature DLEQ is valid
{
assert!(mint_res.signatures.len() == premint_secrets.secrets.len());
for (sig, premint) in mint_res.signatures.iter().zip(&premint_secrets.secrets) {
let keys = self.get_keyset_keys(sig.keyset_id).await?;
let key = keys.amount_key(sig.amount).ok_or(Error::AmountKey)?;
match sig.verify_dleq(key, premint.blinded_message.blinded_secret) {
Ok(_) => (),
Err(nut12::Error::MissingDleqProof) => {
tracing::warn!("Signature for bat returned without dleq proof.");
return Err(Error::DleqProofNotProvided);
}
Err(_) => return Err(Error::CouldNotVerifyDleq),
}
}
}
let proofs = construct_proofs(
mint_res.signatures,
premint_secrets.rs(),
premint_secrets.secrets(),
&keys,
)?;
let proof_infos = proofs
.clone()
.into_iter()
.map(|proof| {
ProofInfo::new(
proof,
self.mint_url.clone(),
State::Unspent,
crate::nuts::CurrencyUnit::Auth,
)
})
.collect::<Result<Vec<ProofInfo>, _>>()?;
// Add new proofs to store
self.localstore.update_proofs(proof_infos, vec![]).await?;
Ok(proofs)
}
/// Total unspent balance of wallet
#[instrument(skip(self))]
pub async fn total_blind_auth_balance(&self) -> Result<Amount, Error> {
Ok(Amount::from(
self.get_unspent_auth_proofs().await?.len() as u64
))
}
}

View File

@@ -0,0 +1,68 @@
mod auth_connector;
mod auth_wallet;
pub use auth_connector::AuthMintConnector;
pub use auth_wallet::AuthWallet;
use cdk_common::{Amount, AuthProof, AuthToken, Proofs};
use tracing::instrument;
use super::Wallet;
use crate::error::Error;
impl Wallet {
/// Mint blind auth tokens
#[instrument(skip_all)]
pub async fn mint_blind_auth(&self, amount: Amount) -> Result<Proofs, Error> {
self.auth_wallet
.read()
.await
.as_ref()
.ok_or(Error::AuthSettingsUndefined)?
.mint_blind_auth(amount)
.await
}
/// Get unspent auth proofs
#[instrument(skip_all)]
pub async fn get_unspent_auth_proofs(&self) -> Result<Vec<AuthProof>, Error> {
self.auth_wallet
.read()
.await
.as_ref()
.ok_or(Error::AuthSettingsUndefined)?
.get_unspent_auth_proofs()
.await
}
/// Set Clear Auth Token (CAT) for authentication
#[instrument(skip_all)]
pub async fn set_cat(&self, cat: String) -> Result<(), Error> {
let auth_wallet = self.auth_wallet.read().await;
if let Some(auth_wallet) = auth_wallet.as_ref() {
auth_wallet
.set_auth_token(AuthToken::ClearAuth(cat))
.await?;
}
Ok(())
}
/// Set refresh for authentication
#[instrument(skip_all)]
pub async fn set_refresh_token(&self, refresh_token: String) -> Result<(), Error> {
let auth_wallet = self.auth_wallet.read().await;
if let Some(auth_wallet) = auth_wallet.as_ref() {
auth_wallet.set_refresh_token(Some(refresh_token)).await;
}
Ok(())
}
/// Refresh CAT token
#[instrument(skip(self))]
pub async fn refresh_access_token(&self) -> Result<(), Error> {
let auth_wallet = self.auth_wallet.read().await;
if let Some(auth_wallet) = auth_wallet.as_ref() {
auth_wallet.refresh_access_token().await?;
}
Ok(())
}
}

View File

@@ -0,0 +1,161 @@
#[cfg(feature = "auth")]
use std::collections::HashMap;
use std::sync::Arc;
use bitcoin::bip32::Xpriv;
use bitcoin::Network;
use cdk_common::database;
#[cfg(feature = "auth")]
use cdk_common::AuthToken;
#[cfg(feature = "auth")]
use tokio::sync::RwLock;
use crate::cdk_database::WalletDatabase;
use crate::error::Error;
use crate::mint_url::MintUrl;
use crate::nuts::CurrencyUnit;
#[cfg(feature = "auth")]
use crate::wallet::auth::AuthWallet;
use crate::wallet::{HttpClient, MintConnector, SubscriptionManager, Wallet};
/// Builder for creating a new [`Wallet`]
#[derive(Debug)]
pub struct WalletBuilder {
mint_url: Option<MintUrl>,
unit: Option<CurrencyUnit>,
localstore: Option<Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>>,
target_proof_count: Option<usize>,
#[cfg(feature = "auth")]
auth_wallet: Option<AuthWallet>,
seed: Option<Vec<u8>>,
client: Option<Arc<dyn MintConnector + Send + Sync>>,
}
impl Default for WalletBuilder {
fn default() -> Self {
Self {
mint_url: None,
unit: None,
localstore: None,
target_proof_count: Some(3),
#[cfg(feature = "auth")]
auth_wallet: None,
seed: None,
client: None,
}
}
}
impl WalletBuilder {
/// Create a new WalletBuilder
pub fn new() -> Self {
Self::default()
}
/// Set the mint URL
pub fn mint_url(mut self, mint_url: MintUrl) -> Self {
self.mint_url = Some(mint_url);
self
}
/// Set the currency unit
pub fn unit(mut self, unit: CurrencyUnit) -> Self {
self.unit = Some(unit);
self
}
/// Set the local storage backend
pub fn localstore(
mut self,
localstore: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
) -> Self {
self.localstore = Some(localstore);
self
}
/// Set the target proof count
pub fn target_proof_count(mut self, count: usize) -> Self {
self.target_proof_count = Some(count);
self
}
/// Set the auth wallet
#[cfg(feature = "auth")]
pub fn auth_wallet(mut self, auth_wallet: AuthWallet) -> Self {
self.auth_wallet = Some(auth_wallet);
self
}
/// Set the seed bytes
pub fn seed(mut self, seed: &[u8]) -> Self {
self.seed = Some(seed.to_vec());
self
}
/// Set a custom client connector
pub fn client<C: MintConnector + 'static + Send + Sync>(mut self, client: C) -> Self {
self.client = Some(Arc::new(client));
self
}
/// Set auth CAT (Clear Auth Token)
#[cfg(feature = "auth")]
pub fn set_auth_cat(mut self, cat: String) -> Self {
self.auth_wallet = Some(AuthWallet::new(
self.mint_url.clone().expect("Mint URL required"),
Some(AuthToken::ClearAuth(cat)),
self.localstore.clone().expect("Localstore required"),
HashMap::new(),
None,
));
self
}
/// Build the wallet
pub fn build(self) -> Result<Wallet, Error> {
let mint_url = self
.mint_url
.ok_or(Error::Custom("Mint url required".to_string()))?;
let unit = self
.unit
.ok_or(Error::Custom("Unit required".to_string()))?;
let localstore = self
.localstore
.ok_or(Error::Custom("Localstore required".to_string()))?;
let seed = self
.seed
.as_ref()
.ok_or(Error::Custom("Seed required".to_string()))?;
let xpriv = Xpriv::new_master(Network::Bitcoin, seed)?;
let client = match self.client {
Some(client) => client,
None => {
#[cfg(feature = "auth")]
{
Arc::new(HttpClient::new(mint_url.clone(), self.auth_wallet.clone()))
as Arc<dyn MintConnector + Send + Sync>
}
#[cfg(not(feature = "auth"))]
{
Arc::new(HttpClient::new(mint_url.clone()))
as Arc<dyn MintConnector + Send + Sync>
}
}
};
Ok(Wallet {
mint_url,
unit,
localstore,
target_proof_count: self.target_proof_count.unwrap_or(3),
#[cfg(feature = "auth")]
auth_wallet: Arc::new(RwLock::new(self.auth_wallet)),
xpriv,
client: client.clone(),
subscription: SubscriptionManager::new(client),
})
}
}

View File

@@ -1,308 +0,0 @@
//! Wallet client
use std::fmt::Debug;
use async_trait::async_trait;
use reqwest::{Client, IntoUrl};
use serde::de::DeserializeOwned;
use serde::Serialize;
use tracing::instrument;
#[cfg(not(target_arch = "wasm32"))]
use url::Url;
use super::Error;
use crate::error::ErrorResponse;
use crate::mint_url::MintUrl;
use crate::nuts::{
CheckStateRequest, CheckStateResponse, Id, KeySet, KeysResponse, KeysetResponse,
MeltBolt11Request, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintBolt11Request,
MintBolt11Response, MintInfo, MintQuoteBolt11Request, MintQuoteBolt11Response, RestoreRequest,
RestoreResponse, SwapRequest, SwapResponse,
};
/// Http Client
#[derive(Debug, Clone)]
pub struct HttpClient {
inner: Client,
mint_url: MintUrl,
}
impl HttpClient {
/// Create new [`HttpClient`]
pub fn new(mint_url: MintUrl) -> Self {
Self {
inner: Client::new(),
mint_url,
}
}
#[inline]
async fn http_get<U: IntoUrl, R: DeserializeOwned>(&self, url: U) -> Result<R, Error> {
let response = self
.inner
.get(url)
.send()
.await
.map_err(|e| Error::HttpError(e.to_string()))?
.text()
.await
.map_err(|e| Error::HttpError(e.to_string()))?;
serde_json::from_str::<R>(&response).map_err(|err| {
tracing::warn!("Http Response error: {}", err);
match ErrorResponse::from_json(&response) {
Ok(ok) => <ErrorResponse as Into<Error>>::into(ok),
Err(err) => err.into(),
}
})
}
#[inline]
async fn http_post<U: IntoUrl, P: Serialize + ?Sized, R: DeserializeOwned>(
&self,
url: U,
payload: &P,
) -> Result<R, Error> {
let response = self
.inner
.post(url)
.json(&payload)
.send()
.await
.map_err(|e| Error::HttpError(e.to_string()))?
.text()
.await
.map_err(|e| Error::HttpError(e.to_string()))?;
serde_json::from_str::<R>(&response).map_err(|err| {
tracing::warn!("Http Response error: {}", err);
match ErrorResponse::from_json(&response) {
Ok(ok) => <ErrorResponse as Into<Error>>::into(ok),
Err(err) => err.into(),
}
})
}
#[cfg(not(target_arch = "wasm32"))]
/// Create new [`HttpClient`] with a proxy for specific TLDs.
/// Specifying `None` for `host_matcher` will use the proxy for all
/// requests.
pub fn with_proxy(
mint_url: MintUrl,
proxy: Url,
host_matcher: Option<&str>,
accept_invalid_certs: bool,
) -> Result<Self, Error> {
let regex = host_matcher
.map(regex::Regex::new)
.transpose()
.map_err(|e| Error::Custom(e.to_string()))?;
let client = reqwest::Client::builder()
.proxy(reqwest::Proxy::custom(move |url| {
if let Some(matcher) = regex.as_ref() {
if let Some(host) = url.host_str() {
if matcher.is_match(host) {
return Some(proxy.clone());
}
}
}
None
}))
.danger_accept_invalid_certs(accept_invalid_certs) // Allow self-signed certs
.build()
.map_err(|e| Error::HttpError(e.to_string()))?;
Ok(Self {
inner: client,
mint_url,
})
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl MintConnector for HttpClient {
/// Get Active Mint Keys [NUT-01]
#[instrument(skip(self), fields(mint_url = %self.mint_url))]
async fn get_mint_keys(&self) -> Result<Vec<KeySet>, Error> {
let url = self.mint_url.join_paths(&["v1", "keys"])?;
Ok(self.http_get::<_, KeysResponse>(url).await?.keysets)
}
/// Get Keyset Keys [NUT-01]
#[instrument(skip(self), fields(mint_url = %self.mint_url))]
async fn get_mint_keyset(&self, keyset_id: Id) -> Result<KeySet, Error> {
let url = self
.mint_url
.join_paths(&["v1", "keys", &keyset_id.to_string()])?;
self.http_get::<_, KeysResponse>(url)
.await?
.keysets
.drain(0..1)
.next()
.ok_or_else(|| Error::UnknownKeySet)
}
/// Get Keysets [NUT-02]
#[instrument(skip(self), fields(mint_url = %self.mint_url))]
async fn get_mint_keysets(&self) -> Result<KeysetResponse, Error> {
let url = self.mint_url.join_paths(&["v1", "keysets"])?;
self.http_get(url).await
}
/// Mint Quote [NUT-04]
#[instrument(skip(self), fields(mint_url = %self.mint_url))]
async fn post_mint_quote(
&self,
request: MintQuoteBolt11Request,
) -> Result<MintQuoteBolt11Response<String>, Error> {
let url = self
.mint_url
.join_paths(&["v1", "mint", "quote", "bolt11"])?;
self.http_post(url, &request).await
}
/// Mint Quote status
#[instrument(skip(self), fields(mint_url = %self.mint_url))]
async fn get_mint_quote_status(
&self,
quote_id: &str,
) -> Result<MintQuoteBolt11Response<String>, Error> {
let url = self
.mint_url
.join_paths(&["v1", "mint", "quote", "bolt11", quote_id])?;
self.http_get(url).await
}
/// Mint Tokens [NUT-04]
#[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
async fn post_mint(
&self,
request: MintBolt11Request<String>,
) -> Result<MintBolt11Response, Error> {
let url = self.mint_url.join_paths(&["v1", "mint", "bolt11"])?;
self.http_post(url, &request).await
}
/// Melt Quote [NUT-05]
#[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
async fn post_melt_quote(
&self,
request: MeltQuoteBolt11Request,
) -> Result<MeltQuoteBolt11Response<String>, Error> {
let url = self
.mint_url
.join_paths(&["v1", "melt", "quote", "bolt11"])?;
self.http_post(url, &request).await
}
/// Melt Quote Status
#[instrument(skip(self), fields(mint_url = %self.mint_url))]
async fn get_melt_quote_status(
&self,
quote_id: &str,
) -> Result<MeltQuoteBolt11Response<String>, Error> {
let url = self
.mint_url
.join_paths(&["v1", "melt", "quote", "bolt11", quote_id])?;
self.http_get(url).await
}
/// Melt [NUT-05]
/// [Nut-08] Lightning fee return if outputs defined
#[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
async fn post_melt(
&self,
request: MeltBolt11Request<String>,
) -> Result<MeltQuoteBolt11Response<String>, Error> {
let url = self.mint_url.join_paths(&["v1", "melt", "bolt11"])?;
self.http_post(url, &request).await
}
/// Swap Token [NUT-03]
#[instrument(skip(self, swap_request), fields(mint_url = %self.mint_url))]
async fn post_swap(&self, swap_request: SwapRequest) -> Result<SwapResponse, Error> {
let url = self.mint_url.join_paths(&["v1", "swap"])?;
self.http_post(url, &swap_request).await
}
/// Get Mint Info [NUT-06]
#[instrument(skip(self), fields(mint_url = %self.mint_url))]
async fn get_mint_info(&self) -> Result<MintInfo, Error> {
let url = self.mint_url.join_paths(&["v1", "info"])?;
self.http_get(url).await
}
/// Spendable check [NUT-07]
#[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
async fn post_check_state(
&self,
request: CheckStateRequest,
) -> Result<CheckStateResponse, Error> {
let url = self.mint_url.join_paths(&["v1", "checkstate"])?;
self.http_post(url, &request).await
}
/// Restore request [NUT-13]
#[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
async fn post_restore(&self, request: RestoreRequest) -> Result<RestoreResponse, Error> {
let url = self.mint_url.join_paths(&["v1", "restore"])?;
self.http_post(url, &request).await
}
}
/// Interface that connects a wallet to a mint. Typically represents an [HttpClient].
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
pub trait MintConnector: Debug {
/// Get Active Mint Keys [NUT-01]
async fn get_mint_keys(&self) -> Result<Vec<KeySet>, Error>;
/// Get Keyset Keys [NUT-01]
async fn get_mint_keyset(&self, keyset_id: Id) -> Result<KeySet, Error>;
/// Get Keysets [NUT-02]
async fn get_mint_keysets(&self) -> Result<KeysetResponse, Error>;
/// Mint Quote [NUT-04]
async fn post_mint_quote(
&self,
request: MintQuoteBolt11Request,
) -> Result<MintQuoteBolt11Response<String>, Error>;
/// Mint Quote status
async fn get_mint_quote_status(
&self,
quote_id: &str,
) -> Result<MintQuoteBolt11Response<String>, Error>;
/// Mint Tokens [NUT-04]
async fn post_mint(
&self,
request: MintBolt11Request<String>,
) -> Result<MintBolt11Response, Error>;
/// Melt Quote [NUT-05]
async fn post_melt_quote(
&self,
request: MeltQuoteBolt11Request,
) -> Result<MeltQuoteBolt11Response<String>, Error>;
/// Melt Quote Status
async fn get_melt_quote_status(
&self,
quote_id: &str,
) -> Result<MeltQuoteBolt11Response<String>, Error>;
/// Melt [NUT-05]
/// [Nut-08] Lightning fee return if outputs defined
async fn post_melt(
&self,
request: MeltBolt11Request<String>,
) -> Result<MeltQuoteBolt11Response<String>, Error>;
/// Split Token [NUT-06]
async fn post_swap(&self, request: SwapRequest) -> Result<SwapResponse, Error>;
/// Get Mint Info [NUT-06]
async fn get_mint_info(&self) -> Result<MintInfo, Error>;
/// Spendable check [NUT-07]
async fn post_check_state(
&self,
request: CheckStateRequest,
) -> Result<CheckStateResponse, Error>;
/// Restore request [NUT-13]
async fn post_restore(&self, request: RestoreRequest) -> Result<RestoreResponse, Error>;
}

View File

@@ -0,0 +1,480 @@
#[cfg(feature = "auth")]
use std::sync::Arc;
use async_trait::async_trait;
#[cfg(feature = "auth")]
use cdk_common::{Method, ProtectedEndpoint, RoutePath};
use reqwest::{Client, IntoUrl};
use serde::de::DeserializeOwned;
use serde::Serialize;
#[cfg(feature = "auth")]
use tokio::sync::RwLock;
use tracing::instrument;
#[cfg(not(target_arch = "wasm32"))]
use url::Url;
use super::{Error, MintConnector};
use crate::error::ErrorResponse;
use crate::mint_url::MintUrl;
#[cfg(feature = "auth")]
use crate::nuts::nut22::MintAuthRequest;
use crate::nuts::{
AuthToken, CheckStateRequest, CheckStateResponse, Id, KeySet, KeysResponse, KeysetResponse,
MeltBolt11Request, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintBolt11Request,
MintBolt11Response, MintInfo, MintQuoteBolt11Request, MintQuoteBolt11Response, RestoreRequest,
RestoreResponse, SwapRequest, SwapResponse,
};
#[cfg(feature = "auth")]
use crate::wallet::auth::{AuthMintConnector, AuthWallet};
#[derive(Debug, Clone)]
struct HttpClientCore {
inner: Client,
}
impl HttpClientCore {
fn new() -> Self {
Self {
inner: Client::new(),
}
}
fn client(&self) -> &Client {
&self.inner
}
async fn http_get<U: IntoUrl + Send, R: DeserializeOwned>(
&self,
url: U,
auth: Option<AuthToken>,
) -> Result<R, Error> {
let mut request = self.client().get(url);
if let Some(auth) = auth {
request = request.header(auth.header_key(), auth.to_string());
}
let response = request
.send()
.await
.map_err(|e| Error::HttpError(e.to_string()))?
.text()
.await
.map_err(|e| Error::HttpError(e.to_string()))?;
serde_json::from_str::<R>(&response).map_err(|err| {
tracing::warn!("Http Response error: {}", err);
match ErrorResponse::from_json(&response) {
Ok(ok) => <ErrorResponse as Into<Error>>::into(ok),
Err(err) => err.into(),
}
})
}
async fn http_post<U: IntoUrl + Send, P: Serialize + ?Sized, R: DeserializeOwned>(
&self,
url: U,
auth_token: Option<AuthToken>,
payload: &P,
) -> Result<R, Error> {
let mut request = self.client().post(url).json(&payload);
if let Some(auth) = auth_token {
request = request.header(auth.header_key(), auth.to_string());
}
let response = request
.send()
.await
.map_err(|e| Error::HttpError(e.to_string()))?
.text()
.await
.map_err(|e| Error::HttpError(e.to_string()))?;
serde_json::from_str::<R>(&response).map_err(|err| {
tracing::warn!("Http Response error: {}", err);
match ErrorResponse::from_json(&response) {
Ok(ok) => <ErrorResponse as Into<Error>>::into(ok),
Err(err) => err.into(),
}
})
}
}
/// Http Client
#[derive(Debug, Clone)]
pub struct HttpClient {
core: HttpClientCore,
mint_url: MintUrl,
#[cfg(feature = "auth")]
auth_wallet: Arc<RwLock<Option<AuthWallet>>>,
}
impl HttpClient {
/// Create new [`HttpClient`]
#[cfg(feature = "auth")]
pub fn new(mint_url: MintUrl, auth_wallet: Option<AuthWallet>) -> Self {
Self {
core: HttpClientCore::new(),
mint_url,
auth_wallet: Arc::new(RwLock::new(auth_wallet)),
}
}
#[cfg(not(feature = "auth"))]
/// Create new [`HttpClient`]
pub fn new(mint_url: MintUrl) -> Self {
Self {
core: HttpClientCore::new(),
mint_url,
}
}
/// Get auth token for a protected endpoint
#[cfg(feature = "auth")]
async fn get_auth_token(
&self,
method: Method,
path: RoutePath,
) -> Result<Option<AuthToken>, Error> {
let auth_wallet = self.auth_wallet.read().await;
match auth_wallet.as_ref() {
Some(auth_wallet) => {
let endpoint = ProtectedEndpoint::new(method, path);
auth_wallet.get_auth_for_request(&endpoint).await
}
None => Ok(None),
}
}
#[cfg(not(target_arch = "wasm32"))]
/// Create new [`HttpClient`] with a proxy for specific TLDs.
/// Specifying `None` for `host_matcher` will use the proxy for all
/// requests.
pub fn with_proxy(
mint_url: MintUrl,
proxy: Url,
host_matcher: Option<&str>,
accept_invalid_certs: bool,
) -> Result<Self, Error> {
let regex = host_matcher
.map(regex::Regex::new)
.transpose()
.map_err(|e| Error::Custom(e.to_string()))?;
let client = reqwest::Client::builder()
.proxy(reqwest::Proxy::custom(move |url| {
if let Some(matcher) = regex.as_ref() {
if let Some(host) = url.host_str() {
if matcher.is_match(host) {
return Some(proxy.clone());
}
}
}
None
}))
.danger_accept_invalid_certs(accept_invalid_certs) // Allow self-signed certs
.build()
.map_err(|e| Error::HttpError(e.to_string()))?;
Ok(Self {
core: HttpClientCore { inner: client },
mint_url,
#[cfg(feature = "auth")]
auth_wallet: Arc::new(RwLock::new(None)),
})
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl MintConnector for HttpClient {
/// Get Active Mint Keys [NUT-01]
#[instrument(skip(self), fields(mint_url = %self.mint_url))]
async fn get_mint_keys(&self) -> Result<Vec<KeySet>, Error> {
let url = self.mint_url.join_paths(&["v1", "keys"])?;
Ok(self
.core
.http_get::<_, KeysResponse>(url, None)
.await?
.keysets)
}
/// Get Keyset Keys [NUT-01]
#[instrument(skip(self), fields(mint_url = %self.mint_url))]
async fn get_mint_keyset(&self, keyset_id: Id) -> Result<KeySet, Error> {
let url = self
.mint_url
.join_paths(&["v1", "keys", &keyset_id.to_string()])?;
let keys_response = self.core.http_get::<_, KeysResponse>(url, None).await?;
Ok(keys_response.keysets.first().unwrap().clone())
}
/// Get Keysets [NUT-02]
#[instrument(skip(self), fields(mint_url = %self.mint_url))]
async fn get_mint_keysets(&self) -> Result<KeysetResponse, Error> {
let url = self.mint_url.join_paths(&["v1", "keysets"])?;
self.core.http_get(url, None).await
}
/// Mint Quote [NUT-04]
#[instrument(skip(self), fields(mint_url = %self.mint_url))]
async fn post_mint_quote(
&self,
request: MintQuoteBolt11Request,
) -> Result<MintQuoteBolt11Response<String>, Error> {
let url = self
.mint_url
.join_paths(&["v1", "mint", "quote", "bolt11"])?;
#[cfg(feature = "auth")]
let auth_token = self
.get_auth_token(Method::Post, RoutePath::MintQuoteBolt11)
.await?;
#[cfg(not(feature = "auth"))]
let auth_token = None;
self.core.http_post(url, auth_token, &request).await
}
/// Mint Quote status
#[instrument(skip(self), fields(mint_url = %self.mint_url))]
async fn get_mint_quote_status(
&self,
quote_id: &str,
) -> Result<MintQuoteBolt11Response<String>, Error> {
let url = self
.mint_url
.join_paths(&["v1", "mint", "quote", "bolt11", quote_id])?;
#[cfg(feature = "auth")]
let auth_token = self
.get_auth_token(Method::Get, RoutePath::MintQuoteBolt11)
.await?;
#[cfg(not(feature = "auth"))]
let auth_token = None;
self.core.http_get(url, auth_token).await
}
/// Mint Tokens [NUT-04]
#[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
async fn post_mint(
&self,
request: MintBolt11Request<String>,
) -> Result<MintBolt11Response, Error> {
let url = self.mint_url.join_paths(&["v1", "mint", "bolt11"])?;
#[cfg(feature = "auth")]
let auth_token = self
.get_auth_token(Method::Post, RoutePath::MintBolt11)
.await?;
#[cfg(not(feature = "auth"))]
let auth_token = None;
self.core.http_post(url, auth_token, &request).await
}
/// Melt Quote [NUT-05]
#[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
async fn post_melt_quote(
&self,
request: MeltQuoteBolt11Request,
) -> Result<MeltQuoteBolt11Response<String>, Error> {
let url = self
.mint_url
.join_paths(&["v1", "melt", "quote", "bolt11"])?;
#[cfg(feature = "auth")]
let auth_token = self
.get_auth_token(Method::Post, RoutePath::MeltQuoteBolt11)
.await?;
#[cfg(not(feature = "auth"))]
let auth_token = None;
self.core.http_post(url, auth_token, &request).await
}
/// Melt Quote Status
#[instrument(skip(self), fields(mint_url = %self.mint_url))]
async fn get_melt_quote_status(
&self,
quote_id: &str,
) -> Result<MeltQuoteBolt11Response<String>, Error> {
let url = self
.mint_url
.join_paths(&["v1", "melt", "quote", "bolt11", quote_id])?;
#[cfg(feature = "auth")]
let auth_token = self
.get_auth_token(Method::Get, RoutePath::MeltQuoteBolt11)
.await?;
#[cfg(not(feature = "auth"))]
let auth_token = None;
self.core.http_get(url, auth_token).await
}
/// Melt [NUT-05]
/// [Nut-08] Lightning fee return if outputs defined
#[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
async fn post_melt(
&self,
request: MeltBolt11Request<String>,
) -> Result<MeltQuoteBolt11Response<String>, Error> {
let url = self.mint_url.join_paths(&["v1", "melt", "bolt11"])?;
#[cfg(feature = "auth")]
let auth_token = self
.get_auth_token(Method::Post, RoutePath::MeltBolt11)
.await?;
#[cfg(not(feature = "auth"))]
let auth_token = None;
self.core.http_post(url, auth_token, &request).await
}
/// Swap Token [NUT-03]
#[instrument(skip(self, swap_request), fields(mint_url = %self.mint_url))]
async fn post_swap(&self, swap_request: SwapRequest) -> Result<SwapResponse, Error> {
let url = self.mint_url.join_paths(&["v1", "swap"])?;
#[cfg(feature = "auth")]
let auth_token = self.get_auth_token(Method::Post, RoutePath::Swap).await?;
#[cfg(not(feature = "auth"))]
let auth_token = None;
self.core.http_post(url, auth_token, &swap_request).await
}
/// Helper to get mint info
async fn get_mint_info(&self) -> Result<MintInfo, Error> {
let url = self.mint_url.join_paths(&["v1", "info"])?;
self.core.http_get(url, None).await
}
#[cfg(feature = "auth")]
async fn get_auth_wallet(&self) -> Option<AuthWallet> {
self.auth_wallet.read().await.clone()
}
#[cfg(feature = "auth")]
async fn set_auth_wallet(&self, wallet: Option<AuthWallet>) {
*self.auth_wallet.write().await = wallet;
}
/// Spendable check [NUT-07]
#[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
async fn post_check_state(
&self,
request: CheckStateRequest,
) -> Result<CheckStateResponse, Error> {
let url = self.mint_url.join_paths(&["v1", "checkstate"])?;
#[cfg(feature = "auth")]
let auth_token = self
.get_auth_token(Method::Post, RoutePath::Checkstate)
.await?;
#[cfg(not(feature = "auth"))]
let auth_token = None;
self.core.http_post(url, auth_token, &request).await
}
/// Restore request [NUT-13]
#[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
async fn post_restore(&self, request: RestoreRequest) -> Result<RestoreResponse, Error> {
let url = self.mint_url.join_paths(&["v1", "restore"])?;
#[cfg(feature = "auth")]
let auth_token = self
.get_auth_token(Method::Post, RoutePath::Restore)
.await?;
#[cfg(not(feature = "auth"))]
let auth_token = None;
self.core.http_post(url, auth_token, &request).await
}
}
/// Http Client
#[derive(Debug, Clone)]
#[cfg(feature = "auth")]
pub struct AuthHttpClient {
core: HttpClientCore,
mint_url: MintUrl,
cat: Arc<RwLock<AuthToken>>,
}
#[cfg(feature = "auth")]
impl AuthHttpClient {
/// Create new [`AuthHttpClient`]
pub fn new(mint_url: MintUrl, cat: Option<AuthToken>) -> Self {
Self {
core: HttpClientCore::new(),
mint_url,
cat: Arc::new(RwLock::new(
cat.unwrap_or(AuthToken::ClearAuth("".to_string())),
)),
}
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
#[cfg(feature = "auth")]
impl AuthMintConnector for AuthHttpClient {
async fn get_auth_token(&self) -> Result<AuthToken, Error> {
Ok(self.cat.read().await.clone())
}
async fn set_auth_token(&self, token: AuthToken) -> Result<(), Error> {
*self.cat.write().await = token;
Ok(())
}
/// Get Mint Info [NUT-06]
async fn get_mint_info(&self) -> Result<MintInfo, Error> {
let url = self.mint_url.join_paths(&["v1", "info"])?;
let mint_info: MintInfo = self.core.http_get::<_, MintInfo>(url, None).await?;
Ok(mint_info)
}
/// Get Auth Keyset Keys [NUT-22]
#[instrument(skip(self), fields(mint_url = %self.mint_url))]
async fn get_mint_blind_auth_keyset(&self, keyset_id: Id) -> Result<KeySet, Error> {
let url =
self.mint_url
.join_paths(&["v1", "auth", "blind", "keys", &keyset_id.to_string()])?;
let mut keys_response = self.core.http_get::<_, KeysResponse>(url, None).await?;
let keyset = keys_response
.keysets
.drain(0..1)
.next()
.ok_or_else(|| Error::UnknownKeySet)?;
Ok(keyset)
}
/// Get Auth Keysets [NUT-22]
#[instrument(skip(self), fields(mint_url = %self.mint_url))]
async fn get_mint_blind_auth_keysets(&self) -> Result<KeysetResponse, Error> {
let url = self
.mint_url
.join_paths(&["v1", "auth", "blind", "keysets"])?;
self.core.http_get(url, None).await
}
/// Mint Tokens [NUT-22]
#[instrument(skip(self, request), fields(mint_url = %self.mint_url))]
async fn post_mint_blind_auth(
&self,
request: MintAuthRequest,
) -> Result<MintBolt11Response, Error> {
let url = self.mint_url.join_paths(&["v1", "auth", "blind", "mint"])?;
self.core
.http_post(url, Some(self.cat.read().await.clone()), &request)
.await
}
}

View File

@@ -0,0 +1,83 @@
//! Wallet client
use std::fmt::Debug;
use async_trait::async_trait;
use super::Error;
use crate::nuts::{
CheckStateRequest, CheckStateResponse, Id, KeySet, KeysetResponse, MeltBolt11Request,
MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintBolt11Request, MintBolt11Response,
MintInfo, MintQuoteBolt11Request, MintQuoteBolt11Response, RestoreRequest, RestoreResponse,
SwapRequest, SwapResponse,
};
#[cfg(feature = "auth")]
use crate::wallet::AuthWallet;
mod http_client;
#[cfg(feature = "auth")]
pub use http_client::AuthHttpClient;
pub use http_client::HttpClient;
/// Interface that connects a wallet to a mint. Typically represents an [HttpClient].
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
pub trait MintConnector: Debug {
/// Get Active Mint Keys [NUT-01]
async fn get_mint_keys(&self) -> Result<Vec<KeySet>, Error>;
/// Get Keyset Keys [NUT-01]
async fn get_mint_keyset(&self, keyset_id: Id) -> Result<KeySet, Error>;
/// Get Keysets [NUT-02]
async fn get_mint_keysets(&self) -> Result<KeysetResponse, Error>;
/// Mint Quote [NUT-04]
async fn post_mint_quote(
&self,
request: MintQuoteBolt11Request,
) -> Result<MintQuoteBolt11Response<String>, Error>;
/// Mint Quote status
async fn get_mint_quote_status(
&self,
quote_id: &str,
) -> Result<MintQuoteBolt11Response<String>, Error>;
/// Mint Tokens [NUT-04]
async fn post_mint(
&self,
request: MintBolt11Request<String>,
) -> Result<MintBolt11Response, Error>;
/// Melt Quote [NUT-05]
async fn post_melt_quote(
&self,
request: MeltQuoteBolt11Request,
) -> Result<MeltQuoteBolt11Response<String>, Error>;
/// Melt Quote Status
async fn get_melt_quote_status(
&self,
quote_id: &str,
) -> Result<MeltQuoteBolt11Response<String>, Error>;
/// Melt [NUT-05]
/// [Nut-08] Lightning fee return if outputs defined
async fn post_melt(
&self,
request: MeltBolt11Request<String>,
) -> Result<MeltQuoteBolt11Response<String>, Error>;
/// Split Token [NUT-06]
async fn post_swap(&self, request: SwapRequest) -> Result<SwapResponse, Error>;
/// Get Mint Info [NUT-06]
async fn get_mint_info(&self) -> Result<MintInfo, Error>;
/// Spendable check [NUT-07]
async fn post_check_state(
&self,
request: CheckStateRequest,
) -> Result<CheckStateResponse, Error>;
/// Restore request [NUT-13]
async fn post_restore(&self, request: RestoreRequest) -> Result<RestoreResponse, Error>;
/// Get the auth wallet for the client
#[cfg(feature = "auth")]
async fn get_auth_wallet(&self) -> Option<AuthWallet>;
/// Set auth wallet on client
#[cfg(feature = "auth")]
async fn set_auth_wallet(&self, wallet: Option<AuthWallet>);
}

View File

@@ -5,15 +5,13 @@ use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
use bitcoin::bip32::Xpriv; use bitcoin::bip32::Xpriv;
use bitcoin::Network;
use cdk_common::database::{self, WalletDatabase}; use cdk_common::database::{self, WalletDatabase};
use cdk_common::subscription::Params; use cdk_common::subscription::Params;
use client::MintConnector;
use getrandom::getrandom; use getrandom::getrandom;
pub use multi_mint_wallet::MultiMintWallet;
use subscription::{ActiveSubscription, SubscriptionManager}; use subscription::{ActiveSubscription, SubscriptionManager};
#[cfg(feature = "auth")]
use tokio::sync::RwLock;
use tracing::instrument; use tracing::instrument;
pub use types::{MeltQuote, MintQuote, SendKind};
use crate::amount::SplitTarget; use crate::amount::SplitTarget;
use crate::dhke::construct_proofs; use crate::dhke::construct_proofs;
@@ -27,13 +25,19 @@ use crate::nuts::{
RestoreRequest, SpendingConditions, State, RestoreRequest, SpendingConditions, State,
}; };
use crate::types::ProofInfo; use crate::types::ProofInfo;
use crate::{Amount, HttpClient}; use crate::util::unix_time;
use crate::Amount;
#[cfg(feature = "auth")]
use crate::OidcClient;
#[cfg(feature = "auth")]
mod auth;
mod balance; mod balance;
pub mod client; mod builder;
mod keysets; mod keysets;
mod melt; mod melt;
mod mint; mod mint;
mod mint_connector;
pub mod multi_mint_wallet; pub mod multi_mint_wallet;
mod proofs; mod proofs;
mod receive; mod receive;
@@ -42,8 +46,16 @@ pub mod subscription;
mod swap; mod swap;
pub mod util; pub mod util;
#[cfg(feature = "auth")]
pub use auth::{AuthMintConnector, AuthWallet};
pub use builder::WalletBuilder;
pub use cdk_common::wallet as types; pub use cdk_common::wallet as types;
#[cfg(feature = "auth")]
pub use mint_connector::AuthHttpClient;
pub use mint_connector::{HttpClient, MintConnector};
pub use multi_mint_wallet::MultiMintWallet;
pub use send::{PreparedSend, SendMemo, SendOptions}; pub use send::{PreparedSend, SendMemo, SendOptions};
pub use types::{MeltQuote, MintQuote, SendKind};
use crate::nuts::nut00::ProofsMethods; use crate::nuts::nut00::ProofsMethods;
@@ -62,6 +74,8 @@ pub struct Wallet {
pub localstore: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>, pub localstore: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
/// The targeted amount of proofs to have at each size /// The targeted amount of proofs to have at each size
pub target_proof_count: usize, pub target_proof_count: usize,
#[cfg(feature = "auth")]
auth_wallet: Arc<RwLock<Option<AuthWallet>>>,
xpriv: Xpriv, xpriv: Xpriv,
client: Arc<dyn MintConnector + Send + Sync>, client: Arc<dyn MintConnector + Send + Sync>,
subscription: SubscriptionManager, subscription: SubscriptionManager,
@@ -115,14 +129,16 @@ impl From<WalletSubscription> for Params {
} }
impl Wallet { impl Wallet {
/// Create new [`Wallet`] /// Create new [`Wallet`] using the builder pattern
/// # Synopsis /// # Synopsis
/// ```rust /// ```rust
/// use std::sync::Arc; /// use std::sync::Arc;
/// use bitcoin::Network;
/// use bitcoin::bip32::Xpriv;
/// ///
/// use cdk_sqlite::wallet::memory; /// use cdk_sqlite::wallet::memory;
/// use cdk::nuts::CurrencyUnit; /// use cdk::nuts::CurrencyUnit;
/// use cdk::wallet::Wallet; /// use cdk::wallet::{Wallet, WalletBuilder};
/// use rand::Rng; /// use rand::Rng;
/// ///
/// async fn test() -> anyhow::Result<()> { /// async fn test() -> anyhow::Result<()> {
@@ -131,7 +147,12 @@ impl Wallet {
/// let unit = CurrencyUnit::Sat; /// let unit = CurrencyUnit::Sat;
/// ///
/// let localstore = memory::empty().await?; /// let localstore = memory::empty().await?;
/// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None); /// let wallet = WalletBuilder::new()
/// .mint_url(mint_url.parse().unwrap())
/// .unit(unit)
/// .localstore(Arc::new(localstore))
/// .seed(&seed)
/// .build();
/// Ok(()) /// Ok(())
/// } /// }
/// ``` /// ```
@@ -142,32 +163,21 @@ impl Wallet {
seed: &[u8], seed: &[u8],
target_proof_count: Option<usize>, target_proof_count: Option<usize>,
) -> Result<Self, Error> { ) -> Result<Self, Error> {
let xpriv = Xpriv::new_master(Network::Bitcoin, seed).expect("Could not create master key");
let mint_url = MintUrl::from_str(mint_url)?; let mint_url = MintUrl::from_str(mint_url)?;
let http_client = Arc::new(HttpClient::new(mint_url.clone())); WalletBuilder::new()
.mint_url(mint_url)
Ok(Self { .unit(unit)
mint_url: mint_url.clone(), .localstore(localstore)
unit, .seed(seed)
client: http_client.clone(), .target_proof_count(target_proof_count.unwrap_or(3))
subscription: SubscriptionManager::new(http_client), .build()
localstore,
xpriv,
target_proof_count: target_proof_count.unwrap_or(3),
})
}
/// Change HTTP client
pub fn set_client<C: MintConnector + 'static + Send + Sync>(&mut self, client: C) {
self.client = Arc::new(client);
self.subscription = SubscriptionManager::new(self.client.clone());
} }
/// Subscribe to events /// Subscribe to events
pub async fn subscribe<T: Into<Params>>(&self, query: T) -> ActiveSubscription { pub async fn subscribe<T: Into<Params>>(&self, query: T) -> ActiveSubscription {
self.subscription self.subscription
.subscribe(self.mint_url.clone(), query.into()) .subscribe(self.mint_url.clone(), query.into(), Arc::new(self.clone()))
.await .await
} }
@@ -232,21 +242,68 @@ impl Wallet {
/// Query mint for current mint information /// Query mint for current mint information
#[instrument(skip(self))] #[instrument(skip(self))]
pub async fn get_mint_info(&self) -> Result<Option<MintInfo>, Error> { pub async fn get_mint_info(&self) -> Result<Option<MintInfo>, Error> {
let mint_info = match self.client.get_mint_info().await { match self.client.get_mint_info().await {
Ok(mint_info) => Some(mint_info), Ok(mint_info) => {
Err(err) => { // If mint provides time make sure it is accurate
tracing::warn!("Could not get mint info {}", err); if let Some(mint_unix_time) = mint_info.time {
None let current_unix_time = unix_time();
if current_unix_time.abs_diff(mint_unix_time) > 30 {
tracing::warn!(
"Mint time does match wallet time. Mint: {}, Wallet: {}",
mint_unix_time,
current_unix_time
);
return Err(Error::MintTimeExceedsTolerance);
}
}
// Create or update auth wallet
#[cfg(feature = "auth")]
{
let mut auth_wallet = self.auth_wallet.write().await;
match &*auth_wallet {
Some(auth_wallet) => {
let mut protected_endpoints =
auth_wallet.protected_endpoints.write().await;
*protected_endpoints = mint_info.protected_endpoints();
if let Some(oidc_client) =
mint_info.openid_discovery().map(OidcClient::new)
{
auth_wallet.set_oidc_client(Some(oidc_client)).await;
}
}
None => {
tracing::info!("Mint has auth enabled creating auth wallet");
let oidc_client = mint_info.openid_discovery().map(OidcClient::new);
let new_auth_wallet = AuthWallet::new(
self.mint_url.clone(),
None,
self.localstore.clone(),
mint_info.protected_endpoints(),
oidc_client,
);
*auth_wallet = Some(new_auth_wallet.clone());
self.client.set_auth_wallet(Some(new_auth_wallet)).await;
}
}
} }
};
self.localstore self.localstore
.add_mint(self.mint_url.clone(), mint_info.clone()) .add_mint(self.mint_url.clone(), Some(mint_info.clone()))
.await?; .await?;
tracing::trace!("Mint info updated for {}", self.mint_url); tracing::trace!("Mint info updated for {}", self.mint_url);
Ok(mint_info) Ok(Some(mint_info))
}
Err(err) => {
tracing::warn!("Could not get mint info {}", err);
Ok(None)
}
}
} }
/// Get amounts needed to refill proof state /// Get amounts needed to refill proof state

View File

@@ -100,6 +100,7 @@ impl Wallet {
.client .client
.post_check_state(CheckStateRequest { ys: proofs.ys()? }) .post_check_state(CheckStateRequest { ys: proofs.ys()? })
.await?; .await?;
let spent_ys: Vec<_> = spendable let spent_ys: Vec<_> = spendable
.states .states
.iter() .iter()

View File

@@ -9,7 +9,8 @@ use super::WsSubscriptionBody;
use crate::nuts::nut17::Kind; use crate::nuts::nut17::Kind;
use crate::nuts::{nut01, nut04, nut05, nut07, CheckStateRequest, NotificationPayload}; use crate::nuts::{nut01, nut04, nut05, nut07, CheckStateRequest, NotificationPayload};
use crate::pub_sub::SubId; use crate::pub_sub::SubId;
use crate::wallet::client::MintConnector; use crate::wallet::MintConnector;
use crate::Wallet;
#[derive(Debug, Hash, PartialEq, Eq)] #[derive(Debug, Hash, PartialEq, Eq)]
enum UrlType { enum UrlType {
@@ -77,6 +78,7 @@ pub async fn http_main<S: IntoIterator<Item = SubId>>(
subscriptions: Arc<RwLock<HashMap<SubId, WsSubscriptionBody>>>, subscriptions: Arc<RwLock<HashMap<SubId, WsSubscriptionBody>>>,
mut new_subscription_recv: mpsc::Receiver<SubId>, mut new_subscription_recv: mpsc::Receiver<SubId>,
mut on_drop: mpsc::Receiver<SubId>, mut on_drop: mpsc::Receiver<SubId>,
_wallet: Arc<Wallet>,
) { ) {
let mut interval = time::interval(Duration::from_secs(2)); let mut interval = time::interval(Duration::from_secs(2));
let mut subscribed_to = HashMap::<UrlType, (mpsc::Sender<_>, _, AnyState)>::new(); let mut subscribed_to = HashMap::<UrlType, (mpsc::Sender<_>, _, AnyState)>::new();
@@ -92,6 +94,7 @@ pub async fn http_main<S: IntoIterator<Item = SubId>>(
tracing::debug!("Polling: {:?}", url); tracing::debug!("Polling: {:?}", url);
match url { match url {
UrlType::Mint(id) => { UrlType::Mint(id) => {
let response = http_client.get_mint_quote_status(id).await; let response = http_client.get_mint_quote_status(id).await;
if let Ok(response) = response { if let Ok(response) = response {
if *last_state == AnyState::MintQuoteState(response.state) { if *last_state == AnyState::MintQuoteState(response.state) {
@@ -104,6 +107,7 @@ pub async fn http_main<S: IntoIterator<Item = SubId>>(
} }
} }
UrlType::Melt(id) => { UrlType::Melt(id) => {
let response = http_client.get_melt_quote_status(id).await; let response = http_client.get_melt_quote_status(id).await;
if let Ok(response) = response { if let Ok(response) = response {
if *last_state == AnyState::MeltQuoteState(response.state) { if *last_state == AnyState::MeltQuoteState(response.state) {
@@ -118,7 +122,8 @@ pub async fn http_main<S: IntoIterator<Item = SubId>>(
UrlType::PublicKey(id) => { UrlType::PublicKey(id) => {
let responses = http_client.post_check_state(CheckStateRequest { let responses = http_client.post_check_state(CheckStateRequest {
ys: vec![*id], ys: vec![*id],
}).await; }
).await;
if let Ok(mut responses) = responses { if let Ok(mut responses) = responses {
let response = if let Some(state) = responses.states.pop() { let response = if let Some(state) = responses.states.pop() {
state state

View File

@@ -14,9 +14,10 @@ use tokio::sync::{mpsc, RwLock};
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tracing::error; use tracing::error;
use super::Wallet;
use crate::mint_url::MintUrl; use crate::mint_url::MintUrl;
use crate::pub_sub::SubId; use crate::pub_sub::SubId;
use crate::wallet::client::MintConnector; use crate::wallet::MintConnector;
mod http; mod http;
#[cfg(all( #[cfg(all(
@@ -59,7 +60,12 @@ impl SubscriptionManager {
} }
/// Subscribe to updates from a mint server with a given filter /// Subscribe to updates from a mint server with a given filter
pub async fn subscribe(&self, mint_url: MintUrl, filter: Params) -> ActiveSubscription { pub async fn subscribe(
&self,
mint_url: MintUrl,
filter: Params,
wallet: Arc<Wallet>,
) -> ActiveSubscription {
let subscription_clients = self.all_connections.read().await; let subscription_clients = self.all_connections.read().await;
let id = filter.id.clone(); let id = filter.id.clone();
if let Some(subscription_client) = subscription_clients.get(&mint_url) { if let Some(subscription_client) = subscription_clients.get(&mint_url) {
@@ -94,8 +100,12 @@ impl SubscriptionManager {
); );
let mut subscription_clients = self.all_connections.write().await; let mut subscription_clients = self.all_connections.write().await;
let subscription_client = let subscription_client = SubscriptionClient::new(
SubscriptionClient::new(mint_url.clone(), self.http_client.clone(), is_ws_support); mint_url.clone(),
self.http_client.clone(),
is_ws_support,
wallet,
);
let (on_drop_notif, receiver) = subscription_client.subscribe(filter).await; let (on_drop_notif, receiver) = subscription_client.subscribe(filter).await;
subscription_clients.insert(mint_url, subscription_client); subscription_clients.insert(mint_url, subscription_client);
@@ -179,6 +189,7 @@ impl SubscriptionClient {
url: MintUrl, url: MintUrl,
http_client: Arc<dyn MintConnector + Send + Sync>, http_client: Arc<dyn MintConnector + Send + Sync>,
prefer_ws_method: bool, prefer_ws_method: bool,
wallet: Arc<Wallet>,
) -> Self { ) -> Self {
let subscriptions = Arc::new(RwLock::new(HashMap::new())); let subscriptions = Arc::new(RwLock::new(HashMap::new()));
let (new_subscription_notif, new_subscription_recv) = mpsc::channel(100); let (new_subscription_notif, new_subscription_recv) = mpsc::channel(100);
@@ -195,6 +206,7 @@ impl SubscriptionClient {
subscriptions, subscriptions,
new_subscription_recv, new_subscription_recv,
on_drop_recv, on_drop_recv,
wallet,
)), )),
} }
} }
@@ -207,6 +219,7 @@ impl SubscriptionClient {
subscriptions: Arc<RwLock<HashMap<SubId, WsSubscriptionBody>>>, subscriptions: Arc<RwLock<HashMap<SubId, WsSubscriptionBody>>>,
new_subscription_recv: mpsc::Receiver<SubId>, new_subscription_recv: mpsc::Receiver<SubId>,
on_drop_recv: mpsc::Receiver<SubId>, on_drop_recv: mpsc::Receiver<SubId>,
wallet: Arc<Wallet>,
) -> JoinHandle<()> { ) -> JoinHandle<()> {
#[cfg(any( #[cfg(any(
feature = "http_subscription", feature = "http_subscription",
@@ -218,6 +231,7 @@ impl SubscriptionClient {
subscriptions, subscriptions,
new_subscription_recv, new_subscription_recv,
on_drop_recv, on_drop_recv,
wallet,
); );
#[cfg(all( #[cfg(all(
@@ -232,6 +246,7 @@ impl SubscriptionClient {
subscriptions, subscriptions,
new_subscription_recv, new_subscription_recv,
on_drop_recv, on_drop_recv,
wallet,
) )
} else { } else {
Self::http_worker( Self::http_worker(
@@ -239,6 +254,7 @@ impl SubscriptionClient {
subscriptions, subscriptions,
new_subscription_recv, new_subscription_recv,
on_drop_recv, on_drop_recv,
wallet,
) )
} }
} }
@@ -268,6 +284,7 @@ impl SubscriptionClient {
subscriptions: Arc<RwLock<HashMap<SubId, WsSubscriptionBody>>>, subscriptions: Arc<RwLock<HashMap<SubId, WsSubscriptionBody>>>,
new_subscription_recv: mpsc::Receiver<SubId>, new_subscription_recv: mpsc::Receiver<SubId>,
on_drop: mpsc::Receiver<SubId>, on_drop: mpsc::Receiver<SubId>,
wallet: Arc<Wallet>,
) -> JoinHandle<()> { ) -> JoinHandle<()> {
let http_worker = http::http_main( let http_worker = http::http_main(
vec![], vec![],
@@ -275,6 +292,7 @@ impl SubscriptionClient {
subscriptions, subscriptions,
new_subscription_recv, new_subscription_recv,
on_drop, on_drop,
wallet,
); );
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
@@ -301,6 +319,7 @@ impl SubscriptionClient {
subscriptions: Arc<RwLock<HashMap<SubId, WsSubscriptionBody>>>, subscriptions: Arc<RwLock<HashMap<SubId, WsSubscriptionBody>>>,
new_subscription_recv: mpsc::Receiver<SubId>, new_subscription_recv: mpsc::Receiver<SubId>,
on_drop: mpsc::Receiver<SubId>, on_drop: mpsc::Receiver<SubId>,
wallet: Arc<Wallet>,
) -> JoinHandle<()> { ) -> JoinHandle<()> {
tokio::spawn(ws::ws_main( tokio::spawn(ws::ws_main(
http_client, http_client,
@@ -308,6 +327,7 @@ impl SubscriptionClient {
subscriptions, subscriptions,
new_subscription_recv, new_subscription_recv,
on_drop, on_drop,
wallet,
)) ))
} }
} }

View File

@@ -13,7 +13,8 @@ use super::http::http_main;
use super::WsSubscriptionBody; use super::WsSubscriptionBody;
use crate::mint_url::MintUrl; use crate::mint_url::MintUrl;
use crate::pub_sub::SubId; use crate::pub_sub::SubId;
use crate::wallet::client::MintConnector; use crate::wallet::MintConnector;
use crate::Wallet;
const MAX_ATTEMPT_FALLBACK_HTTP: usize = 10; const MAX_ATTEMPT_FALLBACK_HTTP: usize = 10;
@@ -23,6 +24,7 @@ async fn fallback_to_http<S: IntoIterator<Item = SubId>>(
subscriptions: Arc<RwLock<HashMap<SubId, WsSubscriptionBody>>>, subscriptions: Arc<RwLock<HashMap<SubId, WsSubscriptionBody>>>,
new_subscription_recv: mpsc::Receiver<SubId>, new_subscription_recv: mpsc::Receiver<SubId>,
on_drop: mpsc::Receiver<SubId>, on_drop: mpsc::Receiver<SubId>,
wallet: Arc<Wallet>,
) { ) {
http_main( http_main(
initial_state, initial_state,
@@ -30,6 +32,7 @@ async fn fallback_to_http<S: IntoIterator<Item = SubId>>(
subscriptions, subscriptions,
new_subscription_recv, new_subscription_recv,
on_drop, on_drop,
wallet,
) )
.await .await
} }
@@ -41,6 +44,7 @@ pub async fn ws_main(
subscriptions: Arc<RwLock<HashMap<SubId, WsSubscriptionBody>>>, subscriptions: Arc<RwLock<HashMap<SubId, WsSubscriptionBody>>>,
mut new_subscription_recv: mpsc::Receiver<SubId>, mut new_subscription_recv: mpsc::Receiver<SubId>,
mut on_drop: mpsc::Receiver<SubId>, mut on_drop: mpsc::Receiver<SubId>,
wallet: Arc<Wallet>,
) { ) {
let url = mint_url let url = mint_url
.join_paths(&["v1", "ws"]) .join_paths(&["v1", "ws"])
@@ -76,6 +80,7 @@ pub async fn ws_main(
subscriptions, subscriptions,
new_subscription_recv, new_subscription_recv,
on_drop, on_drop,
wallet,
) )
.await; .await;
} }
@@ -175,6 +180,7 @@ pub async fn ws_main(
subscriptions, subscriptions,
new_subscription_recv, new_subscription_recv,
on_drop, on_drop,
wallet
).await; ).await;
} }
} }

View File

@@ -229,6 +229,9 @@
${_shellHook} ${_shellHook}
cargo update cargo update
cargo update -p async-compression --precise 0.4.3 cargo update -p async-compression --precise 0.4.3
cargo update -p zstd-sys --precise 2.0.8+zstd.1.5.5
cargo update -p flate2 --precise 1.0.35
cargo update -p home --precise 0.5.5 cargo update -p home --precise 0.5.5
cargo update -p zerofrom --precise 0.1.5 cargo update -p zerofrom --precise 0.1.5

View File

@@ -35,7 +35,7 @@ format:
cargo fmt --all cargo fmt --all
nixpkgs-fmt $(echo **.nix) nixpkgs-fmt $(echo **.nix)
# run tests # run doc tests
test: build test: build
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
@@ -48,6 +48,13 @@ test: build
cargo test -p cdk-integration-tests --test integration_tests_pure cargo test -p cdk-integration-tests --test integration_tests_pure
cargo test -p cdk-integration-tests --test mint cargo test -p cdk-integration-tests --test mint
test-all db:
#!/usr/bin/env bash
just test
./misc/itests.sh "{{db}}"
./misc/fake_itests.sh "{{db}}"
# run `cargo clippy` on everything # run `cargo clippy` on everything
clippy *ARGS="--locked --offline --workspace --all-targets": clippy *ARGS="--locked --offline --workspace --all-targets":
cargo clippy {{ARGS}} cargo clippy {{ARGS}}
@@ -78,6 +85,11 @@ itest-payment-processor ln:
#!/usr/bin/env bash #!/usr/bin/env bash
./misc/mintd_payment_processor.sh "{{ln}}" ./misc/mintd_payment_processor.sh "{{ln}}"
fake-auth-mint-itest db openid_discovery:
#!/usr/bin/env bash
./misc/fake_auth_itests.sh "{{db}}" "{{openid_discovery}}"
run-examples: run-examples:
cargo r --example p2pk cargo r --example p2pk
cargo r --example mint-token cargo r --example mint-token

109
misc/fake_auth_itests.sh Executable file
View File

@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# Function to perform cleanup
cleanup() {
echo "Cleaning up..."
echo "Killing the cdk mintd"
kill -2 $cdk_mintd_pid
wait $cdk_mintd_pid
echo "Mint binary terminated"
# Remove the temporary directory
rm -rf "$cdk_itests"
echo "Temp directory removed: $cdk_itests"
unset cdk_itests
unset cdk_itests_mint_addr
unset cdk_itests_mint_port
}
# Set up trap to call cleanup on script exit
trap cleanup EXIT
# Create a temporary directory
export cdk_itests=$(mktemp -d)
export cdk_itests_mint_addr="127.0.0.1";
export cdk_itests_mint_port=8087;
# Check if the temporary directory was created successfully
if [[ ! -d "$cdk_itests" ]]; then
echo "Failed to create temp directory"
exit 1
fi
echo "Temp directory created: $cdk_itests"
export MINT_DATABASE="$1";
export OPENID_DISCOVERY="$2";
cargo build -p cdk-integration-tests
export CDK_MINTD_URL="http://$cdk_itests_mint_addr:$cdk_itests_mint_port";
export CDK_MINTD_WORK_DIR="$cdk_itests";
export CDK_MINTD_LISTEN_HOST=$cdk_itests_mint_addr;
export CDK_MINTD_LISTEN_PORT=$cdk_itests_mint_port;
export CDK_MINTD_LN_BACKEND="fakewallet";
export CDK_MINTD_FAKE_WALLET_SUPPORTED_UNITS="sat";
export CDK_MINTD_MNEMONIC="eye survey guilt napkin crystal cup whisper salt luggage manage unveil loyal";
export CDK_MINTD_FAKE_WALLET_FEE_PERCENT="0";
export CDK_MINTD_FAKE_WALLET_RESERVE_FEE_MIN="1";
export CDK_MINTD_DATABASE=$MINT_DATABASE;
# Auth configuration
export CDK_TEST_OIDC_USER="cdk-test";
export CDK_TEST_OIDC_PASSWORD="cdkpassword";
export CDK_MINTD_AUTH_OPENID_DISCOVERY=$OPENID_DISCOVERY;
export CDK_MINTD_AUTH_OPENID_CLIENT_ID="cashu-client";
export CDK_MINTD_AUTH_MINT_MAX_BAT="50";
export CDK_MINTD_AUTH_ENABLED_MINT="true";
export CDK_MINTD_AUTH_ENABLED_MELT="true";
export CDK_MINTD_AUTH_ENABLED_SWAP="true";
export CDK_MINTD_AUTH_ENABLED_CHECK_MINT_QUOTE="true";
export CDK_MINTD_AUTH_ENABLED_CHECK_MELT_QUOTE="true";
export CDK_MINTD_AUTH_ENABLED_RESTORE="true";
export CDK_MINTD_AUTH_ENABLED_CHECK_PROOF_STATE="true";
echo "Starting auth mintd";
cargo run --bin cdk-mintd --features redb &
cdk_mintd_pid=$!
URL="http://$cdk_itests_mint_addr:$cdk_itests_mint_port/v1/info"
TIMEOUT=100
START_TIME=$(date +%s)
# Loop until the endpoint returns a 200 OK status or timeout is reached
while true; do
# Get the current time
CURRENT_TIME=$(date +%s)
# Calculate the elapsed time
ELAPSED_TIME=$((CURRENT_TIME - START_TIME))
# Check if the elapsed time exceeds the timeout
if [ $ELAPSED_TIME -ge $TIMEOUT ]; then
echo "Timeout of $TIMEOUT seconds reached. Exiting..."
exit 1
fi
# Make a request to the endpoint and capture the HTTP status code
HTTP_STATUS=$(curl -o /dev/null -s -w "%{http_code}" $URL)
# Check if the HTTP status is 200 OK
if [ "$HTTP_STATUS" -eq 200 ]; then
echo "Received 200 OK from $URL"
break
else
echo "Waiting for 200 OK response, current status: $HTTP_STATUS"
sleep 2 # Wait for 2 seconds before retrying
fi
done
# Run cargo test
cargo test -p cdk-integration-tests --test fake_auth
# Capture the exit status of cargo test
test_status=$?
# Exit with the status of the test
exit $test_status

View File

@@ -0,0 +1,7 @@
POSTGRES_DB=keycloak_db
POSTGRES_USER=keycloak_db_user
POSTGRES_PASSWORD=keycloak_db_user_password
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=password
KC_HOSTNAME=localhost
KC_HOSTNAME_PORT=8080

View File

@@ -0,0 +1,45 @@
services:
postgres:
image: postgres:16.4
volumes:
- ./postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: cashu
POSTGRES_USER: cashu
POSTGRES_PASSWORD: cashu
networks:
- keycloak_network
keycloak:
image: quay.io/keycloak/keycloak:25.0.6
command: start --import-realm
volumes:
- ./keycloak-export:/opt/keycloak/data/import
environment:
KC_HOSTNAME: localhost
KC_HOSTNAME_PORT: 8080
KC_HOSTNAME_STRICT_BACKCHANNEL: false
KC_HTTP_ENABLED: true
KC_HOSTNAME_STRICT_HTTPS: false
KC_HEALTH_ENABLED: true
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres/cashu
KC_DB_USERNAME: cashu
KC_DB_PASSWORD: cashu
ports:
- 8080:8080
restart: always
depends_on:
- postgres
networks:
- keycloak_network
volumes:
postgres_data:
driver: local
networks:
keycloak_network:
driver: bridge

View File

@@ -0,0 +1,43 @@
services:
postgres:
image: postgres:16.4
volumes:
- ./postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
networks:
- keycloak_network
keycloak:
image: quay.io/keycloak/keycloak:25.0.6
command: start
environment:
KC_HOSTNAME: localhost
KC_HOSTNAME_PORT: 8080
KC_HOSTNAME_STRICT_BACKCHANNEL: false
KC_HTTP_ENABLED: true
KC_HOSTNAME_STRICT_HTTPS: false
KC_HEALTH_ENABLED: true
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN}
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres/${POSTGRES_DB}
KC_DB_USERNAME: ${POSTGRES_USER}
KC_DB_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- 8080:8080
restart: always
depends_on:
- postgres
networks:
- keycloak_network
volumes:
postgres_data:
driver: local
networks:
keycloak_network:
driver: bridge

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
{
"realm" : "cdk-test-realm",
"users" : [ {
"id" : "6ea5cccc-bb0d-4757-a676-2515056fb4c6",
"username" : "cdk-test",
"firstName" : "test",
"lastName" : "test",
"email" : "test@email.com",
"emailVerified" : false,
"createdTimestamp" : 1740501176107,
"enabled" : true,
"totp" : false,
"credentials" : [ {
"id" : "a184d280-ee03-48c1-bd99-b60cbec7f828",
"type" : "password",
"userLabel" : "My password",
"createdDate" : 1740501197395,
"secretData" : "{\"value\":\"qIQdnJ76MfwoPE0NgGxjpNPOrWvlvnIhreaCA0fX88g=\",\"salt\":\"npddG2eT8Ofp/M2QORJu0Q==\",\"additionalParameters\":{}}",
"credentialData" : "{\"hashIterations\":5,\"algorithm\":\"argon2\",\"additionalParameters\":{\"hashLength\":[\"32\"],\"memory\":[\"7168\"],\"type\":[\"id\"],\"version\":[\"1.3\"],\"parallelism\":[\"1\"]}}"
} ],
"disableableCredentialTypes" : [ ],
"requiredActions" : [ ],
"realmRoles" : [ "default-roles-cdk-test-realm" ],
"notBefore" : 0,
"groups" : [ ]
} ]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
{
"realm" : "master",
"users" : [ {
"id" : "3ba20234-cfa8-4ff7-ae64-dac4870470e5",
"username" : "admin",
"emailVerified" : false,
"createdTimestamp" : 1740500953408,
"enabled" : true,
"totp" : false,
"credentials" : [ {
"id" : "98ceebfb-8dbe-4842-a335-e900d84eb2ef",
"type" : "password",
"createdDate" : 1740500953499,
"secretData" : "{\"value\":\"J5GuTpzJwSHGrfMzp7yPoBBmyQZ+Ijk5AozNbxkHcI0=\",\"salt\":\"uVtNH1+Non3hg/mKYy3XMQ==\",\"additionalParameters\":{}}",
"credentialData" : "{\"hashIterations\":5,\"algorithm\":\"argon2\",\"additionalParameters\":{\"hashLength\":[\"32\"],\"memory\":[\"7168\"],\"type\":[\"id\"],\"version\":[\"1.3\"],\"parallelism\":[\"1\"]}}"
} ],
"disableableCredentialTypes" : [ ],
"requiredActions" : [ ],
"realmRoles" : [ "default-roles-master", "admin" ],
"clientRoles" : {
"cdk-test-realm-realm" : [ "view-identity-providers", "manage-realm", "query-users", "manage-users", "create-client", "view-events", "manage-clients", "view-clients", "view-authorization", "manage-authorization", "view-realm", "manage-identity-providers", "query-realms", "manage-events", "view-users", "query-clients", "query-groups" ]
},
"notBefore" : 0,
"groups" : [ ]
} ]
}