Skip to content

Commit

Permalink
Blind authentication (#675)
Browse files Browse the repository at this point in the history
* auth server

* cleaning up

* auth ledger class

* class variables -> instance variables

* annotations

* add models and api route

* custom amount and api prefix

* add auth db

* blind auth token working

* jwt working

* clean up

* JWT works

* using openid connect server

* use oauth server with password flow

* new realm

* add keycloak docker

* hopefully not garbage

* auth works

* auth kinda working

* fix cli

* auth works for send and receive

* pass auth_db to Wallet

* auth in info

* refactor

* fix supported

* cache mint info

* fix settings and endpoints

* add description to .env.example

* track changes for openid connect client

* store mint in db

* store credentials

* clean up v1_api.py

* load mint info into auth wallet

* fix first login

* authenticate if refresh token fails

* clear auth also middleware

* use regex

* add cli command

* pw works

* persist keyset amounts

* add errors.py

* do not start auth server if disabled in config

* upadte poetry

* disvoery url

* fix test

* support device code flow

* adopt latest spec changes

* fix code flow

* mint max bat dynamic

* mypy ignore

* fix test

* do not serialize amount in authproof

* all auth flows working

* fix tests

* submodule

* refactor

* test

* dont sleep

* test

* add wallet auth tests

* test differently

* test only keycloak for now

* fix creds

* daemon

* fix test

* install everything

* install jinja

* delete wallet for every test

* auth: use global rate limiter

* test auth rate limit

* keycloak hostname

* move keycloak test data

* reactivate all tests

* add readme

* load proofs

* remove unused code

* remove unused code

* implement change suggestions by ok300

* add error codes

* test errors
  • Loading branch information
callebtc authored Jan 30, 2025
1 parent b67ffd8 commit a0ef44d
Show file tree
Hide file tree
Showing 58 changed files with 8,188 additions and 701 deletions.
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,21 @@ LIGHTNING_RESERVE_FEE_MIN=2000
# MINT_GLOBAL_RATE_LIMIT_PER_MINUTE=60
# Determines the number of transactions (mint, melt, swap) allowed per minute per IP
# MINT_TRANSACTION_RATE_LIMIT_PER_MINUTE=20

# Authentication
# These settings allow you to enable blind authentication to limit the user of your mint to a group of authenticated users.
# To use this, you need to set up an OpenID Connect provider like Keycloak, Auth0, or Hydra.
# - Add the client ID "cashu-client"
# - Enable the ES256 and RS256 algorithms for this client
# - If you want to use the authorization flow, you must add the redirect URI "http://localhost:33388/callback".
# - To support other wallets, use the well-known list of allowed redirect URIs here: https://...TODO.md
#
# Turn on authentication
# MINT_REQUIRE_AUTH=TRUE
# OpenID Connect discovery URL of the authentication provider
# MINT_AUTH_OICD_DISCOVERY_URL=http://localhost:8080/realms/nutshell/.well-known/openid-configuration
# MINT_AUTH_OICD_CLIENT_ID=cashu-client
# Number of authentication attempts allowed per minute per user
# MINT_AUTH_RATE_LIMIT_PER_MINUTE=5
# Maximum number of blind auth tokens per authentication request
# MINT_AUTH_MAX_BLIND_TOKENS=100
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@ jobs:
poetry-version: ${{ matrix.poetry-version }}
mint-database: ${{ matrix.mint-database }}

tests_keycloak_auth:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
python-version: ["3.10"]
poetry-version: ["1.8.5"]
mint-database: ["./test_data/test_mint", "postgres://cashu:cashu@localhost:5432/cashu"]
uses: ./.github/workflows/tests_keycloak_auth.yml
with:
os: ${{ matrix.os }}
python-version: ${{ matrix.python-version }}
poetry-version: ${{ matrix.poetry-version }}
mint-database: ${{ matrix.mint-database }}

regtest:
uses: ./.github/workflows/regtest.yml
strategy:
Expand Down
77 changes: 77 additions & 0 deletions .github/workflows/tests_keycloak_auth.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: tests_keycloak

on:
workflow_call:
inputs:
python-version:
default: "3.10.4"
type: string
poetry-version:
default: "1.8.5"
type: string
mint-database:
default: ""
type: string
os:
default: "ubuntu-latest"
type: string

jobs:
poetry:
name: Auth tests with Keycloak (db ${{ inputs.mint-database }})
runs-on: ${{ inputs.os }}
steps:
- name: Checkout repository
uses: actions/checkout@v2

- name: Prepare environment
uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
poetry-version: ${{ inputs.poetry-version }}

- name: Start PostgreSQL service
if: contains(inputs.mint-database, 'postgres')
run: |
docker run -d --name postgres \
-e POSTGRES_USER=cashu \
-e POSTGRES_PASSWORD=cashu \
-e POSTGRES_DB=cashu \
-p 5432:5432 postgres:16.4
until docker exec postgres pg_isready; do sleep 1; done
- name: Prepare environment
uses: ./.github/actions/prepare
with:
python-version: ${{ inputs.python-version }}
poetry-version: ${{ inputs.poetry-version }}

- name: Start Keycloak with Backup
run: |
docker compose -f tests/keycloak_data/docker-compose-restore.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: Run tests
env:
MINT_BACKEND_BOLT11_SAT: FakeWallet
WALLET_NAME: test_wallet
MINT_HOST: localhost
MINT_PORT: 3337
MINT_TEST_DATABASE: ${{ inputs.mint-database }}
TOR: false
MINT_REQUIRE_AUTH: TRUE
MINT_AUTH_OICD_DISCOVERY_URL: http://localhost:8080/realms/nutshell/.well-known/openid-configuration
MINT_AUTH_OICD_CLIENT_ID: cashu-client
run: |
poetry run pytest tests/test_wallet_auth.py -v --cov=mint --cov-report=xml
- name: Stop and clean up Docker Compose
run: |
docker compose -f tests/keycloak_data/docker-compose-restore.yml down
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
95 changes: 88 additions & 7 deletions cashu/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from dataclasses import dataclass, field
from enum import Enum
from sqlite3 import Row
from typing import Any, Dict, List, Optional, Union
from typing import Any, ClassVar, Dict, List, Optional, Union

import cbor2
from loguru import logger
Expand All @@ -19,7 +19,7 @@
from .crypto.b_dhke import hash_to_curve
from .crypto.keys import (
derive_keys,
derive_keys_sha256,
derive_keys_deprecated_pre_0_15,
derive_keyset_id,
derive_keyset_id_deprecated,
derive_pubkeys,
Expand Down Expand Up @@ -173,6 +173,9 @@ def to_dict(self, include_dleq=False):

return return_dict

def to_base64(self):
return base64.b64encode(cbor2.dumps(self.to_dict(include_dleq=True))).decode()

def to_dict_no_dleq(self):
# dictionary without the fields that don't need to be send to Carol
return dict(id=self.id, amount=self.amount, secret=self.secret, C=self.C)
Expand Down Expand Up @@ -541,6 +544,7 @@ class Unit(Enum):
usd = 2
eur = 3
btc = 4
auth = 999

def str(self, amount: int) -> str:
if self == Unit.sat:
Expand All @@ -553,6 +557,8 @@ def str(self, amount: int) -> str:
return f"{amount/100:.2f} EUR"
elif self == Unit.btc:
return f"{amount/1e8:.8f} BTC"
elif self == Unit.auth:
return f"{amount} AUTH"
else:
raise Exception("Invalid unit")

Expand Down Expand Up @@ -724,6 +730,7 @@ class MintKeyset:
valid_to: Optional[str] = None
first_seen: Optional[str] = None
version: Optional[str] = None
amounts: List[int]

duplicate_keyset_id: Optional[str] = None # BACKWARDS COMPATIBILITY < 0.15.0

Expand All @@ -734,6 +741,7 @@ def __init__(
seed: Optional[str] = None,
encrypted_seed: Optional[str] = None,
seed_encryption_method: Optional[str] = None,
amounts: Optional[List[int]] = None,
valid_from: Optional[str] = None,
valid_to: Optional[str] = None,
first_seen: Optional[str] = None,
Expand Down Expand Up @@ -762,6 +770,12 @@ def __init__(

assert self.seed, "seed not set"

if amounts:
self.amounts = amounts
else:
# use 2^n amounts by default
self.amounts = [2**i for i in range(settings.max_order)]

self.id = id
self.valid_from = valid_from
self.valid_to = valid_to
Expand Down Expand Up @@ -805,6 +819,24 @@ def __init__(

logger.trace(f"Loaded keyset id: {self.id} ({self.unit.name})")

@classmethod
def from_row(cls, row: Row):
return cls(
id=row["id"],
derivation_path=row["derivation_path"],
seed=row["seed"],
encrypted_seed=row["encrypted_seed"],
seed_encryption_method=row["seed_encryption_method"],
valid_from=row["valid_from"],
valid_to=row["valid_to"],
first_seen=row["first_seen"],
active=row["active"],
unit=row["unit"],
version=row["version"],
input_fee_ppk=row["input_fee_ppk"],
amounts=json.loads(row["amounts"]),
)

@property
def public_keys_hex(self) -> Dict[int, str]:
assert self.public_keys, "public keys not set"
Expand All @@ -830,23 +862,27 @@ def generate_keys(self):
self.private_keys = derive_keys_backwards_compatible_insecure_pre_0_12(
self.seed, self.derivation_path
)
self.public_keys = derive_pubkeys(self.private_keys) # type: ignore
self.public_keys = derive_pubkeys(self.private_keys, self.amounts) # type: ignore
logger.trace(
f"WARNING: Using weak key derivation for keyset {self.id} (backwards"
" compatibility < 0.12)"
)
self.id = id_in_db or derive_keyset_id_deprecated(self.public_keys) # type: ignore
elif self.version_tuple < (0, 15):
self.private_keys = derive_keys_sha256(self.seed, self.derivation_path)
self.private_keys = derive_keys_deprecated_pre_0_15(
self.seed, self.amounts, self.derivation_path
)
logger.trace(
f"WARNING: Using non-bip32 derivation for keyset {self.id} (backwards"
" compatibility < 0.15)"
)
self.public_keys = derive_pubkeys(self.private_keys) # type: ignore
self.public_keys = derive_pubkeys(self.private_keys, self.amounts) # type: ignore
self.id = id_in_db or derive_keyset_id_deprecated(self.public_keys) # type: ignore
else:
self.private_keys = derive_keys(self.seed, self.derivation_path)
self.public_keys = derive_pubkeys(self.private_keys) # type: ignore
self.private_keys = derive_keys(
self.seed, self.derivation_path, self.amounts
)
self.public_keys = derive_pubkeys(self.private_keys, self.amounts) # type: ignore
self.id = id_in_db or derive_keyset_id(self.public_keys) # type: ignore


Expand Down Expand Up @@ -1254,3 +1290,48 @@ def parse_obj(cls, token_dict: dict):
t=[TokenV4Token(**t) for t in token_dict["t"]],
d=token_dict.get("d", None),
)


class AuthProof(BaseModel):
"""
Blind authentication token
"""

id: str
secret: str # secret
C: str # signature
amount: int = 1 # default amount

prefix: ClassVar[str] = "authA"

@classmethod
def from_proof(cls, proof: Proof):
return cls(id=proof.id, secret=proof.secret, C=proof.C)

def to_base64(self):
serialize_dict = self.dict()
serialize_dict.pop("amount", None)
return (
self.prefix + base64.b64encode(json.dumps(serialize_dict).encode()).decode()
)

@classmethod
def from_base64(cls, base64_str: str):
assert base64_str.startswith(cls.prefix), Exception(
f"Token prefix not valid. Expected {cls.prefix}."
)
base64_str = base64_str[len(cls.prefix) :]
return cls.parse_obj(json.loads(base64.b64decode(base64_str).decode()))

def to_proof(self):
return Proof(id=self.id, secret=self.secret, C=self.C, amount=self.amount)


class WalletMint(BaseModel):
url: str
info: str
updated: Optional[str] = None
access_token: Optional[str] = None
refresh_token: Optional[str] = None
username: Optional[str] = None
password: Optional[str] = None
30 changes: 16 additions & 14 deletions cashu/core/crypto/keys.py
Original file line number Diff line number Diff line change
@@ -1,54 +1,56 @@
import base64
import hashlib
import random
from typing import Dict
from typing import Dict, List

from bip32 import BIP32

from ..settings import settings
from .secp import PrivateKey, PublicKey


def derive_keys(mnemonic: str, derivation_path: str):
def derive_keys(mnemonic: str, derivation_path: str, amounts: List[int]):
"""
Deterministic derivation of keys for 2^n values.
"""
bip32 = BIP32.from_seed(mnemonic.encode())
orders_str = [f"/{i}'" for i in range(settings.max_order)]
orders_str = [f"/{a}'" for a in range(len(amounts))]
return {
2**i: PrivateKey(
a: PrivateKey(
bip32.get_privkey_from_path(derivation_path + orders_str[i]),
raw=True,
)
for i in range(settings.max_order)
for i, a in enumerate(amounts)
}


def derive_keys_sha256(seed: str, derivation_path: str = ""):
def derive_keys_deprecated_pre_0_15(
seed: str, amounts: List[int], derivation_path: str = ""
):
"""
Deterministic derivation of keys for 2^n values.
TODO: Implement BIP32.
"""
return {
2**i: PrivateKey(
a: PrivateKey(
hashlib.sha256((seed + derivation_path + str(i)).encode("utf-8")).digest()[
:32
],
raw=True,
)
for i in range(settings.max_order)
for i, a in enumerate(amounts)
}


def derive_pubkey(seed: str):
return PrivateKey(
def derive_pubkey(seed: str) -> PublicKey:
pubkey = PrivateKey(
hashlib.sha256((seed).encode("utf-8")).digest()[:32],
raw=True,
).pubkey
assert pubkey
return pubkey


def derive_pubkeys(keys: Dict[int, PrivateKey]):
return {amt: keys[amt].pubkey for amt in [2**i for i in range(settings.max_order)]}
def derive_pubkeys(keys: Dict[int, PrivateKey], amounts: List[int]):
return {amt: keys[amt].pubkey for amt in amounts}


def derive_keyset_id(keys: Dict[int, PublicKey]):
Expand Down
Loading

0 comments on commit a0ef44d

Please sign in to comment.