Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions cashu/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ class Proof(BaseModel):
C: str = "" # signature on secret, unblinded by wallet
dleq: Optional[DLEQWallet] = None # DLEQ proof
witness: Union[None, str] = None # witness for spending condition
p2pk_e: Union[None, str] = None # NUT-28 P2BK ephemeral pubkey E (33-byte SEC1 hex)

# whether this proof is reserved for sending, used for coin management in the wallet
reserved: Union[None, bool] = False
Expand Down Expand Up @@ -173,6 +174,9 @@ def to_dict(self, include_dleq=False):
if self.witness:
return_dict["witness"] = self.witness

if self.p2pk_e:
return_dict["p2pk_e"] = self.p2pk_e

return return_dict

def to_base64(self):
Expand Down Expand Up @@ -1168,6 +1172,7 @@ class TokenV4Proof(BaseModel):
c: bytes # signature
d: Optional[TokenV4DLEQ] = None # DLEQ proof
w: Optional[str] = None # witness
pe: Optional[bytes] = None # NUT-28 P2BK ephemeral pubkey E (33-byte SEC1)

@classmethod
def from_proof(cls, proof: Proof, include_dleq=False):
Expand All @@ -1185,6 +1190,7 @@ def from_proof(cls, proof: Proof, include_dleq=False):
else None
),
w=proof.witness,
pe=bytes.fromhex(proof.p2pk_e) if proof.p2pk_e else None,
)


Expand Down Expand Up @@ -1255,6 +1261,7 @@ def proofs(self) -> List[Proof]:
else None
),
witness=p.w,
p2pk_e=p.pe.hex() if p.pe else None,
)
for token in self.t
for p in token.p
Expand Down Expand Up @@ -1294,6 +1301,7 @@ def from_tokenv3(cls, tokenv3: TokenV3):
else None
),
w=p.witness,
pe=bytes.fromhex(p.p2pk_e) if p.p2pk_e else None,
)
for p in proofs
],
Expand Down Expand Up @@ -1321,6 +1329,10 @@ def serialize_to_dict(self, include_dleq=False):
for proof in token["p"]:
if not proof.get("w"):
del proof["w"]
# strip pe if not present
if not proof.get("pe"):
if "pe" in proof:
del proof["pe"]
# optional memo
if self.d:
return_dict.update(dict(d=self.d))
Expand Down Expand Up @@ -1382,6 +1394,7 @@ def to_tokenv3(self) -> TokenV3:
else None
),
witness=p.w,
p2pk_e=p.pe.hex() if p.pe else None,
)
for p in token.p
],
Expand Down
154 changes: 154 additions & 0 deletions cashu/core/p2bk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import hashlib
import os
from typing import List, Optional, Tuple

from .crypto.secp import PrivateKey, PublicKey

# Domain separator for P2BK blinding scalar derivation
P2BK_DOMAIN_SEPARATOR = b"Cashu_P2BK_v1"

# secp256k1 curve order
SECP256K1_ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141

def _compressed_pubkey(pubkey_hex: str) -> str:
"""Ensure a pubkey is in compressed SEC1 format (33 bytes / 66 hex chars).
Silently adds '02' prefix to bare 32-byte (BIP-340 / Nostr) x-only keys.
"""
raw = bytes.fromhex(pubkey_hex)
if len(raw) == 32:
# x-only key, add 02 prefix
return "02" + pubkey_hex
if len(raw) == 33 and raw[0] in (0x02, 0x03):
return pubkey_hex
raise ValueError(f"Invalid pubkey length: {len(raw)} bytes")

def ecdh_shared_secret(point: PublicKey, scalar: PrivateKey) -> bytes:
"""Compute x-only ECDH shared secret Zx = x(scalar * point)"""
shared_point = point.multiply(bytes.fromhex(scalar.to_hex()))
# compressed format is prefix (1 byte) + x-coordinate (32 bytes)
compressed = shared_point.format(compressed=True)
return compressed[1:] # strip the 02/03 prefix to get Zx

def derive_blinding_scalar(zx: bytes, slot_index: int) -> int:
"""Derive a deterministic blinding scalar r_i from ECDH shared secret and slot index."""
i_byte = bytes([slot_index & 0xFF])
data = P2BK_DOMAIN_SEPARATOR + zx + i_byte
Comment thread
KvngMikey marked this conversation as resolved.
r = int.from_bytes(hashlib.sha256(data).digest(), "big")
if r == 0 or r >= SECP256K1_ORDER:
# retry with 0xff appended
data_retry = data + b"\xff"
r = int.from_bytes(hashlib.sha256(data_retry).digest(), "big")
if r == 0 or r >= SECP256K1_ORDER:
raise ValueError("P2BK: blinding scalar derivation failed")
return r

def _scalar_to_privkey(scalar: int) -> PrivateKey:
"""Convert an integer scalar to a PrivateKey."""
return PrivateKey(scalar.to_bytes(32, "big"))

def _pubkey_x(pubkey: PublicKey) -> bytes:
"""Get the x-coordinate (32 bytes) from a compressed public key."""
return pubkey.format(compressed=True)[1:]

def blind_pubkeys(
data_pubkey: str,
additional_pubkeys: List[str],
refund_pubkeys: List[str],
ephemeral_privkey: Optional[PrivateKey] = None,
) -> Tuple[str, List[str], List[str], str]:
"""Blind all pubkeys in a P2PK secret using per-key ECDH.

Each pubkey P_i gets its own shared secret Zx_i = x(e * P_i), as required
by NUT-28: "For each receiver key P, compute: Unique shared secret Zx = x(eP)."

Args:
data_pubkey: The main locking pubkey.
additional_pubkeys: Additional pubkeys from the "pubkeys" tag.
refund_pubkeys: Refund pubkeys from the "refund" tag.
ephemeral_privkey: Optional ephemeral private key. Generated if None.

Returns:
Tuple of (blinded_data_pubkey, blinded_additional, blinded_refund, ephemeral_pubkey_hex)
"""
if ephemeral_privkey is None:
ephemeral_privkey = PrivateKey(os.urandom(32))

assert ephemeral_privkey.public_key
ephemeral_pubkey_hex = ephemeral_privkey.public_key.format(compressed=True).hex()

# collect all pubkeys in slot order: [data, ...pubkeys, ...refund]
all_pubkeys = [data_pubkey] + additional_pubkeys + refund_pubkeys
blinded = []
for i, pk_hex in enumerate(all_pubkeys):
pk_hex = _compressed_pubkey(pk_hex)
pk = PublicKey(bytes.fromhex(pk_hex))
# Per-key ECDH: Zx_i = x(e * P_i)
zx_i = ecdh_shared_secret(pk, ephemeral_privkey)
r_i = derive_blinding_scalar(zx_i, i)
blinding_point = _scalar_to_privkey(r_i).public_key
assert blinding_point
blinded_pk = pk + blinding_point # type: ignore[operator] # P' = P + r_i*G
blinded.append(blinded_pk.format(compressed=True).hex())

# split back into data, pubkeys, refund
blinded_data = blinded[0]
blinded_additional = blinded[1 : 1 + len(additional_pubkeys)]
blinded_refund = blinded[1 + len(additional_pubkeys) :]

return blinded_data, blinded_additional, blinded_refund, ephemeral_pubkey_hex


def derive_blinded_private_key(
privkey: PrivateKey,
ephemeral_pubkey_hex: str,
blinded_pubkey_hex: str,
slot_index: int,
) -> Optional[PrivateKey]:
"""derive the blinded private key for a given slot.

Args:
privkey: Receiver's long-lived private key p.
ephemeral_pubkey_hex: Sender's ephemeral public key E (hex, 33 bytes).
blinded_pubkey_hex: The blinded public key P' from the secret.
slot_index: The slot index i.

Returns:
The blinded PrivateKey k, or None if this slot is not for this key.
"""
ephemeral_pubkey_hex = _compressed_pubkey(ephemeral_pubkey_hex)
E = PublicKey(bytes.fromhex(ephemeral_pubkey_hex))

# Zx = x(p * E)
zx = ecdh_shared_secret(E, privkey)

r_i = derive_blinding_scalar(zx, slot_index)

# R_i = r_i * G
r_i_key = _scalar_to_privkey(r_i)
R_i = r_i_key.public_key
assert R_i

# Unblind: P = P' - R_i
blinded_pubkey_hex = _compressed_pubkey(blinded_pubkey_hex)
P_prime = PublicKey(bytes.fromhex(blinded_pubkey_hex))
P = P_prime - R_i # type: ignore

# Verify x(P) == x(p*G)
assert privkey.public_key
pG = privkey.public_key
if _pubkey_x(P) != _pubkey_x(pG):
return None # this slot is not for this key

# Parity check
p_int = int.from_bytes(bytes.fromhex(privkey.to_hex()), "big")
P_prefix = P.format(compressed=True)[0]
pG_prefix = pG.format(compressed=True)[0]

if P_prefix == pG_prefix:
# standard derivation: k = (p + r_i) mod n
k = (p_int + r_i) % SECP256K1_ORDER
else:
# negated derivation: k = (-p + r_i) mod n
k = ((-p_int) + r_i) % SECP256K1_ORDER

return _scalar_to_privkey(k)
5 changes: 3 additions & 2 deletions cashu/wallet/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ async def store_proof(
await (conn or db).execute(
"""
INSERT INTO proofs
(id, amount, C, secret, time_created, derivation_path, dleq, mint_id, melt_id)
VALUES (:id, :amount, :C, :secret, :time_created, :derivation_path, :dleq, :mint_id, :melt_id)
(id, amount, C, secret, time_created, derivation_path, dleq, mint_id, melt_id, p2pk_e)
VALUES (:id, :amount, :C, :secret, :time_created, :derivation_path, :dleq, :mint_id, :melt_id, :p2pk_e)
""",
{
"id": proof.id,
Expand All @@ -42,6 +42,7 @@ async def store_proof(
"dleq": json.dumps(proof.dleq.model_dump()) if proof.dleq else "",
"mint_id": proof.mint_id,
"melt_id": proof.melt_id,
"p2pk_e": proof.p2pk_e,
},
)

Expand Down
22 changes: 21 additions & 1 deletion cashu/wallet/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ async def send(
Prints token to send to stdout.
"""
secret_lock = None
p2pk_e = None
if lock:
assert len(lock) > 21, Exception(
"Error: lock has to be at least 22 characters long."
Expand All @@ -169,8 +170,26 @@ async def send(
tags=tags,
)
logger.debug(f"Secret lock: {secret_lock}")
elif lock.startswith("P2BK:") or lock.startswith("P2BK-SIGALL:"):
sigall = lock.startswith("P2BK-SIGALL:")
logger.debug(f"Locking token with P2BK to: {lock}")
logger.debug(
f"Adding a time lock of {settings.locktime_delta_seconds} seconds."
)
tags = None
if refund_pubkeys:
tags = Tags()
tags["refund"] = refund_pubkeys
secret_lock, p2pk_e = await wallet.create_p2bk_lock(
lock.split(":")[1],
locktime_seconds=settings.locktime_delta_seconds,
sig_all=sigall,
n_sigs=1,
tags=tags,
)
Comment thread
KvngMikey marked this conversation as resolved.
logger.debug(f"P2BK secret lock: {secret_lock}, ephemeral pubkey: {p2pk_e}")
else:
raise Exception("Error: lock has to start with P2PK: or P2PK-SIGALL:")
raise Exception("Error: lock has to start with P2PK:, P2PK-SIGALL:, P2BK:, or P2BK-SIGALL:")

await wallet.load_proofs()

Expand All @@ -181,6 +200,7 @@ async def send(
amount,
set_reserved=False, # we set reserved later
secret_lock=secret_lock,
p2pk_e=p2pk_e,
)
else:
send_proofs, fees = await wallet.select_to_send(
Expand Down
10 changes: 10 additions & 0 deletions cashu/wallet/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,3 +334,13 @@ async def m016_remove_nostr_table(db: Database):
DROP TABLE IF EXISTS nostr;
"""
)


async def m017_add_p2pk_e_to_proofs(db: Database):
"""
Column to store the NUT-28 P2BK ephemeral pubkey (p2pk_e / E) on proofs.
Without this, P2BK tokens become unspendable after a wallet restart.
"""
async with db.connect() as conn:
await conn.execute("ALTER TABLE proofs ADD COLUMN p2pk_e TEXT")
await conn.execute("ALTER TABLE proofs_used ADD COLUMN p2pk_e TEXT")
Loading
Loading