Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Stamps #295

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 10 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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ mypy:
poetry run mypy cashu --ignore-missing

flake8:
poetry run flake8 cashu
poetry run flake8 cashu tests

format: isort black

Expand Down
60 changes: 47 additions & 13 deletions cashu/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,22 @@ class P2SHScript(BaseModel):
address: Union[str, None] = None


class ProofY(BaseModel):
"""
ProofY is a proof that is used to stamp a token.
"""

id: str
amount: int
C: str
Y: str


class StampSignature(BaseModel):
e: str
s: str


class Proof(BaseModel):
"""
Value token
Expand All @@ -165,6 +181,7 @@ class Proof(BaseModel):
amount: int = 0
secret: str = "" # secret or message to be blinded and signed
C: str = "" # signature on secret, unblinded by wallet
stamp: Union[StampSignature, None] = None # stamp signature
p2pksigs: Union[List[str], None] = [] # P2PK signature
p2shscript: Union[P2SHScript, None] = None # P2SH spending condition
reserved: Union[
Expand All @@ -177,13 +194,15 @@ class Proof(BaseModel):
time_reserved: Union[None, str] = ""
derivation_path: Union[None, str] = "" # derivation path of the proof

def to_dict(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)

def to_dict_no_secret(self):
# dictionary but without the secret itself
return dict(id=self.id, amount=self.amount, C=self.C)
def to_dict(self, include_stamps=False):
# dictionary without the fields that don't need to be sent to Carol
d: Dict[str, Any] = dict(
id=self.id, amount=self.amount, secret=self.secret, C=self.C
)
if include_stamps:
assert self.stamp, "Stamp signature is missing"
d["stamp"] = self.stamp.dict()
return d

def __getitem__(self, key):
return self.__getattribute__(key)
Expand Down Expand Up @@ -360,6 +379,17 @@ class PostRestoreResponse(BaseModel):
promises: List[BlindedSignature] = []


# ------- API: STAMP -------


class PostStampRequest(BaseModel):
proofys: List[ProofY]


class PostStampResponse(BaseModel):
stamps: List[StampSignature]


# ------- KEYSETS -------


Expand Down Expand Up @@ -551,8 +581,10 @@ 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_stamps=False):
return_dict = dict(
proofs=[p.to_dict(include_stamps=include_stamps) for p in self.proofs]
)
if self.mint:
return_dict.update(dict(mint=self.mint)) # type: ignore
return return_dict
Expand All @@ -566,8 +598,10 @@ 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_stamps=False):
return_dict = dict(
token=[t.to_dict(include_stamps=include_stamps) for t in self.token]
)
if self.memo:
return_dict.update(dict(memo=self.memo)) # type: ignore
return return_dict
Expand All @@ -594,14 +628,14 @@ 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_stamps=False) -> str:
"""
Takes a TokenV3 and serializes it as "cashuA<json_urlsafe_base64>.
"""
prefix = "cashuA"
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_stamps=include_stamps)).encode()
).decode()
return tokenv3_serialized
62 changes: 61 additions & 1 deletion cashu/core/crypto/b_dhke.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"""

import hashlib
from typing import Optional
from typing import Optional, Tuple

from secp256k1 import PrivateKey, PublicKey

Expand Down Expand Up @@ -74,6 +74,66 @@ def verify(a: PrivateKey, C: PublicKey, secret_msg: str) -> bool:
return C == Y.mult(a) # type: ignore


# stamps
"""
Proves that a in A = a*G is the same as a in C_ = a*Y

Bob:
R1 = rG
R2 = rY
e = hash(R1, R2, Y, C)
s = r + e*a

Alice/Carol:
Y = hash_to_curve(x)
R1 = sG - eA
R2 = sY - eC
e == hash(R1, R2, Y, C) (verification)

"""


def hash_e(*args) -> bytes:
"""Hashes a list of public keys to a 32 byte value"""
e_ = ""
for pk in args:
assert isinstance(pk, PublicKey), "object is not of type PublicKey"
e_ += pk.serialize(compressed=False).hex()
e = hashlib.sha256(e_.encode("utf-8")).digest()
return e


def stamp_step1_bob(
Y: PublicKey, C: 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()
assert p.pubkey
R1: PublicKey = p.pubkey # R1 = pG
R2: PublicKey = Y.mult(p) # type: ignore # R2 = pY
print(R1.serialize().hex(), R2.serialize().hex())
e = hash_e(R1, R2, Y, C)
s = p.tweak_add(a.tweak_mul(e)) # s = p + ea
spk = PrivateKey(s, raw=True)
epk = PrivateKey(e, raw=True)
return epk, spk


def stamp_step2_alice_verify(
Y: PublicKey, C: PublicKey, s: PrivateKey, e: PrivateKey, A: PublicKey
) -> bool:
assert s.pubkey
R1: PublicKey = s.pubkey - A.mult(e) # type: ignore # R1 = sG - eA
R2: PublicKey = Y.mult(s) - C.mult(e) # type: ignore # R2 = sY - eC
print(R1.serialize().hex(), R2.serialize().hex())
e_bytes = e.private_key
return e_bytes == hash_e(R1, R2, Y, C)


# Below is a test of a simple positive and negative case

# # Alice's keys
Expand Down
17 changes: 17 additions & 0 deletions cashu/mint/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
MintKeyset,
MintKeysets,
Proof,
ProofY,
Secret,
SecretKind,
SigFlags,
StampSignature,
)
from ..core.crypto import b_dhke
from ..core.crypto.keys import derive_pubkey, random_hash
Expand Down Expand Up @@ -1118,3 +1120,18 @@ async def restore(
return_outputs.append(output)
logger.trace(f"promise found: {promise}")
return return_outputs, promises

async def stamp(self, proofys: List[ProofY]) -> List[StampSignature]:
signatures: List[StampSignature] = []
for proofy in proofys:
assert proofy.id
private_key_amount = self.keysets.keysets[proofy.id].private_keys[
proofy.amount
]
e, s = b_dhke.stamp_step1_bob(
Y=PublicKey(bytes.fromhex(proofy.Y), raw=True),
C=PublicKey(bytes.fromhex(proofy.C), raw=True),
a=private_key_amount,
)
signatures.append(StampSignature(e=e.serialize(), s=s.serialize()))
return signatures
15 changes: 15 additions & 0 deletions cashu/mint/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
PostSplitRequest,
PostSplitResponse,
PostSplitResponse_Deprecated,
PostStampRequest,
PostStampResponse,
)
from ..core.errors import CashuError
from ..core.settings import settings
Expand Down Expand Up @@ -282,3 +284,16 @@ async def restore(payload: PostMintRequest) -> PostRestoreResponse:
assert payload.outputs, Exception("no outputs provided.")
outputs, promises = await ledger.restore(payload.outputs)
return PostRestoreResponse(outputs=outputs, promises=promises)


@router.post(
"/stamp",
name="Stamp",
summary="Request signatures on proofs",
response_model=PostStampResponse,
response_description=("List of signatures on proofs."),
)
async def stamp(payload: PostStampRequest) -> PostStampResponse:
assert payload.proofys, Exception("no proofs provided")
signatures = await ledger.stamp(payload.proofys)
return PostStampResponse(stamps=signatures)
7 changes: 7 additions & 0 deletions cashu/wallet/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,10 @@ async def m009_privatekey_and_determinstic_key_derivation(db: Database):
"""
)
# await db.execute("INSERT INTO secret_derivation (counter) VALUES (0)")


async def m010_proofs_add_stamps(db: Database):
await db.execute("ALTER TABLE proofs ADD COLUMN stamp_e TEXT")
await db.execute("ALTER TABLE proofs ADD COLUMN stamp_r TEXT")
await db.execute("ALTER TABLE proofs_used ADD COLUMN stamp_e TEXT")
await db.execute("ALTER TABLE proofs_used ADD COLUMN stamp_r TEXT")
80 changes: 77 additions & 3 deletions cashu/wallet/wallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@
PostMintResponse,
PostRestoreResponse,
PostSplitRequest,
PostStampRequest,
PostStampResponse,
Proof,
ProofY,
Secret,
SecretKind,
SigFlags,
Expand Down Expand Up @@ -608,6 +611,48 @@ async def restore_promises(
returnObj = PostRestoreResponse.parse_obj(reponse_dict)
return returnObj.outputs, returnObj.promises

@async_set_requests
async def get_proofs_stamps(self, proofs: List[Proof]):
"""
Sends a list of ProofYs (Proofs with Y but without secret) to the mint
and receives a list of signatures. These signatures can used by someone
Carol to ensure that they are from the same public key that also blind-
signed the ecash (with signatures Proof.C).
"""
proofys: List[ProofY] = []
for proof in proofs:
assert proof.id
proofys.append(
ProofY(
id=proof.id,
amount=proof.amount,
Y=b_dhke.hash_to_curve(proof.secret.encode()).serialize().hex(),
C=proof.C,
)
)

payload = PostStampRequest(proofys=proofys)

def _get_proofs_stamps_include_fields(proofs):
"""strips away fields from the model that aren't necessary for this endpoint"""
proofs_include = {"id", "amount", "Y", "C"}
return {
"proofys": {i: proofs_include for i in range(len(proofs))},
}

resp = self.s.post(
self.url + "/stamp",
json=payload.dict(include=_get_proofs_stamps_include_fields(proofs)), # type: ignore
)
self.raise_on_error(resp)

return_dict = resp.json()
stamps = PostStampResponse.parse_obj(return_dict)
assert len(proofs) == len(
stamps.stamps
), "number of proofs and stamps do not match"
return stamps


class Wallet(LedgerAPI):
"""Minimal wallet wrapper."""
Expand Down Expand Up @@ -1185,6 +1230,31 @@ async def pay_lightning(
async def check_proof_state(self, proofs):
return await super().check_proof_state(proofs)

async def get_proofs_stamps(self, proofs: List[Proof]):
"""Sends a list of ProofYs (Proofs with Y but without secret) to the mint
and receives a list of stamps. These stamps can used by another ecash receiver
to veriify that the ecash they are receiving is indeed signed by the public
key of the mint without having to contact the mint.

Args:
proofs (List[Proof]): List of proofs to be stamped

Returns:
_type_: _description_
"""

stamp_response = await super().get_proofs_stamps(proofs)
stamps = stamp_response.stamps
for proof, stamp in zip(proofs, stamps):
assert b_dhke.stamp_step2_alice_verify(
Y=b_dhke.hash_to_curve(proof.secret.encode()),
C=PublicKey(bytes.fromhex(proof.C), raw=True),
s=PrivateKey(bytes.fromhex(stamp.s), raw=True),
e=PrivateKey(bytes.fromhex(stamp.e), raw=True),
A=self.public_keys[proof.amount],
), "stamp verification failed."
return True

# ---------- TOKEN MECHANIS ----------

async def _store_proofs(self, proofs):
Expand Down Expand Up @@ -1273,7 +1343,11 @@ 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,
legacy=False,
include_stamps=False,
) -> str:
"""Produces sharable token with proofs and mint information.

Expand All @@ -1298,8 +1372,8 @@ async def serialize_proofs(
# ).decode()

# V3 tokens
token = await self._make_token(proofs, include_mints)
return token.serialize()
token = await self._make_token(proofs=proofs, include_mints=include_mints)
return token.serialize(include_stamps=include_stamps)

async def _make_token_v2(self, proofs: List[Proof], include_mints=True) -> TokenV2:
"""
Expand Down
Loading