diff --git a/.env.example b/.env.example index c1506f5..893ed44 100644 --- a/.env.example +++ b/.env.example @@ -17,7 +17,7 @@ TOR=TRUE # NOSTR # nostr private key to which to receive tokens to -NOSTR_PRIVATE_KEY=nostr_privatekey_here_hex_or_bech32 +NOSTR_PRIVATE_KEY=nostr_privatekey_here_hex_or_bech32_nsec # nostr relays (comma separated list) NOSTR_RELAYS=["wss://nostr-pub.wellorder.net"] diff --git a/README.md b/README.md index d5c1d82..d197cd1 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ cashu info Returns: ```bash -Version: 0.12.1 +Version: 0.12.2 Debug: False Cashu dir: /home/user/.cashu Wallet: wallet diff --git a/cashu/core/base.py b/cashu/core/base.py index 11eb042..4945b81 100644 --- a/cashu/core/base.py +++ b/cashu/core/base.py @@ -9,13 +9,62 @@ from pydantic import BaseModel from .crypto.keys import derive_keys, derive_keyset_id, derive_pubkeys from .crypto.secp import PrivateKey, PublicKey from .legacy import derive_keys_backwards_compatible_insecure_pre_0_12 +from .p2pk import sign_p2pk_sign # ------- PROOFS ------- +class SecretKind: + P2SH = "P2SH" + P2PK = "P2PK" + + +class Tags(BaseModel): + __root__: List[List[str]] + + def get_tag(self, tag_name: str) -> Union[str, None]: + for tag in self.__root__: + if tag[0] == tag_name: + return tag[1] + return None + + +class Secret(BaseModel): + """Describes spending condition encoded in the secret field of a Proof.""" + + kind: str + data: str + nonce: Union[None, str] = None + timelock: Union[None, int] = None + tags: Union[None, Tags] = None + + def serialize(self) -> str: + data_dict: Dict[str, Any] = { + "data": self.data, + "nonce": self.nonce or PrivateKey().serialize()[:32], + } + if self.timelock: + data_dict["timelock"] = self.timelock + if self.tags: + data_dict["tags"] = self.tags.__root__ + logger.debug( + json.dumps( + [self.kind, data_dict], + ) + ) + return json.dumps( + [self.kind, data_dict], + ) + + @classmethod + def deserialize(cls, data: str): + kind, kwargs = json.loads(data) + return cls(kind=kind, **kwargs) + + class P2SHScript(BaseModel): """ - Describes spending condition of a Proof + Unlocks P2SH spending condition of a Proof """ script: str @@ -34,7 +83,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 - script: Union[P2SHScript, None] = None # P2SH spending condition + p2pksig: Optional[str] = None # P2PK signature + p2shscript: Union[P2SHScript, None] = None # P2SH spending condition reserved: Union[ None, bool ] = False # whether this proof is reserved for sending, used for coin management in the wallet @@ -174,6 +224,19 @@ class PostSplitRequest(BaseModel): proofs: List[Proof] amount: int outputs: List[BlindedMessage] + # signature: Optional[str] = None + + # def sign(self, private_key: PrivateKey): + # """ + # Create a signed split request. The signature is over the `proofs` and `outputs` fields. + # """ + # # message = json.dumps(self.proofs).encode("utf-8") + json.dumps( + # # self.outputs + # # ).encode("utf-8") + # message = json.dumps(self.dict(include={"proofs": ..., "outputs": ...})).encode( + # "utf-8" + # ) + # self.signature = sign_p2pk_sign(message, private_key) class PostSplitResponse(BaseModel): diff --git a/cashu/core/p2pk.py b/cashu/core/p2pk.py new file mode 100644 index 0000000..4a2e4d0 --- /dev/null +++ b/cashu/core/p2pk.py @@ -0,0 +1,39 @@ +import hashlib + +from cashu.core.crypto.secp import PrivateKey, PublicKey + + +def sign_p2pk_sign(message: bytes, private_key: PrivateKey): + # ecdsa version + # signature = private_key.ecdsa_serialize(private_key.ecdsa_sign(message)) + signature = private_key.schnorr_sign( + hashlib.sha256(message).digest(), None, raw=True + ) + return signature.hex() + + +def verify_p2pk_signature(message: bytes, pubkey: PublicKey, signature: bytes): + # ecdsa version + # return pubkey.ecdsa_verify(message, pubkey.ecdsa_deserialize(signature)) + return pubkey.schnorr_verify( + hashlib.sha256(message).digest(), signature, None, raw=True + ) + + +if __name__ == "__main__": + # generate keys + private_key_bytes = b"12300000000000000000000000000123" + private_key = PrivateKey(private_key_bytes, raw=True) + print(private_key.serialize()) + public_key = private_key.pubkey + assert public_key + print(public_key.serialize().hex()) + + # sign message (=pubkey) + message = public_key.serialize() + signature = private_key.ecdsa_serialize(private_key.ecdsa_sign(message)) + print(signature.hex()) + + # verify + pubkey_verify = PublicKey(message, raw=True) + print(public_key.ecdsa_verify(message, pubkey_verify.ecdsa_deserialize(signature))) diff --git a/cashu/core/script.py b/cashu/core/script.py index 6961cef..8b33f5b 100644 --- a/cashu/core/script.py +++ b/cashu/core/script.py @@ -79,28 +79,28 @@ def step3_bob_verify_script(txin_signature, txin_redeemScript, tx): raise Exception("Script execution failed:", e) -def verify_script(txin_redeemScript_b64, txin_signature_b64): +def verify_bitcoin_script(txin_redeemScript_b64, txin_signature_b64): txin_redeemScript = CScript(base64.urlsafe_b64decode(txin_redeemScript_b64)) - print("Redeem script:", txin_redeemScript.__repr__()) + # print("Redeem script:", txin_redeemScript.__repr__()) # txin_redeemScript = CScript([2, 3, OP_LESSTHAN, OP_VERIFY]) txin_signature = CScript(value=base64.urlsafe_b64decode(txin_signature_b64)) txin_p2sh_address = step1_carol_create_p2sh_address(txin_redeemScript) - print(f"Bob recreates secret: P2SH:{txin_p2sh_address}") + # print(f"Bob recreates secret: P2SH:{txin_p2sh_address}") # MINT checks that P2SH:txin_p2sh_address has not been spent yet # ... tx, _ = step1_bob_carol_create_tx(txin_p2sh_address) - print( - f"Bob verifies:\nscript: {txin_redeemScript_b64}\nsignature: {txin_signature_b64}\n" - ) + # print( + # f"Bob verifies:\nscript: {txin_redeemScript_b64}\nsignature: {txin_signature_b64}\n" + # ) script_valid = step3_bob_verify_script(txin_signature, txin_redeemScript, tx) # MINT redeems tokens and stores P2SH:txin_p2sh_address # ... - if script_valid: - print("Successfull.") - else: - print("Error.") + # if script_valid: + # print("Successfull.") + # else: + # print("Error.") return txin_p2sh_address, script_valid diff --git a/cashu/core/settings.py b/cashu/core/settings.py index 6d7616c..80583b3 100644 --- a/cashu/core/settings.py +++ b/cashu/core/settings.py @@ -8,7 +8,7 @@ from pydantic import BaseSettings, Extra, Field env = Env() -VERSION = "0.12.1" +VERSION = "0.12.2" def find_env_file(): @@ -98,9 +98,15 @@ class WalletSettings(CashuSettings): ] ) + timelock_delta_seconds: int = Field(default=86400) # 1 day + class Settings( - EnvSettings, MintSettings, MintInformation, WalletSettings, CashuSettings + EnvSettings, + MintSettings, + MintInformation, + WalletSettings, + CashuSettings, ): version: str = Field(default=VERSION) diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 2010f48..fe5d903 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -1,5 +1,7 @@ import asyncio +import json import math +import time from typing import Dict, List, Literal, Optional, Set, Union from loguru import logger @@ -12,13 +14,16 @@ from ..core.base import ( MintKeyset, MintKeysets, Proof, + Secret, + SecretKind, ) from ..core.crypto import b_dhke from ..core.crypto.keys import derive_pubkey, random_hash from ..core.crypto.secp import PublicKey from ..core.db import Connection, Database from ..core.helpers import fee_reserve, sum_proofs -from ..core.script import verify_script +from ..core.p2pk import verify_p2pk_signature +from ..core.script import verify_bitcoin_script from ..core.settings import settings from ..core.split import amount_split from ..lightning.base import Wallet @@ -185,7 +190,7 @@ class Ledger: """Verifies that a secret is present and is not too long (DOS prevention).""" if proof.secret is None or proof.secret == "": raise Exception("no secret in proof.") - if len(proof.secret) > 64: + if len(proof.secret) > 512: raise Exception("secret too long.") return True @@ -209,35 +214,90 @@ class Ledger: C = PublicKey(bytes.fromhex(proof.C), raw=True) return b_dhke.verify(private_key_amount, C, proof.secret) - def _verify_script(self, idx: int, proof: Proof) -> bool: + def _verify_spending_conditions(self, proof: Proof) -> bool: """ - Verify bitcoin script in proof.script commited to by
in proof.secret. - proof.secret format: P2SH:
: + Verify spending conditions: + Condition: P2SH - Witnesses proof.p2shscript + Condition: P2PK - Witness: proof.p2pksig + """ - # if no script is given - if ( - proof.script is None - or proof.script.script is None - or proof.script.signature is None - ): - if len(proof.secret.split("P2SH:")) == 2: - # secret indicates a script but no script is present - return False - else: - # secret indicates no script, so treat script as valid + # P2SH + try: + secret = Secret.deserialize(proof.secret) + except Exception as e: + # secret is not a spending condition so we treat is a normal secret + return True + if secret.kind == SecretKind.P2SH: + # check if timelock is in the past + now = time.time() + if secret.timelock and secret.timelock < now: + logger.trace(f"p2sh timelock ran out ({secret.timelock}<{now}).") return True - # execute and verify P2SH - txin_p2sh_address, valid = verify_script( - proof.script.script, proof.script.signature - ) - if valid: + logger.trace(f"p2sh timelock still active ({secret.timelock}>{now}).") + + if ( + proof.p2shscript is None + or proof.p2shscript.script is None + or proof.p2shscript.signature is None + ): + # no script present although secret indicates one + raise Exception("no script in proof.") + + # execute and verify P2SH + txin_p2sh_address, valid = verify_bitcoin_script( + proof.p2shscript.script, proof.p2shscript.signature + ) + if not valid: + raise Exception("script invalid.") # check if secret commits to script address - # format: P2SH:
: - assert len(proof.secret.split(":")) == 3, "secret format wrong." - assert proof.secret.split(":")[1] == str( + assert secret.data == str( txin_p2sh_address - ), f"secret does not contain correct P2SH address: {proof.secret.split(':')[1]} is not {txin_p2sh_address}." - return valid + ), f"secret does not contain correct P2SH address: {secret.data} is not {txin_p2sh_address}." + return True + + # P2PK + if secret.kind == SecretKind.P2PK: + # check if timelock is in the past + now = time.time() + if secret.timelock and secret.timelock < now: + logger.trace(f"p2pk timelock ran out ({secret.timelock}<{now}).") + # check tags if a refund pubkey is present. + # If yes, we demand the signature to be from the refund pubkey + if secret.tags and secret.tags.get_tag("refund"): + signature_pubkey = secret.tags.get_tag("refund") + else: + # if no refund pubkey is present and the timelock has expired + # the token can be spent by anyone + return True + else: + # the timelock is still active, therefore we demand the signature + # to be from the pubkey in the data field + signature_pubkey = secret.data + logger.trace(f"p2pk timelock still active ({secret.timelock}>{now}).") + + # now we check the signature + if not proof.p2pksig: + # no signature present although secret indicates one + raise Exception("no p2pk signature in proof.") + + # we parse the secret as a P2PK commitment + # assert len(proof.secret.split(":")) == 5, "p2pk secret format invalid." + + # check signature proof.p2pksig against pubkey + # we expect the signature to be on the pubkey (=message) itself + assert signature_pubkey, "no signature pubkey present." + assert verify_p2pk_signature( + message=secret.serialize().encode("utf-8"), + pubkey=PublicKey(bytes.fromhex(signature_pubkey), raw=True), + signature=bytes.fromhex(proof.p2pksig), + ), "p2pk signature invalid." + logger.trace(proof.p2pksig) + logger.trace("p2pk signature valid.") + + return True + + # no spending contition + return True def _verify_outputs( self, total: int, amount: int, outputs: List[BlindedMessage] @@ -509,7 +569,7 @@ class Ledger: Exception: BDHKE verification failed. """ # Verify scripts - if not all([self._verify_script(i, p) for i, p in enumerate(proofs)]): + if not all([self._verify_spending_conditions(p) for p in proofs]): raise Exception("script validation failed.") # Verify secret criteria if not all([self._verify_secret_criteria(p) for p in proofs]): diff --git a/cashu/wallet/api/router.py b/cashu/wallet/api/router.py index fec9f61..c12f910 100644 --- a/cashu/wallet/api/router.py +++ b/cashu/wallet/api/router.py @@ -220,7 +220,6 @@ async def send_command( @router.post("/receive", name="Receive tokens", response_model=ReceiveResponse) async def receive_command( token: str = Query(default=None, description="Token to receive"), - lock: str = Query(default=None, description="Unlock tokens"), nostr: bool = Query(default=False, description="Receive tokens via nostr"), all: bool = Query(default=False, description="Receive all pending tokens"), ): @@ -228,7 +227,7 @@ async def receive_command( if token: tokenObj: TokenV3 = deserialize_token_from_string(token) await verify_mints(wallet, tokenObj) - balance = await receive(wallet, tokenObj, lock) + balance = await receive(wallet, tokenObj) elif nostr: await receive_nostr(wallet) balance = wallet.available_balance @@ -241,7 +240,7 @@ async def receive_command( token = await wallet.serialize_proofs(proofs) tokenObj = deserialize_token_from_string(token) await verify_mints(wallet, tokenObj) - balance = await receive(wallet, tokenObj, lock) + balance = await receive(wallet, tokenObj) else: raise Exception("enter token or use either flag --nostr or --all.") assert balance @@ -337,9 +336,8 @@ async def pending( @router.get("/lock", name="Generate receiving lock", response_model=LockResponse) async def lock(): - p2shscript = await wallet.create_p2sh_lock() - txin_p2sh_address = p2shscript.address - return LockResponse(P2SH=txin_p2sh_address) + address = await wallet.create_p2sh_address_and_store() + return LockResponse(P2SH=address) @router.get("/locks", name="Show unused receiving locks", response_model=LocksResponse) diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index b983534..1314a85 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -347,33 +347,25 @@ async def send_command( @cli.command("receive", help="Receive tokens.") @click.argument("token", type=str, default="") -@click.option("--lock", "-l", default=None, help="Unlock tokens.", type=str) @click.option( - "--nostr", "-n", default=False, is_flag=True, help="Receive tokens via nostr." + "--nostr", + "-n", + default=False, + is_flag=True, + help="Receive tokens via nostr." "receive", ) @click.option( "--all", "-a", default=False, is_flag=True, help="Receive all pending tokens." ) -@click.option( - "--verbose", - "-v", - help="Display more information.", - is_flag=True, - default=False, - type=bool, -) @click.pass_context @coro async def receive_cli( ctx: Context, token: str, - lock: str, nostr: bool, all: bool, - verbose: bool, ): wallet: Wallet = ctx.obj["WALLET"] - wallet.status() if token: tokenObj = deserialize_token_from_string(token) @@ -385,9 +377,9 @@ async def receive_cli( ) await verify_mint(mint_wallet, mint_url) - await receive(wallet, tokenObj, lock) + await receive(wallet, tokenObj) elif nostr: - await receive_nostr(wallet, verbose) + await receive_nostr(wallet) elif all: reserved_proofs = await get_reserved_proofs(wallet.db) if len(reserved_proofs): @@ -402,7 +394,7 @@ async def receive_cli( mint_url, os.path.join(settings.cashu_dir, wallet.name) ) await verify_mint(mint_wallet, mint_url) - await receive(wallet, tokenObj, lock) + await receive(wallet, tokenObj) else: print("Error: enter token or use either flag --nostr or --all.") @@ -515,24 +507,36 @@ async def pending(ctx: Context, legacy, number: int, offset: int): @cli.command("lock", help="Generate receiving lock.") +@click.option( + "--p2sh", + "-p", + default=False, + is_flag=True, + help="Create P2SH lock.", + type=bool, +) @click.pass_context @coro -async def lock(ctx): +async def lock(ctx, p2sh): wallet: Wallet = ctx.obj["WALLET"] - p2shscript = await wallet.create_p2sh_lock() - txin_p2sh_address = p2shscript.address - print("---- Pay to script hash (P2SH) ----\n") + if p2sh: + address = await wallet.create_p2sh_address_and_store() + lock_str = f"P2SH:{address}" + print("---- Pay to script hash (P2SH) ----\n") + else: + pubkey = await wallet.create_p2pk_pubkey() + lock_str = f"P2PK:{pubkey}" + print("---- Pay to public key (P2PK) ----\n") + print("Use a lock to receive tokens that only you can unlock.") print("") - print(f"Public receiving lock: P2SH:{txin_p2sh_address}") + print(f"Public receiving lock: {lock_str}") print("") print( - f"Anyone can send tokens to this lock:\n\ncashu send --lock P2SH:{txin_p2sh_address}" + f"Anyone can send tokens to this lock:\n\ncashu send --lock {lock_str}" ) print("") - print( - f"Only you can receive tokens from this lock:\n\ncashu receive --lock P2SH:{txin_p2sh_address}\n" - ) + print(f"Only you can receive tokens from this lock: cashu receive ") @cli.command("locks", help="Show unused receiving locks.") @@ -540,17 +544,21 @@ async def lock(ctx): @coro async def locks(ctx): wallet: Wallet = ctx.obj["WALLET"] + # P2PK lock + pubkey = await wallet.create_p2pk_pubkey() + lock_str = f"P2PK:{pubkey}" + print("---- Pay to public key (P2PK) lock ----\n") + print(f"Lock: {lock_str}") + # P2SH locks locks = await get_unused_locks(db=wallet.db) if len(locks): print("") - print(f"--------------------------\n") + print("---- Pay to script hash (P2SH) locks ----\n") for l in locks: - print(f"Address: {l.address}") + print(f"Lock: P2SH:{l.address}") print(f"Script: {l.script}") print(f"Signature: {l.signature}") print("") - print(f"Receive: cashu receive --lock P2SH:{l.address}") - print("") print(f"--------------------------\n") else: print("No locks found. Create one using: cashu lock") diff --git a/cashu/wallet/helpers.py b/cashu/wallet/helpers.py index 5eb2963..421ba9f 100644 --- a/cashu/wallet/helpers.py +++ b/cashu/wallet/helpers.py @@ -1,11 +1,13 @@ import base64 import json import os +from datetime import datetime, timedelta +from typing import List, Optional import click from loguru import logger -from ..core.base import TokenV1, TokenV2, TokenV3, TokenV3Token +from ..core.base import Secret, SecretKind, TokenV1, TokenV2, TokenV3, TokenV3Token from ..core.helpers import sum_proofs from ..core.migrations import migrate_databases from ..core.settings import settings @@ -21,12 +23,7 @@ async def init_wallet(wallet: Wallet, load_proofs: bool = True): await wallet.load_proofs(reload=True) -async def redeem_TokenV3_multimint( - wallet: Wallet, - token: TokenV3, - script, - signature, -): +async def redeem_TokenV3_multimint(wallet: Wallet, token: TokenV3): """ Helper function to iterate thruogh a token with multiple mints and redeem them from these mints one keyset at a time. @@ -43,9 +40,7 @@ async def redeem_TokenV3_multimint( await mint_wallet.load_mint() # redeem proofs of this keyset redeem_proofs = [p for p in t.proofs if p.id == keyset] - _, _ = await mint_wallet.redeem( - redeem_proofs, scnd_script=script, scnd_siganture=signature - ) + _, _ = await mint_wallet.redeem(redeem_proofs) print(f"Received {sum_proofs(redeem_proofs)} sats") @@ -113,20 +108,9 @@ def deserialize_token_from_string(token: str) -> TokenV3: async def receive( wallet: Wallet, tokenObj: TokenV3, - lock: str, ): - # check for P2SH locks - if lock: - # load the script and signature of this address from the database - assert len(lock.split("P2SH:")) == 2, Exception( - "lock has wrong format. Expected P2SH:
." - ) - address_split = lock.split("P2SH:")[1] - p2shscripts = await get_unused_locks(address_split, db=wallet.db) - assert len(p2shscripts) == 1, Exception("lock not found.") - script, signature = p2shscripts[0].script, p2shscripts[0].signature - else: - script, signature = None, None + logger.debug(f"receive: {tokenObj}") + proofs = [p for t in tokenObj.token for p in t.proofs] includes_mint_info: bool = any([t.mint for t in tokenObj.token]) @@ -135,13 +119,10 @@ async def receive( await redeem_TokenV3_multimint( wallet, tokenObj, - script, - signature, ) else: + # this is very legacy code, virtually any token should have mint information # no mint information present, we extract the proofs and use wallet's default mint - - proofs = [p for t in tokenObj.token for p in t.proofs] # first we load the mint URL from the DB keyset_in_token = proofs[0].id assert keyset_in_token @@ -155,7 +136,7 @@ async def receive( os.path.join(settings.cashu_dir, wallet.name), ) await mint_wallet.load_mint(keyset_in_token) - _, _ = await mint_wallet.redeem(proofs, script, signature) + _, _ = await mint_wallet.redeem(proofs) print(f"Received {sum_proofs(proofs)} sats") # reload main wallet so the balance updates @@ -170,19 +151,34 @@ async def send( """ Prints token to send to stdout. """ + secret_lock = None if lock: assert len(lock) > 21, Exception( "Error: lock has to be at least 22 characters long." ) - p2sh = False - if lock and len(lock.split("P2SH:")) == 2: - p2sh = True + if not lock.startswith("P2SH:") and not lock.startswith("P2PK:"): + raise Exception("Error: lock has to start with P2SH: or P2PK:") + # we add a time lock to the P2PK lock by appending the current unix time + 14 days + # we use datetime because it's easier to read + if lock.startswith("P2PK:") or lock.startswith("P2SH:"): + logger.debug(f"Locking token to: {lock}") + logger.debug( + f"Adding a time lock of {settings.timelock_delta_seconds} seconds." + ) + if lock.startswith("P2SH:"): + secret_lock = await wallet.create_p2sh_lock( + lock.split(":")[1], timelock=settings.timelock_delta_seconds + ) + elif lock.startswith("P2PK:"): + secret_lock = await wallet.create_p2pk_lock( + lock.split(":")[1], timelock=settings.timelock_delta_seconds + ) await wallet.load_proofs() if split: await wallet.load_mint() _, send_proofs = await wallet.split_to_send( - wallet.proofs, amount, lock, set_reserved=True + wallet.proofs, amount, secret_lock, set_reserved=True ) else: # get a proof with specific amount diff --git a/cashu/wallet/nostr.py b/cashu/wallet/nostr.py index b2260cc..1ea1918 100644 --- a/cashu/wallet/nostr.py +++ b/cashu/wallet/nostr.py @@ -3,6 +3,7 @@ import threading import time import click +from loguru import logger from requests.exceptions import ConnectionError from ..core.settings import settings @@ -10,7 +11,7 @@ from ..nostr.nostr.client.client import NostrClient from ..nostr.nostr.event import Event from ..nostr.nostr.key import PublicKey from .crud import get_nostr_last_check_timestamp, set_nostr_last_check_timestamp -from .helpers import receive +from .helpers import deserialize_token_from_string, receive from .wallet import Wallet @@ -95,7 +96,6 @@ async def send_nostr( async def receive_nostr( wallet: Wallet, - verbose: bool = False, ): if settings.nostr_private_key is None: print( @@ -107,34 +107,34 @@ async def receive_nostr( private_key=settings.nostr_private_key, relays=settings.nostr_relays ) print(f"Your nostr public key: {client.public_key.bech32()}") - if verbose: - print(f"Your nostr private key (do not share!): {client.private_key.bech32()}") + # print(f"Your nostr private key (do not share!): {client.private_key.bech32()}") await asyncio.sleep(2) def get_token_callback(event: Event, decrypted_content): - if verbose: - print( - f"From {event.public_key[:3]}..{event.public_key[-3:]}: {decrypted_content}" - ) + logger.debug( + f"From {event.public_key[:3]}..{event.public_key[-3:]}: {decrypted_content}" + ) try: # call the receive method - + tokenObj = deserialize_token_from_string(decrypted_content) asyncio.run( receive( wallet, - decrypted_content, - "", + tokenObj, ) ) except Exception as e: + logger.error(e) pass # determine timestamp of last check so we don't scan all historical DMs last_check = await get_nostr_last_check_timestamp(db=wallet.db) + logger.debug(f"Last check: {last_check}") if last_check: last_check -= 60 * 60 # 1 hour tolerance - await set_nostr_last_check_timestamp(timestamp=int(time.time()), db=wallet.db) + await set_nostr_last_check_timestamp(timestamp=int(time.time()), db=wallet.db) + logger.debug("Starting Nostr DM thread") t = threading.Thread( target=client.get_dm, args=(client.public_key, get_token_callback, {"since": last_check}), diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 91c8b2e..93065dc 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -5,6 +5,7 @@ import math import secrets as scrts import time import uuid +from datetime import datetime, timedelta from itertools import groupby from typing import Dict, List, Optional, Tuple, Union @@ -30,6 +31,9 @@ from ..core.base import ( PostMintResponseLegacy, PostSplitRequest, Proof, + Secret, + SecretKind, + Tags, TokenV2, TokenV2Mint, TokenV3, @@ -41,6 +45,7 @@ from ..core.crypto import b_dhke from ..core.crypto.secp import PrivateKey, PublicKey from ..core.db import Database from ..core.helpers import calculate_number_of_blank_outputs, sum_proofs +from ..core.p2pk import sign_p2pk_sign from ..core.script import ( step0_carol_checksig_redeemscrip, step0_carol_privkey, @@ -49,10 +54,12 @@ from ..core.script import ( ) from ..core.settings import settings from ..core.split import amount_split +from ..nostr.nostr.client.client import NostrClient from ..tor.tor import TorProxy from ..wallet.crud import ( get_keyset, get_proofs, + get_unused_locks, invalidate_proof, secret_used, store_keyset, @@ -259,20 +266,6 @@ class LedgerAPI: raise Exception(f"secret already used: {s}") logger.trace("Secret check complete.") - def generate_secrets(self, secret, n) -> List[str]: - """`secret` is the base string that will be tweaked n times - - Args: - secret (str): Base secret - n (int): Number of secrets to generate - - Returns: - List[str]: List of secrets - """ - if len(secret.split("P2SH:")) == 2: - return [f"{secret}:{self._generate_secret()}" for i in range(n)] - return [f"{i}:{secret}" for i in range(n)] - """ ENDPOINTS """ @@ -438,14 +431,14 @@ class LedgerAPI: @async_set_requests async def split( - self, proofs, amount, scnd_secret: Optional[str] = None + self, proofs, amount, secret_lock: Optional[Secret] = None ) -> Tuple[List[Proof], List[Proof]]: """Consume proofs and create new promises based on amount split. - If scnd_secret is None, random secrets will be generated for the tokens to keep (frst_outputs) + If secret_lock is None, random secrets will be generated for the tokens to keep (frst_outputs) and the promises to send (scnd_outputs). - If scnd_secret is provided, the wallet will create blinded secrets with those to attach a + If secret_lock is provided, the wallet will create blinded secrets with those to attach a predefined spending condition to the tokens they want to send.""" logger.debug("Calling split. POST /split") total = sum_proofs(proofs) @@ -454,18 +447,18 @@ class LedgerAPI: scnd_outputs = amount_split(scnd_amt) amounts = frst_outputs + scnd_outputs - if scnd_secret is None: + if secret_lock is None: secrets = [self._generate_secret() for _ in range(len(amounts))] else: - scnd_secrets = self.generate_secrets(scnd_secret, len(scnd_outputs)) - logger.debug(f"Creating proofs with custom secrets: {scnd_secrets}") - assert len(scnd_secrets) == len( + secret_locks = [secret_lock.serialize() for i in range(len(scnd_outputs))] + logger.debug(f"Creating proofs with custom secrets: {secret_locks}") + assert len(secret_locks) == len( scnd_outputs - ), "number of scnd_secrets does not match number of ouptus." + ), "number of secret_locks does not match number of ouptus." # append predefined secrets (to send) to random secrets (to keep) secrets = [ self._generate_secret() for s in range(len(frst_outputs)) - ] + scnd_secrets + ] + secret_locks assert len(secrets) == len( amounts @@ -477,7 +470,7 @@ class LedgerAPI: # construct payload def _splitrequest_include_fields(proofs): """strips away fields from the model that aren't necessary for the /split""" - proofs_include = {"id", "amount", "secret", "C", "script"} + proofs_include = {"id", "amount", "secret", "C", "p2shscript", "p2pksig"} return { "amount": ..., "outputs": ..., @@ -572,6 +565,8 @@ class LedgerAPI: class Wallet(LedgerAPI): """Minimal wallet wrapper.""" + private_key: Optional[PrivateKey] = None + def __init__(self, url: str, db: str, name: str = "no_name"): super().__init__(url) self.db = Database("wallet", db) @@ -579,6 +574,17 @@ class Wallet(LedgerAPI): self.name = name logger.debug(f"Wallet initalized with mint URL {url}") + # temporarily, we use the NostrClient to generate keys + try: + nostr_pk = NostrClient( + private_key=settings.nostr_private_key, connect=False + ).private_key + self.private_key = ( + PrivateKey(bytes.fromhex(nostr_pk.hex()), raw=True) or None + ) + except Exception as e: + pass + # ---------- API ---------- async def load_mint(self, keyset_id: str = ""): @@ -648,27 +654,67 @@ class Wallet(LedgerAPI): self.proofs += proofs return proofs + async def add_witnesses_to_proofs(self, proofs: List[Proof]): + """Adds witnesses to proofs for P2SH or P2PK redemption.""" + + p2sh_script, p2sh_signature = None, None + p2pk_signatures = None + + # iterate through proofs and produce witnesses for each + + # first we check whether all tokens have serialized secrets as their secret + try: + for p in proofs: + Secret.deserialize(p.secret) + except: + # if not, we do not add witnesses (treat as regular token secret) + return proofs + # P2SH scripts + if all([Secret.deserialize(p.secret).kind == SecretKind.P2SH for p in proofs]): + # Quirk: we use a single P2SH script and signature pair for all tokens in proofs + address = Secret.deserialize(proofs[0].secret).data + p2shscripts = await get_unused_locks(address, db=self.db) + assert len(p2shscripts) == 1, Exception("lock not found.") + p2sh_script, p2sh_signature = ( + p2shscripts[0].script, + p2shscripts[0].signature, + ) + logger.debug(f"Unlock script: {p2sh_script} signature: {p2sh_signature}") + + # attach unlock scripts to proofs + for p in proofs: + p.p2shscript = P2SHScript(script=p2sh_script, signature=p2sh_signature) + + # P2PK signatures + elif all( + [Secret.deserialize(p.secret).kind == SecretKind.P2PK for p in proofs] + ): + p2pk_signatures = await self.sign_p2pk_with_privatekey(proofs) + logger.debug(f"Unlock signature: {p2pk_signatures}") + + # attach unlock signatures to proofs + assert len(proofs) == len(p2pk_signatures), "wrong number of signatures" + for p, s in zip(proofs, p2pk_signatures): + p.p2pksig = s + + return proofs + async def redeem( self, proofs: List[Proof], - scnd_script: Optional[str] = None, - scnd_siganture: Optional[str] = None, ): - if scnd_script and scnd_siganture: - logger.debug(f"Unlock script: {scnd_script}") - # attach unlock scripts to proofs - for p in proofs: - p.script = P2SHScript(script=scnd_script, signature=scnd_siganture) + proofs = await self.add_witnesses_to_proofs(proofs) return await self.split(proofs, sum_proofs(proofs)) async def split( self, proofs: List[Proof], amount: int, - scnd_secret: Optional[str] = None, + secret_lock: Optional[Secret] = None, ): assert len(proofs) > 0, ValueError("no proofs provided.") - frst_proofs, scnd_proofs = await super().split(proofs, amount, scnd_secret) + frst_proofs, scnd_proofs = await super().split(proofs, amount, secret_lock) + if len(frst_proofs) == 0 and len(scnd_proofs) == 0: raise Exception("received no splits.") @@ -957,7 +1003,7 @@ class Wallet(LedgerAPI): self, proofs: List[Proof], amount: int, - scnd_secret: Optional[str] = None, + secret_lock: Optional[Secret] = None, set_reserved: bool = False, ): """ @@ -966,25 +1012,26 @@ class Wallet(LedgerAPI): Args: proofs (List[Proof]): Proofs to split amount (int): Amount to split to - scnd_secret (Optional[str], optional): If set, a custom secret is used to lock new outputs. Defaults to None. + secret_lock (Optional[str], optional): If set, a custom secret is used to lock new outputs. Defaults to None. set_reserved (bool, optional): If set, the proofs are marked as reserved. Should be set to False if a payment attempt is made with the split that could fail (like a Lightning payment). Should be set to True if the token to be sent is displayed to the user to be then sent to someone else. Defaults to False. """ - if scnd_secret: - logger.debug(f"Spending conditions: {scnd_secret}") + if secret_lock: + logger.debug(f"Spending conditions: {secret_lock}") spendable_proofs = await self._select_proofs_to_send(proofs, amount) keep_proofs, send_proofs = await self.split( - spendable_proofs, amount, scnd_secret + spendable_proofs, amount, secret_lock ) if set_reserved: await self.set_reserved(send_proofs, reserved=True) return keep_proofs, send_proofs - # ---------- P2SH ---------- + # ---------- P2SH and P2PK ---------- - async def create_p2sh_lock(self): + 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_p2sh_address = step1_carol_create_p2sh_address(txin_redeemScript) @@ -997,7 +1044,61 @@ class Wallet(LedgerAPI): address=str(txin_p2sh_address), ) await store_p2sh(p2shScript, db=self.db) - return p2shScript + assert p2shScript.address + return p2shScript.address + + async def create_p2pk_pubkey(self): + assert ( + self.private_key + ), "No private key set in settings. Set NOSTR_PRIVATE_KEY in .env" + public_key = self.private_key.pubkey + # logger.debug(f"Private key: {self.private_key.bech32()}") + assert public_key + return public_key.serialize().hex() + + async def create_p2pk_lock( + self, + pubkey: str, + timelock: Optional[int] = None, + tags: Optional[Tags] = None, + ): + return Secret( + kind=SecretKind.P2PK, + data=pubkey, + timelock=int((datetime.now() + timedelta(seconds=timelock)).timestamp()) + if timelock + else None, + tags=tags, + ) + + async def create_p2sh_lock( + self, + address: str, + timelock: Optional[int] = None, + tags: Optional[Tags] = None, + ): + return Secret( + kind=SecretKind.P2SH, + data=address, + timelock=int((datetime.now() + timedelta(seconds=timelock)).timestamp()) + if timelock + else None, + tags=tags, + ) + + async def sign_p2pk_with_privatekey(self, proofs: List[Proof]) -> List[str]: + assert ( + self.private_key + ), "No private key set in settings. Set NOSTR_PRIVATE_KEY in .env" + private_key = self.private_key + assert private_key.pubkey + return [ + sign_p2pk_sign( + message=proof.secret.encode("utf-8"), + private_key=private_key, + ) + for proof in proofs + ] # ---------- BALANCE CHECKS ---------- diff --git a/setup.py b/setup.py index 6a2a070..7025cc3 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ entry_points = {"console_scripts": ["cashu = cashu.wallet.cli.cli:cli"]} setuptools.setup( name="cashu", - version="0.12.1", + version="0.12.2", description="Ecash wallet and mint for Bitcoin Lightning", long_description=long_description, long_description_content_type="text/markdown", diff --git a/tests/conftest.py b/tests/conftest.py index 25cf777..6f61c89 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ import multiprocessing import os +import secrets import shutil import time from pathlib import Path @@ -58,6 +59,7 @@ def mint(): settings.port = 3337 settings.mint_url = "http://localhost:3337" settings.port = settings.mint_listen_port + settings.nostr_private_key = secrets.token_hex(32) # wrong private key config = uvicorn.Config( "cashu.mint.app:app", port=settings.mint_listen_port, diff --git a/tests/test_wallet.py b/tests/test_wallet.py index 68c051e..34c7a6b 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -1,10 +1,12 @@ -import time +import asyncio +import secrets from typing import List import pytest import pytest_asyncio -from cashu.core.base import Proof +from cashu.core.base import Proof, Secret, SecretKind, Tags +from cashu.core.crypto.secp import PrivateKey, PublicKey from cashu.core.helpers import async_unwrap, sum_proofs from cashu.core.migrations import migrate_databases from cashu.core.settings import settings @@ -20,9 +22,10 @@ async def assert_err(f, msg): try: await f except Exception as exc: - assert exc.args[0] == msg, Exception( - f"Expected error: {msg}, got: {exc.args[0]}" - ) + if str(exc.args[0]) != msg: + raise Exception(f"Expected error: {msg}, got: {exc.args[0]}") + return + raise Exception(f"Expected error: {msg}, got no error") def assert_amt(proofs: List[Proof], expected: int): @@ -242,43 +245,113 @@ async def test_split_invalid_amount(wallet1: Wallet): @pytest.mark.asyncio -async def test_split_with_secret(wallet1: Wallet): +async def test_create_p2pk_pubkey(wallet1: Wallet): await wallet1.mint(64) - secret = f"asdasd_{time.time()}" - w1_frst_proofs, w1_scnd_proofs = await wallet1.split( - wallet1.proofs, 32, scnd_secret=secret - ) - # check if index prefix is in secret - assert w1_scnd_proofs[0].secret == "0:" + secret + pubkey = await wallet1.create_p2pk_pubkey() + PublicKey(bytes.fromhex(pubkey), raw=True) @pytest.mark.asyncio -async def test_redeem_without_secret(wallet1: Wallet): +async def test_p2pk(wallet1: Wallet, wallet2: Wallet): await wallet1.mint(64) - # strip away the secrets - w1_scnd_proofs_manipulated = wallet1.proofs.copy() - for p in w1_scnd_proofs_manipulated: - p.secret = "" - await assert_err( - wallet1.redeem(w1_scnd_proofs_manipulated), - "Mint Error: no secret in proof.", + pubkey_wallet2 = await wallet2.create_p2pk_pubkey() + # p2pk test + secret_lock = await wallet1.create_p2pk_lock(pubkey_wallet2) # sender side + _, send_proofs = await wallet1.split_to_send( + wallet1.proofs, 8, secret_lock=secret_lock ) + await wallet2.redeem(send_proofs) @pytest.mark.asyncio -async def no_test_p2sh(wallet1: Wallet, wallet2: Wallet): +async def test_p2pk_receive_with_wrong_private_key(wallet1: Wallet, wallet2: Wallet): await wallet1.mint(64) - # p2sh test - p2shscript = await wallet1.create_p2sh_lock() - txin_p2sh_address = p2shscript.address - lock = f"P2SH:{txin_p2sh_address}" - _, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8, lock) - - assert send_proofs[0].secret.startswith("P2SH:") - - frst_proofs, scnd_proofs = await wallet2.redeem( - send_proofs, scnd_script=p2shscript.script, scnd_siganture=p2shscript.signature + pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # receiver side + # sender side + secret_lock = await wallet1.create_p2pk_lock(pubkey_wallet2) # sender side + _, send_proofs = await wallet1.split_to_send( + wallet1.proofs, 8, secret_lock=secret_lock ) + # receiver side: wrong private key + wallet1.private_key = PrivateKey() # wrong private key + await assert_err(wallet1.redeem(send_proofs), "Mint Error: p2pk signature invalid.") + + +@pytest.mark.asyncio +async def test_p2pk_short_timelock_receive_with_wrong_private_key( + wallet1: Wallet, wallet2: Wallet +): + await wallet1.mint(64) + pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # receiver side + # sender side + secret_lock = await wallet1.create_p2pk_lock( + pubkey_wallet2, timelock=4 + ) # sender side + _, send_proofs = await wallet1.split_to_send( + wallet1.proofs, 8, secret_lock=secret_lock + ) + # receiver side: wrong private key + wallet1.private_key = PrivateKey() # wrong private key + await assert_err(wallet1.redeem(send_proofs), "Mint Error: p2pk signature invalid.") + await asyncio.sleep(6) + await wallet1.redeem(send_proofs) + + +@pytest.mark.asyncio +async def test_p2pk_timelock_with_refund_pubkey(wallet1: Wallet, wallet2: Wallet): + await wallet1.mint(64) + pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # receiver side + # sender side + garbage_pubkey = PrivateKey().pubkey + assert garbage_pubkey + secret_lock = await wallet1.create_p2pk_lock( + garbage_pubkey.serialize().hex(), # create lock to unspendable pubkey + timelock=4, # timelock + tags=Tags(__root__=[["refund", pubkey_wallet2]]), # refund pubkey + ) # sender side + _, send_proofs = await wallet1.split_to_send( + wallet1.proofs, 8, secret_lock=secret_lock + ) + # receiver side: can't redeem since we used a garbage pubkey + await assert_err(wallet2.redeem(send_proofs), "Mint Error: p2pk signature invalid.") + await asyncio.sleep(6) + # we can now redeem because of the refund timelock + await wallet2.redeem(send_proofs) + + +@pytest.mark.asyncio +async def test_p2pk_timelock_with_wrong_refund_pubkey(wallet1: Wallet, wallet2: Wallet): + await wallet1.mint(64) + pubkey_wallet2 = await wallet2.create_p2pk_pubkey() # receiver side + # sender side + garbage_pubkey = PrivateKey().pubkey + garbage_pubkey_2 = PrivateKey().pubkey + assert garbage_pubkey + assert garbage_pubkey_2 + secret_lock = await wallet1.create_p2pk_lock( + garbage_pubkey.serialize().hex(), # create lock to unspendable pubkey + timelock=4, # timelock + tags=Tags( + __root__=[["refund", garbage_pubkey_2.serialize().hex()]] + ), # refund pubkey + ) # sender side + _, send_proofs = await wallet1.split_to_send( + wallet1.proofs, 8, secret_lock=secret_lock + ) + # receiver side: can't redeem since we used a garbage pubkey + await assert_err(wallet2.redeem(send_proofs), "Mint Error: p2pk signature invalid.") + await asyncio.sleep(6) + # we still can't redeem it because we used garbage_pubkey_2 as a refund pubkey + await assert_err(wallet2.redeem(send_proofs), "Mint Error: p2pk signature invalid.") + + +@pytest.mark.asyncio +async def test_p2sh(wallet1: Wallet, wallet2: Wallet): + await wallet1.mint(64) + _ = await wallet1.create_p2sh_address_and_store() # receiver side + _, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8) # sender side + + frst_proofs, scnd_proofs = await wallet2.redeem(send_proofs) # receiver side assert len(frst_proofs) == 0 assert len(scnd_proofs) == 1 assert sum_proofs(scnd_proofs) == 8 @@ -286,40 +359,11 @@ async def no_test_p2sh(wallet1: Wallet, wallet2: Wallet): @pytest.mark.asyncio -async def test_p2sh_receive_wrong_script(wallet1: Wallet, wallet2: Wallet): +async def test_p2sh_receive_with_wrong_wallet(wallet1: Wallet, wallet2: Wallet): await wallet1.mint(64) - # p2sh test - p2shscript = await wallet1.create_p2sh_lock() - txin_p2sh_address = p2shscript.address - lock = f"P2SH:{txin_p2sh_address}" - _, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8, lock) # type: ignore - - wrong_script = "asad" + p2shscript.script - - await assert_err( - wallet2.redeem( - send_proofs, scnd_script=wrong_script, scnd_siganture=p2shscript.signature - ), - "Mint Error: ('Script verification failed:', VerifyScriptError('scriptPubKey returned false'))", - ) - assert wallet2.balance == 0 - - -@pytest.mark.asyncio -async def test_p2sh_receive_wrong_signature(wallet1: Wallet, wallet2: Wallet): - await wallet1.mint(64) - # p2sh test - p2shscript = await wallet1.create_p2sh_lock() - txin_p2sh_address = p2shscript.address - lock = f"P2SH:{txin_p2sh_address}" - _, send_proofs = await wallet1.split_to_send(wallet1.proofs, 8, lock) # type: ignore - - wrong_signature = "asda" + p2shscript.signature - - await assert_err( - wallet2.redeem( - send_proofs, scnd_script=p2shscript.script, scnd_siganture=wrong_signature - ), - "Mint Error: ('Script evaluation failed:', EvalScriptError('EvalScript: OP_RETURN called'))", - ) - assert wallet2.balance == 0 + wallet1_address = await wallet1.create_p2sh_address_and_store() # receiver side + secret_lock = await wallet1.create_p2sh_lock(wallet1_address) # sender side + _, send_proofs = await wallet1.split_to_send( + wallet1.proofs, 8, secret_lock + ) # sender side + await assert_err(wallet2.redeem(send_proofs), "lock not found.") # wrong receiver