From 6282e0a22a4d0db8c202ab140ebd74d96b9a4c3c Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 23 Sep 2023 19:06:37 +0200 Subject: [PATCH] [Wallet/Mint] DLEQ proofs (#175) * produce dleq * start working on verification * wip dleq * Use C_ instead of C in verify DLEQ! (#176) * Fix comments (DLEQ sign error) * Fix alice_verify_dleq in d_dhke.py * Fix_generate_promise in ledger.py * Fix verify_proofs_dleq in wallet.py * Fix: invalid public key (#182) * Use C_ instead of C in verify DLEQ! * Fix comments (DLEQ sign error) * Fix alice_verify_dleq in d_dhke.py * Fix_generate_promise in ledger.py * Fix verify_proofs_dleq in wallet.py * Fix: invalid public key * Exception: Mint Error: invalid public key * Update cashu/wallet/wallet.py --------- Co-authored-by: calle <93376500+callebtc@users.noreply.github.com> * Update cashu/core/b_dhke.py * Update tests/test_cli.py * verify all constructed proofs * dleq upon receive * serialize without dleq * all tests passing * make format * remove print * remove debug * option to send with dleq * add tests * fix test * deterministic p in step2_dleq and fix mypy error for hash_to_curve * test crypto/hash_e and crypto/step2_bob_dleq * rename A to K in b_dhke.py and test_alice_verify_dleq * rename tests * make format * store dleq in mint db (and readd balance view) * remove `r` from dleq in tests * add pending output * make format * works with pre-dleq mints * fix comments * make format * fix some tests * fix last test * test serialize dleq fix * flake * flake * keyset.id must be str * fix test decorators * start removing the duplicate fields from the dleq * format * remove print * cleanup * add type anotations to dleq functions * remove unnecessary fields from BlindedSignature * tests not working yet * spelling mistakes * spelling mistakes * fix more spelling mistakes * revert to normal * add comments * bdhke: generalize hash_e * remove P2PKSecret changes * revert tests for P2PKSecret * revert tests * revert test fully * revert p2pksecret changes * refactor proof invalidation * store dleq proofs in wallet db * make mypy happy --------- Co-authored-by: moonsettler --- cashu/core/base.py | 64 ++++++-- cashu/core/crypto/b_dhke.py | 86 ++++++++++- cashu/core/script.py | 6 +- cashu/mint/crud.py | 13 +- cashu/mint/ledger.py | 17 ++- cashu/mint/migrations.py | 12 ++ cashu/wallet/api/router.py | 10 +- cashu/wallet/cli/cli.py | 27 +++- cashu/wallet/crud.py | 48 +++--- cashu/wallet/helpers.py | 13 +- cashu/wallet/migrations.py | 7 + cashu/wallet/nostr.py | 4 +- cashu/wallet/p2pk.py | 4 +- cashu/wallet/wallet.py | 290 ++++++++++++++++++++---------------- tests/test_cli.py | 45 +++++- tests/test_core.py | 68 ++++++++- tests/test_crypto.py | 201 ++++++++++++++++++++++++- tests/test_mint.py | 6 + tests/test_wallet.py | 1 + 19 files changed, 717 insertions(+), 205 deletions(-) diff --git a/cashu/core/base.py b/cashu/core/base.py index 7978d962..d0b465e7 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -11,6 +11,26 @@ from .legacy import derive_keys_backwards_compatible_insecure_pre_0_12 from .p2pk import P2SHScript + +class DLEQ(BaseModel): + """ + Discrete Log Equality (DLEQ) Proof + """ + + e: str + s: str + + +class DLEQWallet(BaseModel): + """ + Discrete Log Equality (DLEQ) Proof + """ + + e: str + s: str + r: str # blinding_factor, unknown to mint but sent from wallet to wallet for DLEQ proof + + # ------- PROOFS ------- @@ -24,6 +44,8 @@ class Proof(BaseModel): amount: int = 0 secret: str = "" # secret or message to be blinded and signed C: str = "" # signature on secret, unblinded by wallet + dleq: Union[DLEQWallet, None] = None # DLEQ proof + p2pksigs: Union[List[str], None] = [] # P2PK signature p2shscript: Union[P2SHScript, None] = None # P2SH spending condition # whether this proof is reserved for sending, used for coin management in the wallet @@ -34,7 +56,28 @@ class Proof(BaseModel): time_reserved: Union[None, str] = "" derivation_path: Union[None, str] = "" # derivation path of the proof - def to_dict(self): + @classmethod + def from_dict(cls, proof_dict: dict): + if proof_dict.get("dleq"): + proof_dict["dleq"] = DLEQWallet(**json.loads(proof_dict["dleq"])) + c = cls(**proof_dict) + return c + + def to_dict(self, include_dleq=False): + # dictionary without the fields that don't need to be send to Carol + if not include_dleq: + return dict(id=self.id, amount=self.amount, secret=self.secret, C=self.C) + + assert self.dleq, "DLEQ proof is missing" + return dict( + id=self.id, + amount=self.amount, + secret=self.secret, + C=self.C, + dleq=self.dleq.dict(), + ) + + 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) @@ -69,9 +112,10 @@ class BlindedSignature(BaseModel): Blinded signature or "promise" which is the signature on a `BlindedMessage` """ - id: Union[str, None] = None + id: str amount: int C_: str # Hex-encoded signature + dleq: Optional[DLEQ] = None # DLEQ proof class BlindedMessages(BaseModel): @@ -296,7 +340,7 @@ class MintKeyset: Contains the keyset from the mint's perspective. """ - id: Union[str, None] + id: str derivation_path: str private_keys: Dict[int, PrivateKey] public_keys: Union[Dict[int, PublicKey], None] = None @@ -308,7 +352,7 @@ class MintKeyset: def __init__( self, - id=None, + id="", valid_from=None, valid_to=None, first_seen=None, @@ -411,8 +455,8 @@ class TokenV3Token(BaseModel): mint: Optional[str] = None proofs: List[Proof] - def to_dict(self): - return_dict = dict(proofs=[p.to_dict() for p in self.proofs]) + def to_dict(self, include_dleq=False): + return_dict = dict(proofs=[p.to_dict(include_dleq) for p in self.proofs]) if self.mint: return_dict.update(dict(mint=self.mint)) # type: ignore return return_dict @@ -426,8 +470,8 @@ class TokenV3(BaseModel): token: List[TokenV3Token] = [] memo: Optional[str] = None - def to_dict(self): - return_dict = dict(token=[t.to_dict() for t in self.token]) + def to_dict(self, include_dleq=False): + return_dict = dict(token=[t.to_dict(include_dleq) for t in self.token]) if self.memo: return_dict.update(dict(memo=self.memo)) # type: ignore return return_dict @@ -454,7 +498,7 @@ def deserialize(cls, tokenv3_serialized: str) -> "TokenV3": token = json.loads(base64.urlsafe_b64decode(token_base64)) return cls.parse_obj(token) - def serialize(self) -> str: + def serialize(self, include_dleq=False) -> str: """ Takes a TokenV3 and serializes it as "cashuA. """ @@ -462,6 +506,6 @@ def serialize(self) -> str: tokenv3_serialized = prefix # encode the token as a base64 string tokenv3_serialized += base64.urlsafe_b64encode( - json.dumps(self.to_dict()).encode() + json.dumps(self.to_dict(include_dleq)).encode() ).decode() return tokenv3_serialized diff --git a/cashu/core/crypto/b_dhke.py b/cashu/core/crypto/b_dhke.py index bac6355d..e8706239 100644 --- a/cashu/core/crypto/b_dhke.py +++ b/cashu/core/crypto/b_dhke.py @@ -28,22 +28,43 @@ Y = hash_to_curve(secret_message) C == a*Y If true, C must have originated from Bob + + +# DLEQ Proof + +(These steps occur once Bob returns C') + +Bob: +r = random nonce +R1 = r*G +R2 = r*B' +e = hash(R1,R2,A,C') +s = r + e*a +return e, s + +Alice: +R1 = s*G - e*A +R2 = s*B' - e*C' +e == hash(R1,R2,A,C') + +If true, a in A = a*G must be equal to a in C' = a*B' """ import hashlib -from typing import Optional +from typing import Optional, Tuple from secp256k1 import PrivateKey, PublicKey def hash_to_curve(message: bytes) -> PublicKey: """Generates a point from the message hash and checks if the point lies on the curve. - If it does not, it tries computing a new point from the hash.""" + If it does not, iteratively tries to compute a new point from the hash.""" point = None msg_to_hash = message while point is None: _hash = hashlib.sha256(msg_to_hash).digest() try: + # will error if point does not lie on curve point = PublicKey(b"\x02" + _hash, raw=True) except Exception: msg_to_hash = _hash @@ -59,9 +80,11 @@ def step1_alice( return B_, r -def step2_bob(B_: PublicKey, a: PrivateKey) -> PublicKey: +def step2_bob(B_: PublicKey, a: PrivateKey) -> Tuple[PublicKey, PrivateKey, PrivateKey]: C_: PublicKey = B_.mult(a) # type: ignore - return C_ + # produce dleq proof + e, s = step2_bob_dleq(B_, a) + return C_, e, s def step3_alice(C_: PublicKey, r: PrivateKey, A: PublicKey) -> PublicKey: @@ -74,6 +97,61 @@ def verify(a: PrivateKey, C: PublicKey, secret_msg: str) -> bool: return C == Y.mult(a) # type: ignore +def hash_e(*publickeys: PublicKey) -> bytes: + e_ = "" + for p in publickeys: + _p = p.serialize(compressed=False).hex() + e_ += str(_p) + e = hashlib.sha256(e_.encode("utf-8")).digest() + return e + + +def step2_bob_dleq( + B_: PublicKey, a: PrivateKey, p_bytes: bytes = b"" +) -> Tuple[PrivateKey, PrivateKey]: + if p_bytes: + # deterministic p for testing + p = PrivateKey(privkey=p_bytes, raw=True) + else: + # normally, we generate a random p + p = PrivateKey() + + R1 = p.pubkey # R1 = pG + assert R1 + R2: PublicKey = B_.mult(p) # R2 = pB_ # type: ignore + C_: PublicKey = B_.mult(a) # C_ = aB_ # type: ignore + A = a.pubkey + assert A + e = hash_e(R1, R2, A, C_) # e = hash(R1, R2, A, C_) + s = p.tweak_add(a.tweak_mul(e)) # s = p + ek + spk = PrivateKey(s, raw=True) + epk = PrivateKey(e, raw=True) + return epk, spk + + +def alice_verify_dleq( + B_: PublicKey, C_: PublicKey, e: PrivateKey, s: PrivateKey, A: PublicKey +) -> bool: + R1 = s.pubkey - A.mult(e) # type: ignore + R2 = B_.mult(s) - C_.mult(e) # type: ignore + e_bytes = e.private_key + return e_bytes == hash_e(R1, R2, A, C_) + + +def carol_verify_dleq( + secret_msg: str, + r: PrivateKey, + C: PublicKey, + e: PrivateKey, + s: PrivateKey, + A: PublicKey, +) -> bool: + Y: PublicKey = hash_to_curve(secret_msg.encode("utf-8")) + C_: PublicKey = C + A.mult(r) # type: ignore + B_: PublicKey = Y + r.pubkey # type: ignore + return alice_verify_dleq(B_, C_, e, s, A) + + # Below is a test of a simple positive and negative case # # Alice's keys diff --git a/cashu/core/script.py b/cashu/core/script.py index 0fc2a8ea..19827090 100644 --- a/cashu/core/script.py +++ b/cashu/core/script.py @@ -25,7 +25,7 @@ def step0_carol_privkey(): return seckey -def step0_carol_checksig_redeemscrip(carol_pubkey): +def step0_carol_checksig_redeemscript(carol_pubkey): """Create script""" txin_redeemScript = CScript([carol_pubkey, OP_CHECKSIG]) # txin_redeemScript = CScript([-123, OP_CHECKLOCKTIMEVERIFY]) @@ -111,7 +111,7 @@ def verify_bitcoin_script(txin_redeemScript_b64, txin_signature_b64): # --------- # CAROL defines scripthash and ALICE mints them alice_privkey = step0_carol_privkey() - txin_redeemScript = step0_carol_checksig_redeemscrip(alice_privkey.pub) + txin_redeemScript = step0_carol_checksig_redeemscript(alice_privkey.pub) print("Script:", txin_redeemScript.__repr__()) txin_p2sh_address = step1_carol_create_p2sh_address(txin_redeemScript) print(f"Carol sends Alice secret = P2SH:{txin_p2sh_address}") @@ -128,7 +128,7 @@ def verify_bitcoin_script(txin_redeemScript_b64, txin_signature_b64): # CAROL redeems with MINT # CAROL PRODUCES txin_redeemScript and txin_signature to send to MINT - txin_redeemScript = step0_carol_checksig_redeemscrip(alice_privkey.pub) + txin_redeemScript = step0_carol_checksig_redeemscript(alice_privkey.pub) txin_signature = step2_carol_sign_tx(txin_redeemScript, alice_privkey).scriptSig txin_redeemScript_b64 = base64.urlsafe_b64encode(txin_redeemScript).decode() diff --git a/cashu/mint/crud.py b/cashu/mint/crud.py index d15ab222..5759ccfc 100644 --- a/cashu/mint/crud.py +++ b/cashu/mint/crud.py @@ -49,23 +49,28 @@ async def update_lightning_invoice(*args, **kwags): async def store_promise( + *, db: Database, amount: int, B_: str, C_: str, id: str, + e: str = "", + s: str = "", conn: Optional[Connection] = None, ): await (conn or db).execute( f""" INSERT INTO {table_with_schema(db, 'promises')} - (amount, B_b, C_b, id) - VALUES (?, ?, ?, ?) + (amount, B_b, C_b, e, s, id) + VALUES (?, ?, ?, ?, ?, ?) """, ( amount, - str(B_), - str(C_), + B_, + C_, + e, + s, id, ), ) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index d508029b..19899568 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -7,6 +7,7 @@ from ..core import bolt11 from ..core.base import ( + DLEQ, BlindedMessage, BlindedSignature, Invoice, @@ -117,8 +118,7 @@ async def load_keyset(self, derivation_path, autosave=True): logger.trace(f"crud: stored new keyset {keyset.id}.") # store the new keyset in the current keysets - if keyset.id: - self.keysets.keysets[keyset.id] = keyset + self.keysets.keysets[keyset.id] = keyset logger.debug(f"Loaded keyset {keyset.id}.") return keyset @@ -188,17 +188,24 @@ async def _generate_promise( keyset = keyset if keyset else self.keyset logger.trace(f"Generating promise with keyset {keyset.id}.") private_key_amount = keyset.private_keys[amount] - C_ = b_dhke.step2_bob(B_, private_key_amount) + C_, e, s = b_dhke.step2_bob(B_, private_key_amount) logger.trace(f"crud: _generate_promise storing promise for {amount}") await self.crud.store_promise( amount=amount, B_=B_.serialize().hex(), C_=C_.serialize().hex(), - id=keyset.id, + e=e.serialize(), + s=s.serialize(), db=self.db, + id=keyset.id, ) logger.trace(f"crud: _generate_promise stored promise for {amount}") - return BlindedSignature(id=keyset.id, amount=amount, C_=C_.serialize().hex()) + return BlindedSignature( + id=keyset.id, + amount=amount, + C_=C_.serialize().hex(), + dleq=DLEQ(e=e.serialize(), s=s.serialize()), + ) def _check_spendable(self, proof: Proof): """Checks whether the proof was already spent.""" diff --git a/cashu/mint/migrations.py b/cashu/mint/migrations.py index 44779e62..0d4df023 100644 --- a/cashu/mint/migrations.py +++ b/cashu/mint/migrations.py @@ -156,3 +156,15 @@ async def m007_proofs_and_promises_store_id(db: Database): await db.execute( f"ALTER TABLE {table_with_schema(db, 'promises')} ADD COLUMN id TEXT" ) + + +async def m008_promises_dleq(db: Database): + """ + Add columns for DLEQ proof to promises table. + """ + await db.execute( + f"ALTER TABLE {table_with_schema(db, 'promises')} ADD COLUMN e TEXT" + ) + await db.execute( + f"ALTER TABLE {table_with_schema(db, 'promises')} ADD COLUMN s TEXT" + ) diff --git a/cashu/wallet/api/router.py b/cashu/wallet/api/router.py index 1f6fcaee..1f02ef0f 100644 --- a/cashu/wallet/api/router.py +++ b/cashu/wallet/api/router.py @@ -225,11 +225,11 @@ async def send_command( global wallet if not nostr: balance, token = await send( - wallet, amount, lock, legacy=False, split=not nosplit + wallet, amount=amount, lock=lock, legacy=False, split=not nosplit ) return SendResponse(balance=balance, token=token) else: - token, pubkey = await send_nostr(wallet, amount, nostr) + token, pubkey = await send_nostr(wallet, amount=amount, pubkey=nostr) return SendResponse(balance=wallet.available_balance, token=token, npub=pubkey) @@ -325,7 +325,7 @@ async def pending( enumerate( groupby( sorted_proofs, - key=itemgetter("send_id"), + key=itemgetter("send_id"), # type: ignore ) ), offset, @@ -334,9 +334,9 @@ async def pending( grouped_proofs = list(value) token = await wallet.serialize_proofs(grouped_proofs) tokenObj = deserialize_token_from_string(token) - mint = [t.mint for t in tokenObj.token][0] + mint = [t.mint for t in tokenObj.token if t.mint][0] reserved_date = datetime.utcfromtimestamp( - int(grouped_proofs[0].time_reserved) + int(grouped_proofs[0].time_reserved) # type: ignore ).strftime("%Y-%m-%d %H:%M:%S") result.update( { diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index 5dec8f1a..bdbe5167 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -352,6 +352,14 @@ async def balance(ctx: Context, verbose): type=str, ) @click.option("--lock", "-l", default=None, help="Lock tokens (P2SH).", type=str) +@click.option( + "--dleq", + "-d", + default=False, + is_flag=True, + help="Send with DLEQ proof.", + type=bool, +) @click.option( "--legacy", "-l", @@ -387,6 +395,7 @@ async def send_command( nostr: str, nopt: str, lock: str, + dleq: bool, legacy: bool, verbose: bool, yes: bool, @@ -394,9 +403,18 @@ async def send_command( ): wallet: Wallet = ctx.obj["WALLET"] if not nostr and not nopt: - await send(wallet, amount, lock, legacy, split=not nosplit) + await send( + wallet, + amount=amount, + lock=lock, + legacy=legacy, + split=not nosplit, + include_dleq=dleq, + ) else: - await send_nostr(wallet, amount, nostr or nopt, verbose, yes) + await send_nostr( + wallet, amount=amount, pubkey=nostr or nopt, verbose=verbose, yes=yes + ) @cli.command("receive", help="Receive tokens.") @@ -532,14 +550,15 @@ async def pending(ctx: Context, legacy, number: int, offset: int): enumerate( groupby( sorted_proofs, - key=itemgetter("send_id"), + key=itemgetter("send_id"), # type: ignore ) ), offset, number, ): grouped_proofs = list(value) - token = await wallet.serialize_proofs(grouped_proofs) + # TODO: we can't return DLEQ because we don't store it + token = await wallet.serialize_proofs(grouped_proofs, include_dleq=False) tokenObj = deserialize_token_from_string(token) mint = [t.mint for t in tokenObj.token][0] # token_hidden_secret = await wallet.serialize_proofs(grouped_proofs) diff --git a/cashu/wallet/crud.py b/cashu/wallet/crud.py index e229ccf1..fcdfab1a 100644 --- a/cashu/wallet/crud.py +++ b/cashu/wallet/crud.py @@ -1,3 +1,4 @@ +import json import time from typing import Any, List, Optional, Tuple @@ -9,12 +10,12 @@ async def store_proof( proof: Proof, db: Database, conn: Optional[Connection] = None, -): +) -> None: await (conn or db).execute( """ INSERT INTO proofs - (id, amount, C, secret, time_created, derivation_path) - VALUES (?, ?, ?, ?, ?, ?) + (id, amount, C, secret, time_created, derivation_path, dleq) + VALUES (?, ?, ?, ?, ?, ?, ?) """, ( proof.id, @@ -23,6 +24,7 @@ async def store_proof( str(proof.secret), int(time.time()), proof.derivation_path, + json.dumps(proof.dleq.dict()) if proof.dleq else "", ), ) @@ -30,29 +32,29 @@ async def store_proof( async def get_proofs( db: Database, conn: Optional[Connection] = None, -): +) -> List[Proof]: rows = await (conn or db).fetchall(""" SELECT * from proofs """) - return [Proof(**dict(r)) for r in rows] + return [Proof.from_dict(dict(r)) for r in rows] async def get_reserved_proofs( db: Database, conn: Optional[Connection] = None, -): +) -> List[Proof]: rows = await (conn or db).fetchall(""" SELECT * from proofs WHERE reserved """) - return [Proof(**r) for r in rows] + return [Proof.from_dict(dict(r)) for r in rows] async def invalidate_proof( proof: Proof, db: Database, conn: Optional[Connection] = None, -): +) -> None: await (conn or db).execute( """ DELETE FROM proofs @@ -84,7 +86,7 @@ async def update_proof_reserved( send_id: str = "", db: Optional[Database] = None, conn: Optional[Connection] = None, -): +) -> None: clauses = [] values: List[Any] = [] clauses.append("reserved = ?") @@ -109,7 +111,7 @@ async def secret_used( secret: str, db: Database, conn: Optional[Connection] = None, -): +) -> bool: rows = await (conn or db).fetchone( """ SELECT * from proofs @@ -124,7 +126,7 @@ async def store_p2sh( p2sh: P2SHScript, db: Database, conn: Optional[Connection] = None, -): +) -> None: await (conn or db).execute( """ INSERT INTO p2sh @@ -144,7 +146,7 @@ async def get_unused_locks( address: str = "", db: Optional[Database] = None, conn: Optional[Connection] = None, -): +) -> List[P2SHScript]: clause: List[str] = [] args: List[str] = [] @@ -173,7 +175,7 @@ async def update_p2sh_used( used: bool, db: Optional[Database] = None, conn: Optional[Connection] = None, -): +) -> None: clauses = [] values = [] clauses.append("used = ?") @@ -190,7 +192,7 @@ async def store_keyset( mint_url: str = "", db: Optional[Database] = None, conn: Optional[Connection] = None, -): +) -> None: await (conn or db).execute( # type: ignore """ INSERT INTO keysets @@ -243,7 +245,7 @@ async def store_lightning_invoice( db: Database, invoice: Invoice, conn: Optional[Connection] = None, -): +) -> None: await (conn or db).execute( """ INSERT INTO invoices @@ -266,7 +268,7 @@ async def get_lightning_invoice( db: Database, hash: str = "", conn: Optional[Connection] = None, -): +) -> Invoice: clauses = [] values: List[Any] = [] if hash: @@ -291,7 +293,7 @@ async def get_lightning_invoices( db: Database, paid: Optional[bool] = None, conn: Optional[Connection] = None, -): +) -> List[Invoice]: clauses: List[Any] = [] values: List[Any] = [] @@ -319,7 +321,7 @@ async def update_lightning_invoice( paid: bool, time_paid: Optional[int] = None, conn: Optional[Connection] = None, -): +) -> None: clauses = [] values: List[Any] = [] clauses.append("paid = ?") @@ -344,7 +346,7 @@ async def bump_secret_derivation( by: int = 1, skip: bool = False, conn: Optional[Connection] = None, -): +) -> int: rows = await (conn or db).fetchone( "SELECT counter from keysets WHERE id = ?", (keyset_id,) ) @@ -374,7 +376,7 @@ async def set_secret_derivation( keyset_id: str, counter: int, conn: Optional[Connection] = None, -): +) -> None: await (conn or db).execute( "UPDATE keysets SET counter = ? WHERE id = ?", ( @@ -388,7 +390,7 @@ async def set_nostr_last_check_timestamp( db: Database, timestamp: int, conn: Optional[Connection] = None, -): +) -> None: await (conn or db).execute( "UPDATE nostr SET last = ? WHERE type = ?", (timestamp, "dm"), @@ -398,7 +400,7 @@ async def set_nostr_last_check_timestamp( async def get_nostr_last_check_timestamp( db: Database, conn: Optional[Connection] = None, -): +) -> Optional[int]: row = await (conn or db).fetchone( """ SELECT last from nostr WHERE type = ? @@ -432,7 +434,7 @@ async def store_seed_and_mnemonic( seed: str, mnemonic: str, conn: Optional[Connection] = None, -): +) -> None: await (conn or db).execute( """ INSERT INTO seed diff --git a/cashu/wallet/helpers.py b/cashu/wallet/helpers.py index d6a1d289..418a6b75 100644 --- a/cashu/wallet/helpers.py +++ b/cashu/wallet/helpers.py @@ -160,7 +160,13 @@ async def receive( async def send( - wallet: Wallet, amount: int, lock: str, legacy: bool, split: bool = True + wallet: Wallet, + *, + amount: int, + lock: str, + legacy: bool, + split: bool = True, + include_dleq: bool = False, ): """ Prints token to send to stdout. @@ -207,14 +213,14 @@ async def send( "No proof with this amount found. Available amounts:" f" {set([p.amount for p in wallet.proofs])}" ) - await wallet.set_reserved(send_proofs, reserved=True) token = await wallet.serialize_proofs( send_proofs, include_mints=True, + include_dleq=include_dleq, ) print(token) - + await wallet.set_reserved(send_proofs, reserved=True) if legacy: print("") print("Old token format:") @@ -222,6 +228,7 @@ async def send( token = await wallet.serialize_proofs( send_proofs, legacy=True, + include_dleq=include_dleq, ) print(token) diff --git a/cashu/wallet/migrations.py b/cashu/wallet/migrations.py index 061b31c5..94ce47fa 100644 --- a/cashu/wallet/migrations.py +++ b/cashu/wallet/migrations.py @@ -173,3 +173,10 @@ async def m009_privatekey_and_determinstic_key_derivation(db: Database): ); """) # await db.execute("INSERT INTO secret_derivation (counter) VALUES (0)") + + +async def m010_add_proofs_dleq(db: Database): + """ + Columns to store DLEQ proofs for proofs. + """ + await db.execute("ALTER TABLE proofs ADD COLUMN dleq TEXT") diff --git a/cashu/wallet/nostr.py b/cashu/wallet/nostr.py index 3f508b71..69ac6534 100644 --- a/cashu/wallet/nostr.py +++ b/cashu/wallet/nostr.py @@ -45,10 +45,12 @@ async def nip5_to_pubkey(wallet: Wallet, address: str): async def send_nostr( wallet: Wallet, + *, amount: int, pubkey: str, verbose: bool = False, yes: bool = True, + include_dleq=False, ): """ Sends tokens via nostr. @@ -62,7 +64,7 @@ async def send_nostr( _, send_proofs = await wallet.split_to_send( wallet.proofs, amount, set_reserved=True ) - token = await wallet.serialize_proofs(send_proofs) + token = await wallet.serialize_proofs(send_proofs, include_dleq=include_dleq) if pubkey.startswith("npub"): pubkey_to = PublicKey().from_npub(pubkey) diff --git a/cashu/wallet/p2pk.py b/cashu/wallet/p2pk.py index ae95eb31..b176b2b4 100644 --- a/cashu/wallet/p2pk.py +++ b/cashu/wallet/p2pk.py @@ -21,7 +21,7 @@ sign_p2pk_sign, ) from ..core.script import ( - step0_carol_checksig_redeemscrip, + step0_carol_checksig_redeemscript, step0_carol_privkey, step1_carol_create_p2sh_address, step2_carol_sign_tx, @@ -41,7 +41,7 @@ class WalletP2PK(SupportsPrivateKey, SupportsDb): async def create_p2sh_address_and_store(self) -> str: """Creates a P2SH lock script and stores the script and signature in the database.""" alice_privkey = step0_carol_privkey() - txin_redeemScript = step0_carol_checksig_redeemscrip(alice_privkey.pub) + txin_redeemScript = step0_carol_checksig_redeemscript(alice_privkey.pub) txin_p2sh_address = step1_carol_create_p2sh_address(txin_redeemScript) txin_signature = step2_carol_sign_tx(txin_redeemScript, alice_privkey).scriptSig txin_redeemScript_b64 = base64.urlsafe_b64encode(txin_redeemScript).decode() diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 72650d93..bfbe9efd 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -19,6 +19,7 @@ CheckFeesRequest, CheckSpendableRequest, CheckSpendableResponse, + DLEQWallet, GetInfoResponse, GetMeltResponse, GetMintResponse, @@ -110,101 +111,11 @@ def __init__(self, url: str, db: Database): self.s = requests.Session() self.db = db - # async def generate_n_secrets( - # self, n: int = 1, skip_bump: bool = False - # ) -> Tuple[List[str], List[PrivateKey], List[str]]: - # return await self.generate_n_secrets(n, skip_bump) - - # async def _generate_secret(self, skip_bump: bool = False) -> str: - # return await self._generate_secret(skip_bump) - @async_set_requests async def _init_s(self): """Dummy function that can be called from outside to use LedgerAPI.s""" return - def _construct_proofs( - self, - promises: List[BlindedSignature], - secrets: List[str], - rs: List[PrivateKey], - derivation_paths: List[str], - ) -> List[Proof]: - """Constructs proofs from promises, secrets, rs and derivation paths. - - This method is called after the user has received blind signatures from - the mint. The results are proofs that can be used as ecash. - - Args: - promises (List[BlindedSignature]): blind signatures from mint - secrets (List[str]): secrets that were previously used to create blind messages (that turned into promises) - rs (List[PrivateKey]): blinding factors that were previously used to create blind messages (that turned into promises) - derivation_paths (List[str]): derivation paths that were used to generate secrets and blinding factors - - Returns: - List[Proof]: list of proofs that can be used as ecash - """ - logger.trace("Constructing proofs.") - proofs: List[Proof] = [] - for promise, secret, r, path in zip(promises, secrets, rs, derivation_paths): - logger.trace(f"Creating proof with keyset {self.keyset_id} = {promise.id}") - assert ( - self.keyset_id == promise.id - ), "our keyset id does not match promise id." - - C_ = PublicKey(bytes.fromhex(promise.C_), raw=True) - C = b_dhke.step3_alice(C_, r, self.public_keys[promise.amount]) - - proof = Proof( - id=promise.id, - amount=promise.amount, - C=C.serialize().hex(), - secret=secret, - derivation_path=path, - ) - proofs.append(proof) - logger.trace( - f"Created proof: {proof}, r: {r.serialize()} out of promise {promise}" - ) - - logger.trace(f"Constructed {len(proofs)} proofs.") - return proofs - - @staticmethod - def _construct_outputs( - amounts: List[int], secrets: List[str], rs: List[PrivateKey] = [] - ) -> Tuple[List[BlindedMessage], List[PrivateKey]]: - """Takes a list of amounts and secrets and returns outputs. - Outputs are blinded messages `outputs` and blinding factors `rs` - - Args: - amounts (List[int]): list of amounts - secrets (List[str]): list of secrets - rs (List[PrivateKey], optional): list of blinding factors. If not given, `rs` are generated in step1_alice. Defaults to []. - - Returns: - List[BlindedMessage]: list of blinded messages that can be sent to the mint - List[PrivateKey]: list of blinding factors that can be used to construct proofs after receiving blind signatures from the mint - - Raises: - AssertionError: if len(amounts) != len(secrets) - """ - assert len(amounts) == len( - secrets - ), f"len(amounts)={len(amounts)} not equal to len(secrets)={len(secrets)}" - outputs: List[BlindedMessage] = [] - - rs_ = [None] * len(amounts) if not rs else rs - rs_return: List[PrivateKey] = [] - for secret, amount, r in zip(secrets, amounts, rs_): - B_, r = b_dhke.step1_alice(secret, r or None) - rs_return.append(r) - output = BlindedMessage(amount=amount, B_=B_.serialize().hex()) - outputs.append(output) - logger.trace(f"Constructing output: {output}, r: {r.serialize()}") - - return outputs, rs_return - @staticmethod def raise_on_error(resp: Response) -> None: """Raises an exception if the response from the mint contains an error. @@ -465,9 +376,9 @@ async def mint( }, ) self.raise_on_error(resp) - reponse_dict = resp.json() + response_dict = resp.json() logger.trace("Lightning invoice checked. POST /mint") - promises = PostMintResponse.parse_obj(reponse_dict).promises + promises = PostMintResponse.parse_obj(response_dict).promises return promises @async_set_requests @@ -506,7 +417,7 @@ def _splitrequest_include_fields(proofs: List[Proof]): @async_set_requests async def check_proof_state(self, proofs: List[Proof]): """ - Cheks whether the secrets in proofs are already spent or not and returns a list of booleans. + Checks whether the secrets in proofs are already spent or not and returns a list of booleans. """ payload = CheckSpendableRequest(proofs=proofs) @@ -577,8 +488,8 @@ async def restore_promises( payload = PostMintRequest(outputs=outputs) resp = self.s.post(self.url + "/restore", json=payload.dict()) self.raise_on_error(resp) - reponse_dict = resp.json() - returnObj = PostRestoreResponse.parse_obj(reponse_dict) + response_dict = resp.json() + returnObj = PostRestoreResponse.parse_obj(response_dict) return returnObj.outputs, returnObj.promises @@ -609,7 +520,7 @@ def __init__( self.name = name super().__init__(url=url, db=self.db) - logger.debug(f"Wallet initalized with mint URL {url}") + logger.debug(f"Wallet initialized with mint URL {url}") @classmethod async def with_db( @@ -726,16 +637,12 @@ async def mint( await bump_secret_derivation( db=self.db, keyset_id=self.keyset_id, by=len(amounts) ) - proofs = self._construct_proofs(promises, secrets, rs, derivation_paths) + proofs = await self._construct_proofs(promises, secrets, rs, derivation_paths) - if proofs == []: - raise Exception("received no proofs.") - await self._store_proofs(proofs) if hash: await update_lightning_invoice( db=self.db, hash=hash, paid=True, time_paid=int(time.time()) ) - self.proofs += proofs return proofs async def redeem( @@ -749,6 +656,10 @@ async def redeem( Args: proofs (List[Proof]): Proofs to be redeemed. """ + # verify DLEQ of incoming proofs + logger.debug("Verifying DLEQ of incoming proofs.") + self.verify_proofs_dleq(proofs) + logger.debug("DLEQ verified.") return await self.split(proofs, sum_proofs(proofs)) async def split( @@ -797,9 +708,9 @@ async def split( logger.debug(f"Creating proofs with custom secrets: {secret_locks}") assert len(secret_locks) == len( scnd_outputs - ), "number of secret_locks does not match number of ouptus." + ), "number of secret_locks does not match number of outputs." # append predefined secrets (to send) to random secrets (to keep) - # generate sercets to keep + # generate secrets to keep secrets = [ await self._generate_secret() for s in range(len(frst_outputs)) ] + secret_locks @@ -822,18 +733,11 @@ async def split( promises = await super().split(proofs, outputs) # Construct proofs from returned promises (i.e., unblind the signatures) - new_proofs = self._construct_proofs(promises, secrets, rs, derivation_paths) + new_proofs = await self._construct_proofs( + promises, secrets, rs, derivation_paths + ) - # remove used proofs from wallet and add new ones - used_secrets = [p.secret for p in proofs] - self.proofs = list(filter(lambda p: p.secret not in used_secrets, self.proofs)) - # add new proofs to wallet - self.proofs += new_proofs - # store new proofs in database - await self._store_proofs(new_proofs) - # invalidate used proofs in database - for proof in proofs: - await invalidate_proof(proof, db=self.db) + await self.invalidate(proofs) keep_proofs = new_proofs[: len(frst_outputs)] send_proofs = new_proofs[len(frst_outputs) :] @@ -862,7 +766,6 @@ async def pay_lightning( if status.paid: # the payment was successful - await self.invalidate(proofs) invoice_obj = Invoice( amount=-sum_proofs(proofs), pr=invoice, @@ -877,14 +780,15 @@ async def pay_lightning( # handle change and produce proofs if status.change: - change_proofs = self._construct_proofs( + change_proofs = await self._construct_proofs( status.change, secrets[: len(status.change)], rs[: len(status.change)], derivation_paths[: len(status.change)], ) logger.debug(f"Received change: {sum_proofs(change_proofs)} sat") - await self._store_proofs(change_proofs) + + await self.invalidate(proofs) else: raise Exception("could not pay invoice.") @@ -895,10 +799,137 @@ async def check_proof_state(self, proofs): # ---------- TOKEN MECHANICS ---------- + # ---------- DLEQ PROOFS ---------- + + def verify_proofs_dleq(self, proofs: List[Proof]): + """Verifies DLEQ proofs in proofs.""" + for proof in proofs: + if not proof.dleq: + logger.trace("No DLEQ proof in proof.") + return + logger.trace("Verifying DLEQ proof.") + assert self.keys.public_keys + if not b_dhke.carol_verify_dleq( + secret_msg=proof.secret, + C=PublicKey(bytes.fromhex(proof.C), raw=True), + r=PrivateKey(bytes.fromhex(proof.dleq.r), raw=True), + e=PrivateKey(bytes.fromhex(proof.dleq.e), raw=True), + s=PrivateKey(bytes.fromhex(proof.dleq.s), raw=True), + A=self.keys.public_keys[proof.amount], + ): + raise Exception("DLEQ proof invalid.") + else: + logger.debug("DLEQ proof valid.") + + async def _construct_proofs( + self, + promises: List[BlindedSignature], + secrets: List[str], + rs: List[PrivateKey], + derivation_paths: List[str], + ) -> List[Proof]: + """Constructs proofs from promises, secrets, rs and derivation paths. + + This method is called after the user has received blind signatures from + the mint. The results are proofs that can be used as ecash. + + Args: + promises (List[BlindedSignature]): blind signatures from mint + secrets (List[str]): secrets that were previously used to create blind messages (that turned into promises) + rs (List[PrivateKey]): blinding factors that were previously used to create blind messages (that turned into promises) + derivation_paths (List[str]): derivation paths that were used to generate secrets and blinding factors + + Returns: + List[Proof]: list of proofs that can be used as ecash + """ + logger.trace("Constructing proofs.") + proofs: List[Proof] = [] + for promise, secret, r, path in zip(promises, secrets, rs, derivation_paths): + logger.trace(f"Creating proof with keyset {self.keyset_id} = {promise.id}") + assert ( + self.keyset_id == promise.id + ), "our keyset id does not match promise id." + + C_ = PublicKey(bytes.fromhex(promise.C_), raw=True) + C = b_dhke.step3_alice(C_, r, self.public_keys[promise.amount]) + B_, r = b_dhke.step1_alice(secret, r) # recompute B_ for dleq proofs + + proof = Proof( + id=promise.id, + amount=promise.amount, + C=C.serialize().hex(), + secret=secret, + derivation_path=path, + ) + + # if the mint returned a dleq proof, we add it to the proof + if promise.dleq: + proof.dleq = DLEQWallet( + e=promise.dleq.e, s=promise.dleq.s, r=r.serialize() + ) + + proofs.append(proof) + + logger.trace( + f"Created proof: {proof}, r: {r.serialize()} out of promise {promise}" + ) + + # DLEQ verify + self.verify_proofs_dleq(proofs) + + logger.trace(f"Constructed {len(proofs)} proofs.") + + # add new proofs to wallet + self.proofs += proofs + # store new proofs in database + await self._store_proofs(proofs) + + return proofs + + @staticmethod + def _construct_outputs( + amounts: List[int], secrets: List[str], rs: List[PrivateKey] = [] + ) -> Tuple[List[BlindedMessage], List[PrivateKey]]: + """Takes a list of amounts and secrets and returns outputs. + Outputs are blinded messages `outputs` and blinding factors `rs` + + Args: + amounts (List[int]): list of amounts + secrets (List[str]): list of secrets + rs (List[PrivateKey], optional): list of blinding factors. If not given, `rs` are generated in step1_alice. Defaults to []. + + Returns: + List[BlindedMessage]: list of blinded messages that can be sent to the mint + List[PrivateKey]: list of blinding factors that can be used to construct proofs after receiving blind signatures from the mint + + Raises: + AssertionError: if len(amounts) != len(secrets) + """ + assert len(amounts) == len( + secrets + ), f"len(amounts)={len(amounts)} not equal to len(secrets)={len(secrets)}" + outputs: List[BlindedMessage] = [] + + rs_ = [None] * len(amounts) if not rs else rs + rs_return: List[PrivateKey] = [] + for secret, amount, r in zip(secrets, amounts, rs_): + B_, r = b_dhke.step1_alice(secret, r or None) + rs_return.append(r) + output = BlindedMessage(amount=amount, B_=B_.serialize().hex()) + outputs.append(output) + logger.trace(f"Constructing output: {output}, r: {r.serialize()}") + + return outputs, rs_return + async def _store_proofs(self, proofs): - async with self.db.connect() as conn: - for proof in proofs: - await store_proof(proof, db=self.db, conn=conn) + try: + async with self.db.connect() as conn: # type: ignore + for proof in proofs: + await store_proof(proof, db=self.db, conn=conn) + except Exception as e: + logger.error(f"Could not store proofs in database: {e}") + logger.error(proofs) + raise e @staticmethod def _get_proofs_per_keyset(proofs: List[Proof]): @@ -981,7 +1012,7 @@ async def _make_token(self, proofs: List[Proof], include_mints=True) -> TokenV3: return token async def serialize_proofs( - self, proofs: List[Proof], include_mints=True, legacy=False + self, proofs: List[Proof], include_mints=True, include_dleq=False, legacy=False ) -> str: """Produces sharable token with proofs and mint information. @@ -1007,7 +1038,7 @@ async def serialize_proofs( # V3 tokens token = await self._make_token(proofs, include_mints) - return token.serialize() + return token.serialize(include_dleq) async def _make_token_v2(self, proofs: List[Proof], include_mints=True) -> TokenV2: """ @@ -1016,6 +1047,7 @@ async def _make_token_v2(self, proofs: List[Proof], include_mints=True) -> Token """ # build token token = TokenV2(proofs=proofs) + # add mint information to the token, if requested if include_mints: # dummy object to hold information about the mint @@ -1132,7 +1164,7 @@ async def invalidate( invalidated_proofs = proofs if invalidated_proofs: - logger.debug( + logger.trace( f"Invalidating {len(invalidated_proofs)} proofs worth" f" {sum_proofs(invalidated_proofs)} sat." ) @@ -1235,7 +1267,7 @@ async def restore_wallet_from_mnemonic( Args: mnemonic (Optional[str]): The mnemonic to restore the wallet from. If None, the mnemonic is loaded from the db. - to (int, optional): The number of consecutive empty reponses to stop restoring. Defaults to 2. + to (int, optional): The number of consecutive empty responses to stop restoring. Defaults to 2. batch (int, optional): The number of proofs to restore in one batch. Defaults to 25. """ await self._init_private_key(mnemonic) @@ -1295,7 +1327,7 @@ async def restore_promises_from_to( ) # we don't know the amount but luckily the mint will tell us so we use a dummy amount here amounts_dummy = [1] * len(secrets) - # we generate outptus from deterministic secrets and rs + # we generate outputs from deterministic secrets and rs regenerated_outputs, _ = self._construct_outputs(amounts_dummy, secrets, rs) # we ask the mint to reissue the promises proofs = await self.restore_promises( @@ -1339,14 +1371,8 @@ async def restore_promises( secrets = [secrets[i] for i in matching_indices] rs = [rs[i] for i in matching_indices] # now we can construct the proofs with the secrets and rs - proofs = self._construct_proofs( + proofs = await self._construct_proofs( restored_promises, secrets, rs, derivation_paths ) logger.debug(f"Restored {len(restored_promises)} promises") - await self._store_proofs(proofs) - - # append proofs to proofs in memory so the balance updates - for proof in proofs: - if proof.secret not in [p.secret for p in self.proofs]: - self.proofs.append(proof) return proofs diff --git a/tests/test_cli.py b/tests/test_cli.py index b2d2e659..bc1208fd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,6 +3,7 @@ import pytest from click.testing import CliRunner +from cashu.core.base import TokenV3 from cashu.core.settings import settings from cashu.wallet.cli.cli import cli from cashu.wallet.wallet import Wallet @@ -123,9 +124,38 @@ def test_send(mint, cli_prefix): [*cli_prefix, "send", "10"], ) assert result.exception is None - print("SEND") print(result.output) - assert "cashuA" in result.output, "output does not have a token" + token_str = result.output.split("\n")[0] + assert "cashuA" in token_str, "output does not have a token" + token = TokenV3.deserialize(token_str) + assert token.token[0].proofs[0].dleq is None, "dleq included" + + +def test_send_with_dleq(mint, cli_prefix): + runner = CliRunner() + result = runner.invoke( + cli, + [*cli_prefix, "send", "10", "--dleq"], + ) + assert result.exception is None + print(result.output) + token_str = result.output.split("\n")[0] + assert "cashuA" in token_str, "output does not have a token" + token = TokenV3.deserialize(token_str) + assert token.token[0].proofs[0].dleq is not None, "no dleq included" + + +def test_send_legacy(mint, cli_prefix): + runner = CliRunner() + result = runner.invoke( + cli, + [*cli_prefix, "send", "10", "--legacy"], + ) + assert result.exception is None + print(result.output) + # this is the legacy token in the output + token_str = result.output.split("\n")[4] + assert token_str.startswith("eyJwcm9v"), "output is not as expected" def test_send_without_split(mint, cli_prefix): @@ -243,3 +273,14 @@ def test_nostr_send(mint, cli_prefix): assert result.exception is None print("NOSTR_SEND") print(result.output) + + +def test_pending(cli_prefix): + runner = CliRunner() + result = runner.invoke( + cli, + [*cli_prefix, "pending"], + ) + assert result.exception is None + print(result.output) + assert result.exit_code == 0 diff --git a/tests/test_core.py b/tests/test_core.py index 5a46d16c..e41df38c 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -31,17 +31,79 @@ def test_tokenv3_get_proofs(): assert len(token.get_proofs()) == 2 +def test_tokenv3_deserialize_serialize_with_dleq(): + token_str = ( + "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIjFjQ05JQVoyWC93M" + "SIsICJhbW91bnQiOiAyLCAic2VjcmV0IjogIjZmZjFiY2VlOGUzMzk2NGE4ZDNjNGQ5NzYwNzdiZ" + "DI4ZGVkZWJkODYyMDU0MDQzNDY4ZjU5ZDFiZjI1OTQzN2QiLCAiQyI6ICIwM2I3ZD" + "lkMzIzYTAxOWJlNTE4NzRlOGE5OGY1NDViOTg3Y2JmNmU5MWUwMDc1YTFhZjQ3MjY2NDMxOGRlZ" + "TQzZTUiLCAiZGxlcSI6IHsiZSI6ICI1ZjkxMGQ4NTc0M2U0OTI0ZjRiNjlkNzhjM" + "jFjYTc1ZjEzNzg3Zjc3OTE1NWRmMjMzMjJmYTA1YjU5ODdhYzNmIiwgInMiOiAiZTc4Y2U0MzNiZ" + "WNlZTNjNGU1NzM4ZDdjMzRlNDQyZWQ0MmJkMzk0MjI0ZTc3MjE4OGFjMmI5MzZmM" + "jA2Y2QxYSIsICJyIjogIjI3MzM3ODNmOTQ4MWZlYzAxNzdlYmM4ZjBhOTI2OWVjOGFkNzU5MDU2ZT" + "k3MTRiMWEwYTEwMDQ3MmY2Y2Y5YzIifX0sIHsiaWQiOiAiMWNDTklBWjJYL3cxIi" + "wgImFtb3VudCI6IDgsICJzZWNyZXQiOiAiMmFkNDMyZDRkNTg2MzJiMmRlMzI0ZmQxYmE5OTcyZmE" + "4MDljNmU3ZGE1ZTkyZWVmYjBiNjYxMmQ5M2Q3ZTAwMCIsICJDIjogIjAzMmFmYjg" + "zOWQwMmRmMWNhOGY5ZGZjNTI1NzUxN2Q0MzY4YjdiMTc0MzgzM2JlYWUzZDQzNmExYmQwYmJkYjVk" + "OCIsICJkbGVxIjogeyJlIjogImY0NjM2MzU5YTUzZGQxNGEyNmUyNTMyMDQxZWIx" + "MDE2OTk1ZTg4NzgwODY0OWFlY2VlNTcwZTA5ZTk2NTU3YzIiLCAicyI6ICJmZWYzMGIzMDcwMDJkMW" + "VjNWZiZjg0ZGZhZmRkMGEwOTdkNDJlMDYxNTZiNzdiMTMzMmNjNGZjNGNjYWEyOD" + "JmIiwgInIiOiAiODQ5MjQxNzBlYzc3ZjhjMDNmZDRlZTkyZTA3MjdlMzYyNTliZjRhYTc4NTBjZTc2" + "NDExMDQ0MmNlNmVlM2FjYyJ9fV0sICJtaW50IjogImh0dHA6Ly9sb2NhbGhvc3Q6MzMzOCJ9XX0=" + ) + token = TokenV3.deserialize(token_str) + assert token.serialize(include_dleq=True) == token_str + + def test_tokenv3_deserialize_serialize(): token_str = ( - "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIkplaFpMVTZuQ3BSZCIsICJhbW91bnQiOiAyLCAic2VjcmV0IjogIjBFN2lDazRkVmxSZjVQRjFnNFpWMnci" - "LCAiQyI6ICIwM2FiNTgwYWQ5NTc3OGVkNTI5NmY4YmVlNjU1ZGJkN2Q2NDJmNWQzMmRlOGUyNDg0NzdlMGI0ZDZhYTg2M2ZjZDUifSwgeyJpZCI6ICJKZWhaTFU2bkNwUmQiLCAiYW" - "1vdW50IjogOCwgInNlY3JldCI6ICJzNklwZXh3SGNxcXVLZDZYbW9qTDJnIiwgIkMiOiAiMDIyZDAwNGY5ZWMxNmE1OGFkOTAxNGMyNTliNmQ2MTRlZDM2ODgyOWYwMmMzODc3M2M0" + "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIkplaFpMVTZuQ3BSZCIsICJh" + "bW91bnQiOiAyLCAic2VjcmV0IjogIjBFN2lDazRkVmxSZjVQRjFnNFpWMnci" + "LCAiQyI6ICIwM2FiNTgwYWQ5NTc3OGVkNTI5NmY4YmVlNjU1ZGJkN2Q2NDJmNWQzMmRlOG" + "UyNDg0NzdlMGI0ZDZhYTg2M2ZjZDUifSwgeyJpZCI6ICJKZWhaTFU2bkNwUmQiLCAiYW" + "1vdW50IjogOCwgInNlY3JldCI6ICJzNklwZXh3SGNxcXVLZDZYbW9qTDJnIiwgIkMiOiAiM" + "DIyZDAwNGY5ZWMxNmE1OGFkOTAxNGMyNTliNmQ2MTRlZDM2ODgyOWYwMmMzODc3M2M0" "NzIyMWY0OTYxY2UzZjIzIn1dLCAibWludCI6ICJodHRwOi8vbG9jYWxob3N0OjMzMzgifV19" ) token = TokenV3.deserialize(token_str) assert token.serialize() == token_str +def test_tokenv3_deserialize_serialize_no_dleq(): + token_str = ( + "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIjFjQ05JQVoyWC93MSIsICJhb" + "W91bnQiOiAyLCAic2VjcmV0IjogIjZmZjFiY2VlOGUzMzk2NGE4ZDNjNGQ5NzYwNzdiZ" + "DI4ZGVkZWJkODYyMDU0MDQzNDY4ZjU5ZDFiZjI1OTQzN2QiLCAiQyI6ICIwM2I3ZDlkMzIzY" + "TAxOWJlNTE4NzRlOGE5OGY1NDViOTg3Y2JmNmU5MWUwMDc1YTFhZjQ3MjY2NDMxOGRlZ" + "TQzZTUiLCAiZGxlcSI6IHsiZSI6ICI1ZjkxMGQ4NTc0M2U0OTI0ZjRiNjlkNzhjMjFjYTc1Z" + "jEzNzg3Zjc3OTE1NWRmMjMzMjJmYTA1YjU5ODdhYzNmIiwgInMiOiAiZTc4Y2U0MzNiZ" + "WNlZTNjNGU1NzM4ZDdjMzRlNDQyZWQ0MmJkMzk0MjI0ZTc3MjE4OGFjMmI5MzZmMjA2Y2QxY" + "SIsICJyIjogIjI3MzM3ODNmOTQ4MWZlYzAxNzdlYmM4ZjBhOTI2OWVjOGFkNzU5MDU2ZT" + "k3MTRiMWEwYTEwMDQ3MmY2Y2Y5YzIifX0sIHsiaWQiOiAiMWNDTklBWjJYL3cxIiwgImFtb3" + "VudCI6IDgsICJzZWNyZXQiOiAiMmFkNDMyZDRkNTg2MzJiMmRlMzI0ZmQxYmE5OTcyZmE" + "4MDljNmU3ZGE1ZTkyZWVmYjBiNjYxMmQ5M2Q3ZTAwMCIsICJDIjogIjAzMmFmYjgzOWQwMmR" + "mMWNhOGY5ZGZjNTI1NzUxN2Q0MzY4YjdiMTc0MzgzM2JlYWUzZDQzNmExYmQwYmJkYjVk" + "OCIsICJkbGVxIjogeyJlIjogImY0NjM2MzU5YTUzZGQxNGEyNmUyNTMyMDQxZWIxMDE2OTk1" + "ZTg4NzgwODY0OWFlY2VlNTcwZTA5ZTk2NTU3YzIiLCAicyI6ICJmZWYzMGIzMDcwMDJkMW" + "VjNWZiZjg0ZGZhZmRkMGEwOTdkNDJlMDYxNTZiNzdiMTMzMmNjNGZjNGNjYWEyODJmIiwgIn" + "IiOiAiODQ5MjQxNzBlYzc3ZjhjMDNmZDRlZTkyZTA3MjdlMzYyNTliZjRhYTc4NTBjZTc2" + "NDExMDQ0MmNlNmVlM2FjYyJ9fV0sICJtaW50IjogImh0dHA6Ly9sb2NhbGhvc3Q6MzMzOCJ9XX0=" + ) + token_str_no_dleq = ( + "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIjFjQ05JQVoyWC93MSIsICJhbW91bn" + "QiOiAyLCAic2VjcmV0IjogIjZmZjFiY2VlOGUzMzk2NGE4ZDNjNGQ5NzYwNzdiZDI4" + "ZGVkZWJkODYyMDU0MDQzNDY4ZjU5ZDFiZjI1OTQzN2QiLCAiQyI6ICIwM2I3ZDlkMzIzYTAxOWJlN" + "TE4NzRlOGE5OGY1NDViOTg3Y2JmNmU5MWUwMDc1YTFhZjQ3MjY2NDMxOGRlZTQzZTU" + "ifSwgeyJpZCI6ICIxY0NOSUFaMlgvdzEiLCAiYW1vdW50IjogOCwgInNlY3JldCI6ICIyYWQ0MzJkN" + "GQ1ODYzMmIyZGUzMjRmZDFiYTk5NzJmYTgwOWM2ZTdkYTVlOTJlZWZiMGI2NjEyZD" + "kzZDdlMDAwIiwgIkMiOiAiMDMyYWZiODM5ZDAyZGYxY2E4ZjlkZmM1MjU3NTE3ZDQzNjhiN2IxNzQz" + "ODMzYmVhZTNkNDM2YTFiZDBiYmRiNWQ4In1dLCAibWludCI6ICJodHRwOi8vbG9jY" + "Wxob3N0OjMzMzgifV19" + ) + token = TokenV3.deserialize(token_str) + assert token.serialize(include_dleq=False) == token_str_no_dleq + + def test_tokenv3_deserialize_with_memo(): token_str = ( "cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIkplaFpMVTZuQ3BSZCIsICJhbW91bnQiOiAyLCAic2VjcmV0IjogIjBFN2lDazRkVmxSZjV" diff --git a/tests/test_crypto.py b/tests/test_crypto.py index 9afc5bb6..59b94849 100644 --- a/tests/test_crypto.py +++ b/tests/test_crypto.py @@ -1,4 +1,13 @@ -from cashu.core.crypto.b_dhke import hash_to_curve, step1_alice, step2_bob, step3_alice +from cashu.core.crypto.b_dhke import ( + alice_verify_dleq, + carol_verify_dleq, + hash_e, + hash_to_curve, + step1_alice, + step2_bob, + step2_bob_dleq, + step3_alice, +) from cashu.core.crypto.secp import PrivateKey, PublicKey @@ -38,9 +47,9 @@ def test_hash_to_curve_iteration(): def test_step1(): - """""" + secret_msg = "test_message" B_, blinding_factor = step1_alice( - "test_message", + secret_msg, blinding_factor=PrivateKey( privkey=bytes.fromhex( "0000000000000000000000000000000000000000000000000000000000000001" @@ -73,7 +82,7 @@ def test_step2(): ), raw=True, ) - C_ = step2_bob(B_, a) + C_, e, s = step2_bob(B_, a) assert ( C_.serialize().hex() == "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2" @@ -107,3 +116,187 @@ def test_step3(): C.serialize().hex() == "03c724d7e6a5443b39ac8acf11f40420adc4f99a02e7cc1b57703d9391f6d129cd" ) + + +def test_dleq_hash_e(): + C_ = PublicKey( + bytes.fromhex( + "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2" + ), + raw=True, + ) + K = PublicKey( + pubkey=b"\x02" + + bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001", + ), + raw=True, + ) + R1 = PublicKey( + pubkey=b"\x02" + + bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001", + ), + raw=True, + ) + R2 = PublicKey( + pubkey=b"\x02" + + bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001", + ), + raw=True, + ) + e = hash_e(R1, R2, K, C_) + assert e.hex() == "a4dc034b74338c28c6bc3ea49731f2a24440fc7c4affc08b31a93fc9fbe6401e" + + +def test_dleq_step2_bob_dleq(): + B_, _ = step1_alice( + "test_message", + blinding_factor=PrivateKey( + privkey=bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001" + ), + raw=True, + ), + ) + a = PrivateKey( + privkey=bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001" + ), + raw=True, + ) + p_bytes = bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001" + ) # 32 bytes + e, s = step2_bob_dleq(B_, a, p_bytes) + assert ( + e.serialize() + == "9818e061ee51d5c8edc3342369a554998ff7b4381c8652d724cdf46429be73d9" + ) + assert ( + s.serialize() + == "9818e061ee51d5c8edc3342369a554998ff7b4381c8652d724cdf46429be73da" + ) # differs from e only in least significant byte because `a = 0x1` + + # change `a` + a = PrivateKey( + privkey=bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000001111" + ), + raw=True, + ) + e, s = step2_bob_dleq(B_, a, p_bytes) + assert ( + e.serialize() + == "df1984d5c22f7e17afe33b8669f02f530f286ae3b00a1978edaf900f4721f65e" + ) + assert ( + s.serialize() + == "828404170c86f240c50ae0f5fc17bb6b82612d46b355e046d7cd84b0a3c934a0" + ) + + +def test_dleq_alice_verify_dleq(): + # e from test_step2_bob_dleq for a=0x1 + e = PrivateKey( + bytes.fromhex( + "9818e061ee51d5c8edc3342369a554998ff7b4381c8652d724cdf46429be73d9" + ), + raw=True, + ) + # s from test_step2_bob_dleq for a=0x1 + s = PrivateKey( + bytes.fromhex( + "9818e061ee51d5c8edc3342369a554998ff7b4381c8652d724cdf46429be73da" + ), + raw=True, + ) + + a = PrivateKey( + privkey=bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001" + ), + raw=True, + ) + A = a.pubkey + assert A + # B_ is the same as we did: + # B_, _ = step1_alice( + # "test_message", + # blinding_factor=bytes.fromhex( + # "0000000000000000000000000000000000000000000000000000000000000001" + # ), # 32 bytes + # ) + B_ = PublicKey( + bytes.fromhex( + "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2" + ), + raw=True, + ) + + # # C_ is the same as if we did: + # a = PrivateKey( + # privkey=bytes.fromhex( + # "0000000000000000000000000000000000000000000000000000000000000001" + # ), + # raw=True, + # ) + # C_, e, s = step2_bob(B_, a) + + C_ = PublicKey( + bytes.fromhex( + "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2" + ), + raw=True, + ) + + assert alice_verify_dleq(B_, C_, e, s, A) + + +def test_dleq_alice_direct_verify_dleq(): + # ----- test again with B_ and C_ as per step1 and step2 + a = PrivateKey( + privkey=bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001" + ), + raw=True, + ) + A = a.pubkey + assert A + B_, _ = step1_alice( + "test_message", + blinding_factor=PrivateKey( + privkey=bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001" + ), + raw=True, + ), + ) + C_, e, s = step2_bob(B_, a) + assert alice_verify_dleq(B_, C_, e, s, A) + + +def test_dleq_carol_varify_from_bob(): + a = PrivateKey( + privkey=bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001" + ), + raw=True, + ) + A = a.pubkey + assert A + secret_msg = "test_message" + r = PrivateKey( + privkey=bytes.fromhex( + "0000000000000000000000000000000000000000000000000000000000000001" + ), + raw=True, + ) + B_, _ = step1_alice(secret_msg, r) + C_, e, s = step2_bob(B_, a) + assert alice_verify_dleq(B_, C_, e, s, A) + C = step3_alice(C_, r, A) + + # carol does not know B_ and C_, but she receives C and r from Alice + assert carol_verify_dleq(secret_msg=secret_msg, C=C, r=r, e=e, s=s, A=A) diff --git a/tests/test_mint.py b/tests/test_mint.py index ae9ed4a9..cf95caca 100644 --- a/tests/test_mint.py +++ b/tests/test_mint.py @@ -107,6 +107,12 @@ async def test_generate_promises(ledger: Ledger): promises[0].C_ == "037074c4f53e326ee14ed67125f387d160e0e729351471b69ad41f7d5d21071e15" ) + assert promises[0].amount == 8 + + # DLEQ proof present + assert promises[0].dleq + assert promises[0].dleq.s + assert promises[0].dleq.e @pytest.mark.asyncio diff --git a/tests/test_wallet.py b/tests/test_wallet.py index f4ec78b6..b4e5cb07 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -172,6 +172,7 @@ async def test_mint_amounts_wrong_order(wallet1: Wallet): @pytest.mark.asyncio async def test_split(wallet1: Wallet): await wallet1.mint(64) + assert wallet1.balance == 64 p1, p2 = await wallet1.split(wallet1.proofs, 20) assert wallet1.balance == 64 assert sum_proofs(p1) == 44