Skip to content

Commit

Permalink
Support NUT-XX (signatures on quotes) for mint and wallet side (#670)
Browse files Browse the repository at this point in the history
* nut-19 sign mint quote

* ephemeral key for quote

* `mint` adjustments + crypto/nut19.py

* wip: mint side working

* fix import

* post-merge fixups

* more fixes

* make format

* move nut19 to nuts directory

* `key` -> `privkey` and `pubkey`

* make format

* mint_info method for nut-19 support

* fix tests imports

* fix signature missing positional argument + fix db migration format not correctly escaped + pass in NUT-19 keypair to `request_mint` `request_mint_with_callback`

* make format

* fix `get_invoice_status`

* rename to xx

* nutxx -> nut20

* mypy

* remove `mint_quote_signature_required` as per spec

* wip edits

* clean up

* fix tests

* fix deprecated api tests

* fix redis tests

* fix cache tests

* fix regtest mint external

* fix mint regtest

* add test without signature

* test pubkeys in quotes

* wip

* add compat

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
  • Loading branch information
lollerfirst and callebtc authored Dec 14, 2024
1 parent 399c201 commit d98d166
Show file tree
Hide file tree
Showing 30 changed files with 507 additions and 245 deletions.
5 changes: 5 additions & 0 deletions cashu/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,8 @@ class MintQuote(LedgerEvent):
paid_time: Union[int, None] = None
expiry: Optional[int] = None
mint: Optional[str] = None
privkey: Optional[str] = None
pubkey: Optional[str] = None

@classmethod
def from_row(cls, row: Row):
Expand All @@ -436,6 +438,8 @@ def from_row(cls, row: Row):
state=MintQuoteState(row["state"]),
created_time=created_time,
paid_time=paid_time,
pubkey=row["pubkey"] if "pubkey" in row.keys() else None,
privkey=row["privkey"] if "privkey" in row.keys() else None,
)

@classmethod
Expand All @@ -458,6 +462,7 @@ def from_resp_wallet(cls, mint_quote_resp, mint: str, amount: int, unit: str):
mint=mint,
expiry=mint_quote_resp.expiry,
created_time=int(time.time()),
pubkey=mint_quote_resp.pubkey,
)

@property
Expand Down
16 changes: 16 additions & 0 deletions cashu/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,19 @@ class QuoteNotPaidError(CashuError):

def __init__(self):
super().__init__(self.detail, code=2001)


class QuoteSignatureInvalidError(CashuError):
detail = "Signature for mint request invalid"
code = 20008

def __init__(self):
super().__init__(self.detail, code=20008)


class QuoteRequiresPubkeyError(CashuError):
detail = "Pubkey required for mint quote"
code = 20009

def __init__(self):
super().__init__(self.detail, code=20009)
15 changes: 11 additions & 4 deletions cashu/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,21 +128,25 @@ class PostMintQuoteRequest(BaseModel):
description: Optional[str] = Field(
default=None, max_length=settings.mint_max_request_length
) # invoice description
pubkey: Optional[str] = Field(
default=None, max_length=settings.mint_max_request_length
) # NUT-20 quote lock pubkey


class PostMintQuoteResponse(BaseModel):
quote: str # quote id
request: str # input payment request
paid: Optional[bool] # DEPRECATED as per NUT-04 PR #141
state: Optional[str] # state of the quote
state: Optional[str] # state of the quote (optional for backwards compat)
expiry: Optional[int] # expiry of the quote
pubkey: Optional[str] = None # NUT-20 quote lock pubkey
paid: Optional[bool] = None # DEPRECATED as per NUT-04 PR #141

@classmethod
def from_mint_quote(self, mint_quote: MintQuote) -> "PostMintQuoteResponse":
def from_mint_quote(cls, mint_quote: MintQuote) -> "PostMintQuoteResponse":
to_dict = mint_quote.dict()
# turn state into string
to_dict["state"] = mint_quote.state.value
return PostMintQuoteResponse.parse_obj(to_dict)
return cls.parse_obj(to_dict)


# ------- API: MINT -------
Expand All @@ -153,6 +157,9 @@ class PostMintRequest(BaseModel):
outputs: List[BlindedMessage] = Field(
..., max_items=settings.mint_max_request_length
)
signature: Optional[str] = Field(
default=None, max_length=settings.mint_max_request_length
) # NUT-20 quote signature


class PostMintResponse(BaseModel):
Expand Down
Empty file added cashu/core/nuts/__init__.py
Empty file.
41 changes: 41 additions & 0 deletions cashu/core/nuts/nut20.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from hashlib import sha256
from typing import List

from ..base import BlindedMessage
from ..crypto.secp import PrivateKey, PublicKey


def generate_keypair() -> tuple[str, str]:
privkey = PrivateKey()
assert privkey.pubkey
pubkey = privkey.pubkey
return privkey.serialize(), pubkey.serialize(True).hex()


def construct_message(quote_id: str, outputs: List[BlindedMessage]) -> bytes:
serialized_outputs = b"".join([o.B_.encode("utf-8") 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],
private_key: str,
) -> str:
privkey = PrivateKey(bytes.fromhex(private_key), raw=True)
msgbytes = construct_message(quote_id, outputs)
sig = privkey.schnorr_sign(msgbytes, None, raw=True)
return sig.hex()


def verify_mint_quote(
quote_id: str,
outputs: List[BlindedMessage],
public_key: str,
signature: str,
) -> bool:
pubkey = PublicKey(bytes.fromhex(public_key), raw=True)
msgbytes = construct_message(quote_id, outputs)
sig = bytes.fromhex(signature)
return pubkey.schnorr_verify(msgbytes, sig, None, raw=True)
1 change: 1 addition & 0 deletions cashu/core/nuts.py → cashu/core/nuts/nuts.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@
MPP_NUT = 15
WEBSOCKETS_NUT = 17
CACHE_NUT = 19
MINT_QUOTE_SIGNATURE_NUT = 20
19 changes: 0 additions & 19 deletions cashu/core/p2pk.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,22 +50,3 @@ def verify_schnorr_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)))
5 changes: 3 additions & 2 deletions cashu/mint/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -421,8 +421,8 @@ async def store_mint_quote(
await (conn or db).execute(
f"""
INSERT INTO {db.table_with_schema('mint_quotes')}
(quote, method, request, checking_id, unit, amount, paid, issued, state, created_time, paid_time)
VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :paid, :issued, :state, :created_time, :paid_time)
(quote, method, request, checking_id, unit, amount, paid, issued, state, created_time, paid_time, pubkey)
VALUES (:quote, :method, :request, :checking_id, :unit, :amount, :paid, :issued, :state, :created_time, :paid_time, :pubkey)
""",
{
"quote": quote.quote,
Expand All @@ -440,6 +440,7 @@ async def store_mint_quote(
"paid_time": db.to_timestamp(
db.timestamp_from_seconds(quote.paid_time) or ""
),
"pubkey": quote.pubkey or ""
},
)

Expand Down
4 changes: 3 additions & 1 deletion cashu/mint/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
MeltMethodSetting,
MintMethodSetting,
)
from ..core.nuts import (
from ..core.nuts.nuts import (
CACHE_NUT,
DLEQ_NUT,
FEE_RETURN_NUT,
HTLC_NUT,
MELT_NUT,
MINT_NUT,
MINT_QUOTE_SIGNATURE_NUT,
MPP_NUT,
P2PK_NUT,
RESTORE_NUT,
Expand Down Expand Up @@ -75,6 +76,7 @@ def add_supported_features(
mint_features[P2PK_NUT] = supported_dict
mint_features[DLEQ_NUT] = supported_dict
mint_features[HTLC_NUT] = supported_dict
mint_features[MINT_QUOTE_SIGNATURE_NUT] = supported_dict
return mint_features

def add_mpp_features(
Expand Down
10 changes: 8 additions & 2 deletions cashu/mint/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
LightningError,
NotAllowedError,
QuoteNotPaidError,
QuoteSignatureInvalidError,
TransactionError,
)
from ..core.helpers import sum_proofs
Expand Down Expand Up @@ -459,6 +460,7 @@ async def mint_quote(self, quote_request: PostMintQuoteRequest) -> MintQuote:
state=MintQuoteState.unpaid,
created_time=int(time.time()),
expiry=expiry,
pubkey=quote_request.pubkey,
)
await self.crud.store_mint_quote(quote=quote, db=self.db)
await self.events.submit(quote)
Expand Down Expand Up @@ -518,13 +520,14 @@ async def mint(
*,
outputs: List[BlindedMessage],
quote_id: str,
signature: Optional[str] = None,
) -> List[BlindedSignature]:
"""Mints new coins if quote with `quote_id` was paid. Ingest blind messages `outputs` and returns blind signatures `promises`.
Args:
outputs (List[BlindedMessage]): Outputs (blinded messages) to sign.
quote_id (str): Mint quote id.
keyset (Optional[MintKeyset], optional): Keyset to use. If not provided, uses active keyset. Defaults to None.
witness (Optional[str], optional): NUT-19 witness signature. Defaults to None.
Raises:
Exception: Validation of outputs failed.
Expand All @@ -536,7 +539,6 @@ async def mint(
Returns:
List[BlindedSignature]: Signatures on the outputs.
"""

await self._verify_outputs(outputs)
sum_amount_outputs = sum([b.amount for b in outputs])
# we already know from _verify_outputs that all outputs have the same unit because they have the same keyset
Expand All @@ -549,6 +551,7 @@ async def mint(
raise TransactionError("Mint quote already issued.")
if not quote.paid:
raise QuoteNotPaidError()

previous_state = quote.state
await self.db_write._set_mint_quote_pending(quote_id=quote_id)
try:
Expand All @@ -558,6 +561,9 @@ async def mint(
raise TransactionError("amount to mint does not match quote amount")
if quote.expiry and quote.expiry > int(time.time()):
raise TransactionError("quote expired")
if not self._verify_mint_quote_witness(quote, outputs, signature):
raise QuoteSignatureInvalidError()

promises = await self._generate_promises(outputs)
except Exception as e:
await self.db_write._unset_mint_quote_pending(
Expand Down
9 changes: 9 additions & 0 deletions cashu/mint/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -838,3 +838,12 @@ async def m022_quote_set_states_to_values(db: Database):
await conn.execute(
f"UPDATE {db.table_with_schema('mint_quotes')} SET state = '{mint_quote_states.value}' WHERE state = '{mint_quote_states.name}'"
)

async def m023_add_key_to_mint_quote_table(db: Database):
async with db.connect() as conn:
await conn.execute(
f"""
ALTER TABLE {db.table_with_schema('mint_quotes')}
ADD COLUMN pubkey TEXT DEFAULT NULL
"""
)
6 changes: 5 additions & 1 deletion cashu/mint/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ async def mint_quote(
paid=quote.paid, # deprecated
state=quote.state.value,
expiry=quote.expiry,
pubkey=quote.pubkey,
)
logger.trace(f"< POST /v1/mint/quote/bolt11: {resp}")
return resp
Expand All @@ -198,6 +199,7 @@ async def get_mint_quote(request: Request, quote: str) -> PostMintQuoteResponse:
paid=mint_quote.paid, # deprecated
state=mint_quote.state.value,
expiry=mint_quote.expiry,
pubkey=mint_quote.pubkey,
)
logger.trace(f"< GET /v1/mint/quote/bolt11/{quote}")
return resp
Expand Down Expand Up @@ -251,7 +253,9 @@ async def mint(
"""
logger.trace(f"> POST /v1/mint/bolt11: {payload}")

promises = await ledger.mint(outputs=payload.outputs, quote_id=payload.quote)
promises = await ledger.mint(
outputs=payload.outputs, quote_id=payload.quote, signature=payload.signature
)
blinded_signatures = PostMintResponse(signatures=promises)
logger.trace(f"< POST /v1/mint/bolt11: {blinded_signatures}")
return blinded_signatures
Expand Down
15 changes: 15 additions & 0 deletions cashu/mint/verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
BlindedSignature,
Method,
MintKeyset,
MintQuote,
Proof,
Unit,
)
Expand All @@ -20,6 +21,7 @@
TransactionError,
TransactionUnitError,
)
from ..core.nuts import nut20
from ..core.settings import settings
from ..lightning.base import LightningBackend
from ..mint.crud import LedgerCrud
Expand Down Expand Up @@ -277,3 +279,16 @@ def _verify_and_get_unit_method(
)

return unit, method

def _verify_mint_quote_witness(
self,
quote: MintQuote,
outputs: List[BlindedMessage],
signature: Optional[str],
) -> bool:
"""Verify signature on quote id and outputs"""
if not quote.pubkey:
return True
if not signature:
return False
return nut20.verify_mint_quote(quote.quote, outputs, quote.pubkey, signature)
25 changes: 19 additions & 6 deletions cashu/wallet/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,9 @@ 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,
)
)
# set paid so we won't react to any more callbacks
Expand Down Expand Up @@ -402,7 +404,9 @@ 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,
)
paid = True
else:
Expand All @@ -423,7 +427,14 @@ 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,
)

# close open subscriptions so we can exit
try:
Expand Down Expand Up @@ -921,11 +932,13 @@ async def invoices(ctx, paid: bool, unpaid: bool, pending: bool, mint: bool):
print("No invoices found.")
return

async def _try_to_mint_pending_invoice(amount: int, id: str) -> Optional[MintQuote]:
async def _try_to_mint_pending_invoice(
amount: int, quote_id: str
) -> Optional[MintQuote]:
try:
proofs = await wallet.mint(amount, id)
proofs = await wallet.mint(amount, quote_id)
print(f"Received {wallet.unit.str(sum_proofs(proofs))}")
return await get_bolt11_mint_quote(db=wallet.db, quote=id)
return await get_bolt11_mint_quote(db=wallet.db, quote=quote_id)
except Exception as e:
logger.error(f"Could not mint pending invoice: {e}")
return None
Expand Down
Loading

0 comments on commit d98d166

Please sign in to comment.