diff --git a/cashu/core/crypto/nut19.py b/cashu/core/crypto/nut19.py new file mode 100644 index 00000000..dc3921ec --- /dev/null +++ b/cashu/core/crypto/nut19.py @@ -0,0 +1,34 @@ +from hashlib import sha256 +from typing import List + +from secp import PrivateKey, PublicKey + +from ..base import BlindedMessage + + +def construct_message(quote_id: str, outputs: List[BlindedMessage]) -> bytes: + serialized_outputs = bytes.fromhex("".join([o.B_ for o in outputs])) + msgbytes = sha256( + quote_id.encode("utf-8") + + serialized_outputs + ).digest() + return msgbytes + +def sign_mint_quote( + quote_id: str, + outputs: List[BlindedMessage], + privkey: PrivateKey, +) -> str: + msgbytes = construct_message(quote_id, outputs) + sig = privkey.schnorr_sign(msgbytes) + return sig.hex() + +def verify_mint_quote( + quote_id: str, + outputs: List[BlindedMessage], + pubkey: PublicKey, + signature: str, +) -> bool: + msgbytes = construct_message(quote_id, outputs) + sig = bytes.fromhex(signature) + return pubkey.schnorr_verify(msgbytes, sig) \ No newline at end of file diff --git a/cashu/core/errors.py b/cashu/core/errors.py index 24971547..082a5cc0 100644 --- a/cashu/core/errors.py +++ b/cashu/core/errors.py @@ -97,15 +97,15 @@ class QuoteNotPaidError(CashuError): def __init__(self): super().__init__(self.detail, code=2001) -class QuoteWitnessNotProvidedError(CashuError): - detail = "Witness not provided for mint quote" +class QuoteInvalidWitnessError(CashuError): + detail = "Witness is invalid for mint quote" code = 20008 def __init__(self): super().__init__(self.detail, code=20008) -class QuoteInvalidWitnessError(CashuError): - detail = "Witness is invalid for mint quote" +class QuoteWitnessNotProvidedError(CashuError): + detail = "Witness not provided for mint quote" code = 20009 def __init__(self): diff --git a/cashu/mint/features.py b/cashu/mint/features.py index e41e2a3f..870b26e1 100644 --- a/cashu/mint/features.py +++ b/cashu/mint/features.py @@ -13,11 +13,11 @@ MINT_NUT, MPP_NUT, P2PK_NUT, + QUOTE_SIGNATURE_NUT, RESTORE_NUT, SCRIPT_NUT, STATE_NUT, WEBSOCKETS_NUT, - QUOTE_SIGNATURE_NUT, ) from ..core.settings import settings from ..mint.protocols import SupportsBackends diff --git a/cashu/mint/ledger.py b/cashu/mint/ledger.py index 9a9e0abb..075edd98 100644 --- a/cashu/mint/ledger.py +++ b/cashu/mint/ledger.py @@ -38,8 +38,8 @@ LightningError, NotAllowedError, QuoteNotPaidError, + QuoteWitnessNotProvidedError, TransactionError, - QuoteWitnessNotProvidedError ) from ..core.helpers import sum_proofs from ..core.models import ( diff --git a/cashu/mint/verification.py b/cashu/mint/verification.py index 13f7a291..dbc9684d 100644 --- a/cashu/mint/verification.py +++ b/cashu/mint/verification.py @@ -7,20 +7,20 @@ BlindedSignature, Method, MintKeyset, + MintQuote, Proof, Unit, - MintQuote, ) -from ..core.crypto import b_dhke +from ..core.crypto import b_dhke, nut19 from ..core.crypto.secp import PublicKey from ..core.db import Connection, Database from ..core.errors import ( NoSecretInProofsError, NotAllowedError, + QuoteInvalidWitnessError, SecretTooLongError, TransactionError, TransactionUnitError, - QuoteInvalidWitnessError, ) from ..core.settings import settings from ..lightning.base import LightningBackend @@ -285,9 +285,5 @@ def _verify_quote_signature( ) -> None: """Verify signature on quote id and outputs""" pubkey = PublicKey(bytes.fromhex(quote.key), raw=True) # type: ignore - sigbytes = bytes.fromhex(signature) - serialized_outputs = b"".join([o.json().encode("utf-8") for o in outputs]) - msgbytes = quote.quote.encode("utf-8") + serialized_outputs - - if not pubkey.schnorr_verify(msgbytes, sigbytes, raw=True): + if not nut19.verify_mint_quote(quote.quote, outputs, pubkey, signature): raise QuoteInvalidWitnessError() \ No newline at end of file diff --git a/cashu/wallet/api/router.py b/cashu/wallet/api/router.py index c495b616..af21f5d3 100644 --- a/cashu/wallet/api/router.py +++ b/cashu/wallet/api/router.py @@ -206,7 +206,7 @@ async def swap( ) # mint token in incoming mint - await incoming_wallet.mint(amount, quote_id=mint_quote.quote) + await incoming_wallet.mint(amount, quote_id=mint_quote.quote, quote_key=mint_quote.key) await incoming_wallet.load_proofs(reload=True) mint_balances = await incoming_wallet.balance_per_minturl() return SwapResponse( diff --git a/cashu/wallet/cli/cli.py b/cashu/wallet/cli/cli.py index fef2d45f..d77ba621 100644 --- a/cashu/wallet/cli/cli.py +++ b/cashu/wallet/cli/cli.py @@ -348,7 +348,7 @@ def mint_invoice_callback(msg: JSONRPCNotficationParams): try: asyncio.run( wallet.mint( - int(amount), split=optional_split, quote_id=mint_quote.quote + int(amount), split=optional_split, quote_id=mint_quote.quote, quote_key=mint_quote.key ) ) # set paid so we won't react to any more callbacks @@ -402,7 +402,7 @@ def mint_invoice_callback(msg: JSONRPCNotficationParams): mint_quote_resp = await wallet.get_mint_quote(mint_quote.quote) if mint_quote_resp.state == MintQuoteState.paid.value: await wallet.mint( - amount, split=optional_split, quote_id=mint_quote.quote + amount, split=optional_split, quote_id=mint_quote.quote, quote_key=mint_quote.key ) paid = True else: @@ -423,7 +423,10 @@ def mint_invoice_callback(msg: JSONRPCNotficationParams): # user paid invoice before and wants to check the quote id elif amount and id: - await wallet.mint(amount, split=optional_split, quote_id=id) + quote = await get_bolt11_mint_quote(wallet.db, quote=id) + if not quote: + raise Exception("Quote not found") + await wallet.mint(amount, split=optional_split, quote_id=quote.quote, quote_key=quote.key) # close open subscriptions so we can exit try: diff --git a/cashu/wallet/v1_api.py b/cashu/wallet/v1_api.py index 89473beb..d5d47e29 100644 --- a/cashu/wallet/v1_api.py +++ b/cashu/wallet/v1_api.py @@ -333,13 +333,14 @@ async def get_mint_quote(self, quote: str) -> PostMintQuoteResponse: @async_set_httpx_client @async_ensure_mint_loaded async def mint( - self, outputs: List[BlindedMessage], quote: str + self, outputs: List[BlindedMessage], quote: str, witness: Optional[str] = None ) -> List[BlindedSignature]: """Mints new coins and returns a proof of promise. Args: outputs (List[BlindedMessage]): Outputs to mint new tokens with quote (str): Quote ID. + witness (Optional[str], optional): NUT-19 signature of the request. Returns: list[Proof]: List of proofs. @@ -347,16 +348,19 @@ async def mint( Raises: Exception: If the minting fails """ - outputs_payload = PostMintRequest(outputs=outputs, quote=quote) + outputs_payload = PostMintRequest(outputs=outputs, quote=quote, witness=witness) logger.trace("Checking Lightning invoice. POST /v1/mint/bolt11") def _mintrequest_include_fields(outputs: List[BlindedMessage]): """strips away fields from the model that aren't necessary for the /mint""" outputs_include = {"id", "amount", "B_"} - return { + res = { "quote": ..., "outputs": {i: outputs_include for i in range(len(outputs))}, } + if witness: + res["witness"] = ... + return res payload = outputs_payload.dict(include=_mintrequest_include_fields(outputs)) # type: ignore resp = await self.httpx.post( diff --git a/cashu/wallet/wallet.py b/cashu/wallet/wallet.py index 5fbda885..cb910bb9 100644 --- a/cashu/wallet/wallet.py +++ b/cashu/wallet/wallet.py @@ -19,7 +19,7 @@ Unit, WalletKeyset, ) -from ..core.crypto import b_dhke +from ..core.crypto import b_dhke, nut19 from ..core.crypto.keys import derive_keyset_id from ..core.crypto.secp import PrivateKey, PublicKey from ..core.db import Database @@ -30,13 +30,13 @@ sum_promises, sum_proofs, ) -from ..core.nuts import QUOTE_SIGNATURE_NUT from ..core.json_rpc.base import JSONRPCSubscriptionKinds from ..core.migrations import migrate_databases from ..core.models import ( PostCheckStateResponse, PostMeltQuoteResponse, ) +from ..core.nuts import QUOTE_SIGNATURE_NUT from ..core.p2pk import Secret from ..core.settings import settings from ..core.split import amount_split @@ -399,8 +399,7 @@ async def request_mint_with_callback( Returns: MintQuote: Mint Quote """ - privkey = await self.get_quote_ephemeral_key() - pubkey = (privkey.pubkey.serialize(True).hex() if privkey else None) + privkey, pubkey = await self.get_quote_ephemeral_keypair() mint_qoute = await super().mint_quote(amount, self.unit, memo, pubkey) subscriptions = SubscriptionManager(self.url) threading.Thread( @@ -411,9 +410,7 @@ async def request_mint_with_callback( filters=[mint_qoute.quote], callback=callback, ) - quote = MintQuote.from_resp_wallet(mint_qoute, self.url, amount, self.unit.name, - privkey.serialize() if privkey else None - ) + quote = MintQuote.from_resp_wallet(mint_qoute, self.url, amount, self.unit.name, privkey) await store_bolt11_mint_quote(db=self.db, quote=quote) return quote, subscriptions @@ -432,28 +429,28 @@ async def request_mint(self, Returns: MintQuote: Mint Quote """ - privkey = await self.get_quote_ephemeral_key() - pubkey = (privkey.pubkey.serialize(True).hex() if privkey else None) + privkey, pubkey = await self.get_quote_ephemeral_keypair() mint_quote_response = await super().mint_quote(amount, self.unit, memo, pubkey) quote = MintQuote.from_resp_wallet( - mint_quote_response, self.url, amount, self.unit.name, - privkey.serialize() if privkey else None, + mint_quote_response, self.url, amount, self.unit.name, privkey, ) await store_bolt11_mint_quote(db=self.db, quote=quote) return quote # TODO: generate secret with BIP39 (seed and specific derivation + counter) - async def get_quote_ephemeral_key(self) -> Union[PrivateKey, None]: - """Creates a secret key for a quote + async def get_quote_ephemeral_keypair(self) -> Tuple[Optional[str], Optional[str]]: + """Creates a keypair for a quote IF the mint supports NUT-19 """ if not self.mint_info: await self.load_mint_info() assert self.mint_info.nuts nut19 = self.mint_info.nuts.get(QUOTE_SIGNATURE_NUT, None) if nut19 and nut19["supported"]: - return PrivateKey() + privkey = PrivateKey() + pubkey = privkey.pubkey.serialize(True).hex() + return privkey.serialize(), pubkey else: - return None + return None, None def split_wallet_state(self, amount: int) -> List[int]: """This function produces an amount split for outputs based on the current state of the wallet. @@ -505,6 +502,7 @@ async def mint( amount: int, quote_id: str, split: Optional[List[int]] = None, + quote_key: Optional[str] = None, ) -> List[Proof]: """Mint tokens of a specific amount after an invoice has been paid. @@ -512,6 +510,7 @@ async def mint( amount (int): Total amount of tokens to be minted id (str): Id for looking up the paid Lightning invoice. split (Optional[List[str]], optional): List of desired amount splits to be minted. Total must sum to `amount`. + quote_key (Optional[str], optional): NUT-19 quote key for signing the request. Raises: Exception: Raises exception if `amounts` does not sum to `amount` or has unsupported value. @@ -545,8 +544,13 @@ async def mint( await self._check_used_secrets(secrets) outputs, rs = self._construct_outputs(amounts, secrets, rs) + witness: Optional[str] = None + if quote_key: + privkey = PrivateKey(bytes.fromhex(quote_key), raw=True) + witness = nut19.sign_mint_quote(quote_id, outputs, privkey) + # will raise exception if mint is unsuccessful - promises = await super().mint(outputs, quote_id) + promises = await super().mint(outputs, quote_id, witness) promises_keyset_id = promises[0].id await bump_secret_derivation(