Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c01459f
feat(crypto): introduce backward compatible keyset v3 (prefix 02) wit…
a1denvalu3 May 5, 2026
c894cf3
fix(crypto): replace keyset v3 prefix checks with general >= 02 logic
a1denvalu3 May 5, 2026
cfc8a39
style(crypto): hoist imports to module level
a1denvalu3 May 5, 2026
f358fe4
fix(crypto): fix undefined imports in wallet
a1denvalu3 May 5, 2026
775e9dd
feat(crypto): implement BLS12-381 multiplicative blinding and pairing…
a1denvalu3 May 5, 2026
5813113
test(crypto): add BLS12-381 test suite
a1denvalu3 May 5, 2026
1500b29
test(crypto): add deterministic hash_to_curve tests for BLS12-381
a1denvalu3 May 5, 2026
0c63c63
style(crypto): clean up _G2_HEX usage in bls_dhke.py
a1denvalu3 May 5, 2026
8065148
format
a1denvalu3 May 10, 2026
f1f608b
refactor(crypto): introduce AnyPublicKey/AnyPrivateKey and improve ty…
a1denvalu3 May 10, 2026
99f89b9
feat(crypto): implement key interface hierarchy (ABC-based)
a1denvalu3 May 11, 2026
82bb52b
fix(wallet): resolve some mypy errors in wallet.py
a1denvalu3 May 11, 2026
840b72c
fix(crypto): continue refactor of imports and instantiations to resol…
a1denvalu3 May 11, 2026
c9abea6
feat(crypto): resolve majority of mypy errors in crypto refactor
a1denvalu3 May 11, 2026
a43b845
feat(crypto): final interface hierarchy adjustment for wallet/crypto
a1denvalu3 May 11, 2026
ae384f5
Ralph iteration 1: work in progress
a1denvalu3 May 11, 2026
dd81a66
Ralph iteration 2: work in progress
a1denvalu3 May 11, 2026
26d7103
Ralph iteration 3: work in progress
a1denvalu3 May 11, 2026
a89c228
Ralph iteration 4: work in progress
a1denvalu3 May 11, 2026
d73f031
Ralph iteration 1: work in progress
a1denvalu3 May 11, 2026
3ec3f7c
fix mypy issues
a1denvalu3 May 11, 2026
8209e08
fixes
a1denvalu3 May 11, 2026
6835206
format fixes
a1denvalu3 May 11, 2026
733c1f0
fix tests
a1denvalu3 May 11, 2026
92a1cb6
delete accidentally committed ralph conf
a1denvalu3 May 11, 2026
e327d63
fix v3 Proof.Y hash-to-curve and BlindedSignature dleq=None
robwoodgate May 13, 2026
0e8c93f
fix v3 Proof.Y hash-to-curve and BlindedSignature dleq=None
robwoodgate May 13, 2026
3880724
Merge branch 'fix/v3-y-and-dleq-null' of github.com:robwoodgate/nutsh…
a1denvalu3 May 13, 2026
104789e
concise statement
a1denvalu3 May 13, 2026
5efea39
Merge pull request #1003 from robwoodgate/fix/v3-y-and-dleq-null
a1denvalu3 May 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,6 @@ tor-data
*.pem

junit.xml

# Ralph
.ralph
53 changes: 44 additions & 9 deletions cashu/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,21 @@
from ..mint.events.event_model import LedgerEvent
from .crypto.aes import AESCipher
from .crypto.b_dhke import hash_to_curve
from .crypto.bls import PublicKey as BlsPublicKey
from .crypto.bls_dhke import hash_to_curve as bls_hash_to_curve
from .crypto.interfaces import PrivateKey, PublicKey
from .crypto.keys import (
derive_keys,
derive_keys_deprecated_pre_0_15,
derive_keys_v3,
derive_keyset_id,
derive_keyset_id_deprecated,
derive_keyset_id_v2,
derive_keyset_id_v3,
derive_pubkeys,
is_bls_keyset,
)
from .crypto.secp import PrivateKey, PublicKey
from .crypto.secp import SecpPublicKey
from .legacy import derive_keys_backwards_compatible_insecure_pre_0_12
from .settings import settings

Expand Down Expand Up @@ -146,7 +152,13 @@ class Proof(BaseModel):

def __init__(self, **data):
super().__init__(**data)
self.Y = hash_to_curve(self.secret.encode("utf-8")).format().hex()
# v3 (BLS12-381) keysets compute Y on G1; v0/v1/v2 keep secp256k1 hash-to-curve.
# Y is used purely as a lookup index for proofs_used / checkstate / subscriptions,
# so the wallet must compute the same hex for the same (secret, keyset_version).
if self.id and is_bls_keyset(self.id):
self.Y = bls_hash_to_curve(self.secret.encode("utf-8")).format().hex()
else:
self.Y = hash_to_curve(self.secret.encode("utf-8")).format().hex()

@classmethod
def from_dict(cls, proof_dict: dict):
Expand Down Expand Up @@ -266,11 +278,16 @@ class BlindedSignature(BaseModel):

@classmethod
def from_row(cls, row: Row):
# v3 (BLS) promises store dleq_e / dleq_s as NULL because pairings replace DLEQ.
# Match the model's `dleq: Optional[DLEQ] = None` declaration and skip construction.
dleq_e = row["dleq_e"]
dleq_s = row["dleq_s"]
dleq = DLEQ(e=dleq_e, s=dleq_s) if None not in (dleq_e, dleq_s) else None
return cls(
id=row["id"],
amount=row["amount"],
C_=row["c_"],
dleq=DLEQ(e=row["dleq_e"], s=row["dleq_s"]),
dleq=dleq,
)


Expand Down Expand Up @@ -759,12 +776,17 @@ def serialize(self):
)

@classmethod
def from_row(cls, row: Row):
def from_row(cls, row: RowMapping):
def deserialize(serialized: str) -> Dict[int, PublicKey]:
return {
int(amount): PublicKey(bytes.fromhex(hex_key))
for amount, hex_key in dict(json.loads(serialized)).items()
}

is_v3 = is_bls_keyset(row["id"])
pub_keys: Dict[int, PublicKey] = {}
for amount, hex_key in dict(json.loads(serialized)).items():
if is_v3:
pub_keys[int(amount)] = BlsPublicKey(bytes.fromhex(hex_key), group="G2") # type: ignore
else:
pub_keys[int(amount)] = SecpPublicKey(bytes.fromhex(hex_key)) # type: ignore
return pub_keys

return cls(
id=row["id"],
Expand Down Expand Up @@ -974,7 +996,7 @@ def generate_keys(self):
assert self.public_keys is not None
self.id = derive_keyset_id(self.public_keys)
logger.info(f"Generated keyset v1 ID: {self.id}")
else:
elif self.version_tuple < (0, 21):
self.private_keys = derive_keys(
self.seed, self.derivation_path, self.amounts
)
Expand All @@ -988,6 +1010,19 @@ def generate_keys(self):
assert self.public_keys is not None
self.id = derive_keyset_id_v2(self.public_keys, self.unit.name, self.final_expiry, self.input_fee_ppk)
logger.info(f"Generated keyset v2 ID: {self.id}")
else:
self.private_keys = derive_keys_v3(
self.seed, self.derivation_path, self.amounts
) # type: ignore
self.public_keys = derive_pubkeys(self.private_keys, self.amounts) # type: ignore

# KEYSETS V3: BLS12-381 cryptography
if id_in_db:
self.id = id_in_db
else:
assert self.public_keys is not None
self.id = derive_keyset_id_v3(self.public_keys, self.unit.name, self.final_expiry, self.input_fee_ppk)
logger.info(f"Generated keyset v3 (BLS) ID: {self.id}")


# ------- TOKEN -------
Expand Down
97 changes: 59 additions & 38 deletions cashu/core/crypto/b_dhke.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,22 @@
"""

import hashlib
from typing import Optional, Tuple
from typing import Optional, Tuple, Union, cast

from .secp import PrivateKey, PublicKey
from .bls import PrivateKey as BlsPrivateKey
from .bls import PublicKey as BlsPublicKey
from .secp import SecpPrivateKey, SecpPublicKey

PublicKey = Union[SecpPublicKey, BlsPublicKey]
PrivateKey = Union[SecpPrivateKey, BlsPrivateKey]


# Remove the import from bls_dhke as it's causing redefinition
# from .bls_dhke import hash_to_curve
DOMAIN_SEPARATOR = b"Secp256k1_HashToCurve_Cashu_"


def hash_to_curve(message: bytes) -> PublicKey:
def hash_to_curve(message: bytes) -> SecpPublicKey:
"""Generates a secp256k1 point from a message.

The point is generated by hashing the message with a domain separator and then
Expand All @@ -78,45 +86,51 @@ def hash_to_curve(message: bytes) -> PublicKey:
_hash = hashlib.sha256(msg_to_hash + counter.to_bytes(4, "little")).digest()
try:
# will error if point does not lie on curve
return PublicKey(b"\x02" + _hash)
return SecpPublicKey(b"\x02" + _hash)
except Exception:
counter += 1
# it should never reach this point
raise ValueError("No valid point found")


def step1_alice(
secret_msg: str, blinding_factor: Optional[PrivateKey] = None
) -> tuple[PublicKey, PrivateKey]:
Y: PublicKey = hash_to_curve(secret_msg.encode("utf-8"))
r = blinding_factor or PrivateKey()
B_: PublicKey = Y + r.public_key # type: ignore
return B_, r
def step1_alice(secret_msg: str, blinding_factor: Optional[SecpPrivateKey] = None) -> Tuple[SecpPublicKey, SecpPrivateKey]:
Y: SecpPublicKey = hash_to_curve(secret_msg.encode("utf-8")) # type: ignore
r = blinding_factor or SecpPrivateKey()
B_: SecpPublicKey = Y + r.public_key # type: ignore
return B_, r # type: ignore


def step2_bob(B_: PublicKey, a: PrivateKey) -> Tuple[PublicKey, PrivateKey, PrivateKey]:
C_: PublicKey = B_ * a # type: ignore
def step2_bob(B_: SecpPublicKey, a: SecpPrivateKey) -> Tuple[SecpPublicKey, SecpPrivateKey, SecpPrivateKey]:
C_: SecpPublicKey = B_ * a # type: ignore
# produce dleq proof
e, s = step2_bob_dleq(B_, a)
return C_, e, s
return C_, SecpPrivateKey(bytes.fromhex(e.to_hex())), SecpPrivateKey(bytes.fromhex(s.to_hex()))


def step3_alice(C_: PublicKey, r: PrivateKey, A: PublicKey) -> PublicKey:
C: PublicKey = C_ - A * r # type: ignore
def step3_alice(
C_: Union[SecpPublicKey, BlsPublicKey],
r: Union[SecpPrivateKey, BlsPrivateKey],
A: Union[SecpPublicKey, BlsPublicKey],
) -> Union[SecpPublicKey, BlsPublicKey]:
C = C_ - A * r
return C


def verify(a: PrivateKey, C: PublicKey, secret_msg: str) -> bool:
Y: PublicKey = hash_to_curve(secret_msg.encode("utf-8"))
def verify(
a: Union[SecpPrivateKey, BlsPrivateKey],
C: Union[SecpPublicKey, BlsPublicKey],
secret_msg: str,
) -> bool:
Y: Union[SecpPublicKey, BlsPublicKey] = hash_to_curve(secret_msg.encode("utf-8")) # type: ignore
valid = C == Y * a # type: ignore
# BEGIN: BACKWARDS COMPATIBILITY < 0.15.1
if not valid:
valid = verify_deprecated(a, C, secret_msg)
valid = verify_deprecated(a, C, secret_msg) # type: ignore
# END: BACKWARDS COMPATIBILITY < 0.15.1
return valid


def hash_e(*publickeys: PublicKey) -> bytes:
def hash_e(*publickeys: Union[SecpPublicKey, BlsPublicKey]) -> bytes:
e_ = ""
for p in publickeys:
_p = p.format(compressed=False).hex()
Expand All @@ -126,35 +140,42 @@ def hash_e(*publickeys: PublicKey) -> bytes:


def step2_bob_dleq(
B_: PublicKey, a: PrivateKey, p_bytes: bytes = b""
) -> Tuple[PrivateKey, PrivateKey]:
B_: SecpPublicKey,
a: SecpPrivateKey,
p_bytes: bytes = b"",
) -> Tuple[Union[SecpPrivateKey, BlsPrivateKey], Union[SecpPrivateKey, BlsPrivateKey]]:

if p_bytes:
# deterministic p for testing
p = PrivateKey(p_bytes)
p = SecpPrivateKey(p_bytes)
else:
# normally, we generate a random p
p = PrivateKey()
p = SecpPrivateKey()


R1 = p.public_key # R1 = pG
R1 = SecpPublicKey(p.public_key.format()) # R1 = pG
assert R1
R2: PublicKey = B_ * p # type: ignore
C_: PublicKey = B_ * a # type: ignore
A = a.public_key
R2: PublicKey = cast(PublicKey, B_ * p) # type: ignore
C_: PublicKey = cast(PublicKey, B_ * a) # type: ignore
A = cast(PublicKey, a.public_key)
assert A
e = hash_e(R1, R2, A, C_) # e = hash(R1, R2, A, C_)
s = p.add(bytes.fromhex(a.multiply(e).to_hex())) # s = p + ek
spk = PrivateKey(bytes.fromhex(s.to_hex()))
epk = PrivateKey(e)
spk = SecpPrivateKey(bytes.fromhex(s.to_hex()))
epk = SecpPrivateKey(e)
return epk, spk


def alice_verify_dleq(
B_: PublicKey, C_: PublicKey, e: PrivateKey, s: PrivateKey, A: PublicKey
) -> bool:
R1 = s.public_key - A * e # type: ignore
R2 = B_ * s - C_ * e # type: ignore
s_pub = SecpPublicKey(s.public_key.format())
A_secp = SecpPublicKey(A.format())
B_secp = SecpPublicKey(B_.format())
C_secp = SecpPublicKey(C_.format())
R1 = s_pub - A_secp * e # type: ignore
R2 = B_secp * s - C_secp * e # type: ignore
e_bytes = bytes.fromhex(e.to_hex())
return e_bytes == hash_e(R1, R2, A, C_)
return e_bytes == hash_e(R1, R2, A_secp, C_secp)


def carol_verify_dleq(
Expand All @@ -179,7 +200,7 @@ def carol_verify_dleq(
# -------- Deprecated hash_to_curve before 0.15.0 --------


def hash_to_curve_deprecated(message: bytes) -> PublicKey:
def hash_to_curve_deprecated(message: bytes) -> SecpPublicKey:
"""Generates a point from the message hash and checks if the point lies on the curve.
If it does not, iteratively tries to compute a new point from the hash."""
point = None
Expand All @@ -188,7 +209,7 @@ def hash_to_curve_deprecated(message: bytes) -> PublicKey:
_hash = hashlib.sha256(msg_to_hash).digest()
try:
# will error if point does not lie on curve
point = PublicKey(b"\x02" + _hash)
point = SecpPublicKey(b"\x02" + _hash)
except Exception:
msg_to_hash = _hash
return point
Expand All @@ -198,12 +219,12 @@ def step1_alice_deprecated(
secret_msg: str, blinding_factor: Optional[PrivateKey] = None
) -> tuple[PublicKey, PrivateKey]:
Y: PublicKey = hash_to_curve_deprecated(secret_msg.encode("utf-8"))
r = blinding_factor or PrivateKey()
r = blinding_factor or SecpPrivateKey()
B_: PublicKey = Y + r.public_key # type: ignore
return B_, r


def verify_deprecated(a: PrivateKey, C: PublicKey, secret_msg: str) -> bool:
def verify_deprecated(a: SecpPrivateKey, C: SecpPublicKey, secret_msg: str) -> bool:
Y: PublicKey = hash_to_curve_deprecated(secret_msg.encode("utf-8"))
valid = C == Y * a # type: ignore
return valid
Expand Down
71 changes: 71 additions & 0 deletions cashu/core/crypto/bls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import os
from typing import Optional

import pyblst

from .interfaces import ICashuPrivateKey, ICashuPublicKey

curve_order = 52435875175126190479447740508185965837690552500527637822603658699938581184513
_G2_HEX = '93e02b6052719f607dacd3a088274f65596bd0d09920b61ab5da61bbdc7f5049334cf11213945d57e5ac7d055d042b7e024aa2b2f08f0a91260805272dc51051c6e47ad4fa403b02b4510b647ae3d1770bac0326a805bbefd48056c8c121bdb8'



class PrivateKey(ICashuPrivateKey):
def __init__(self, privkey: bytes = b"", scalar: Optional[int] = None):
if scalar is not None:
self.scalar = scalar % curve_order
elif privkey:
self.scalar = int.from_bytes(privkey, "big") % curve_order
else:
self.scalar = int.from_bytes(os.urandom(32), "big") % curve_order

@property
def private_key(self) -> bytes:
return self.scalar.to_bytes(32, "big")

def to_hex(self) -> str:
return self.private_key.hex()

def get_g2_public_key(self) -> "PublicKey":
pt = pyblst.BlstP2Element().uncompress(bytes.fromhex(_G2_HEX)).scalar_mul(self.scalar)
return PublicKey(point=pt, group="G2")

@property
def public_key(self) -> "PublicKey":
return self.get_g2_public_key()


class PublicKey(ICashuPublicKey):
def __init__(self, compressed: bytes = b"", point=None, group="G1"):
self.group = group
try:
if point is not None:
self.point = point
elif compressed:
if self.group == "G1":
self.point = pyblst.BlstP1Element().uncompress(compressed)
else:
self.point = pyblst.BlstP2Element().uncompress(compressed)
else:
raise ValueError("Must provide point or compressed bytes")
except Exception as e:
print(f"Exception parsing BLS public key (group={self.group}, compressed={compressed.hex() if compressed else ''}):", repr(e))
raise ValueError("The public key could not be parsed or is invalid.")

def format(self, compressed: bool = True) -> bytes:
return self.point.compress()

def serialize(self) -> bytes:
return self.format()

def __eq__(self, other):
if isinstance(other, PublicKey):
return self.point == other.point
return False

def __mul__(self, scalar):
if isinstance(scalar, PrivateKey):
return PublicKey(point=self.point.scalar_mul(scalar.scalar), group=self.group)
elif isinstance(scalar, int):
return PublicKey(point=self.point.scalar_mul(scalar), group=self.group)
raise TypeError("Can't multiply with non-scalar")
Loading