Skip to content

jmcore.crypto

jmcore.crypto

Cryptographic primitives for JoinMarket.

Attributes

NICK_HASH_LENGTH = 10 module-attribute

NICK_MAX_ENCODED = 14 module-attribute

Classes

CryptoError

Bases: Exception

Source code in jmcore/src/jmcore/crypto.py
23
24
class CryptoError(Exception):
    pass

KeyPair

Source code in jmcore/src/jmcore/crypto.py
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
class KeyPair:
    def __init__(self, private_key: PrivateKey | None = None):
        if private_key is None:
            private_key = PrivateKey()
        self._private_key = private_key
        self._public_key = private_key.public_key

    @property
    def private_key(self) -> PrivateKey:
        return self._private_key

    @property
    def public_key(self) -> PublicKey:
        return self._public_key

    def sign(self, message: bytes) -> bytes:
        """Sign a message with SHA256 hashing."""
        return self._private_key.sign(message)

    def verify(self, message: bytes, signature: bytes) -> bool:
        try:
            return self._public_key.verify(signature, message)
        except Exception:
            return False

    def public_key_bytes(self) -> bytes:
        return self._public_key.format(compressed=True)

    def public_key_hex(self) -> str:
        return self.public_key_bytes().hex()
Attributes
private_key: PrivateKey property
public_key: PublicKey property
Functions
__init__(private_key: PrivateKey | None = None)
Source code in jmcore/src/jmcore/crypto.py
262
263
264
265
266
def __init__(self, private_key: PrivateKey | None = None):
    if private_key is None:
        private_key = PrivateKey()
    self._private_key = private_key
    self._public_key = private_key.public_key
public_key_bytes() -> bytes
Source code in jmcore/src/jmcore/crypto.py
286
287
def public_key_bytes(self) -> bytes:
    return self._public_key.format(compressed=True)
public_key_hex() -> str
Source code in jmcore/src/jmcore/crypto.py
289
290
def public_key_hex(self) -> str:
    return self.public_key_bytes().hex()
sign(message: bytes) -> bytes

Sign a message with SHA256 hashing.

Source code in jmcore/src/jmcore/crypto.py
276
277
278
def sign(self, message: bytes) -> bytes:
    """Sign a message with SHA256 hashing."""
    return self._private_key.sign(message)
verify(message: bytes, signature: bytes) -> bool
Source code in jmcore/src/jmcore/crypto.py
280
281
282
283
284
def verify(self, message: bytes, signature: bytes) -> bool:
    try:
        return self._public_key.verify(signature, message)
    except Exception:
        return False

NickIdentity

Encapsulates a JoinMarket nick identity with signing capabilities.

Each participant has a nick identity consisting of: - A private key for signing messages - A public key derived from the private key - A nick derived from hash(hex(pubkey))

All private messages must be signed with this key for nick authentication.

Source code in jmcore/src/jmcore/crypto.py
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
class NickIdentity:
    """
    Encapsulates a JoinMarket nick identity with signing capabilities.

    Each participant has a nick identity consisting of:
    - A private key for signing messages
    - A public key derived from the private key
    - A nick derived from hash(hex(pubkey))

    All private messages must be signed with this key for nick authentication.
    """

    def __init__(self, version: int = 5, private_key_bytes: bytes | None = None):
        """
        Create a new nick identity.

        Args:
            version: JoinMarket protocol version (default 5)
            private_key_bytes: Optional 32-byte private key (random if not provided)
        """
        if private_key_bytes is None:
            # Match reference: hashlib.sha256(os.urandom(16)).digest()
            private_key_bytes = hashlib.sha256(secrets.token_bytes(16)).digest()

        self._private_key_bytes = private_key_bytes
        self._private_key = PrivateKey(private_key_bytes)
        self._public_key = self._private_key.public_key
        self._version = version

        # Derive nick from pubkey hash
        # Reference uses COMPRESSED pubkey (33 bytes) - the 0x01 suffix indicates compressed
        pubkey_bytes = self._public_key.format(compressed=True)
        pubkey_hex = binascii.hexlify(pubkey_bytes)
        nick_pkh_raw = hashlib.sha256(pubkey_hex).digest()[:NICK_HASH_LENGTH]
        nick_pkh = base58_encode(nick_pkh_raw)
        nick_pkh += "O" * (NICK_MAX_ENCODED - len(nick_pkh))
        self._nick = f"J{version}{nick_pkh}"

    @property
    def nick(self) -> str:
        """The JoinMarket nick (e.g., J5xxx...)."""
        return self._nick

    @property
    def public_key_hex(self) -> str:
        """Public key as hex string (compressed, 33 bytes)."""
        return self._public_key.format(compressed=True).hex()

    def sign_message(self, message: str, hostid: str = "") -> str:
        """
        Sign a message for transmission using Bitcoin message signing format.

        Args:
            message: The message content (without pubkey/sig)
            hostid: Directory server hostid (appended to message before signing)

        Returns:
            Signed message string: "<message> <pubkey_hex> <sig_base64>"
        """
        # Message to sign is message + hostid (as per reference implementation)
        msg_to_sign = message + hostid

        # Hash using Bitcoin message format (double SHA256 with prefix)
        msg_hash = bitcoin_message_hash(msg_to_sign)

        # Sign the pre-hashed message (raw signature, no additional hashing)
        signature = self._private_key.sign(msg_hash, hasher=None)

        # Encode signature as base64
        sig_b64 = base64.b64encode(signature).decode("ascii")

        return f"{message} {self.public_key_hex} {sig_b64}"
Attributes
nick: str property

The JoinMarket nick (e.g., J5xxx...).

public_key_hex: str property

Public key as hex string (compressed, 33 bytes).

Functions
__init__(version: int = 5, private_key_bytes: bytes | None = None)

Create a new nick identity.

Args: version: JoinMarket protocol version (default 5) private_key_bytes: Optional 32-byte private key (random if not provided)

Source code in jmcore/src/jmcore/crypto.py
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
def __init__(self, version: int = 5, private_key_bytes: bytes | None = None):
    """
    Create a new nick identity.

    Args:
        version: JoinMarket protocol version (default 5)
        private_key_bytes: Optional 32-byte private key (random if not provided)
    """
    if private_key_bytes is None:
        # Match reference: hashlib.sha256(os.urandom(16)).digest()
        private_key_bytes = hashlib.sha256(secrets.token_bytes(16)).digest()

    self._private_key_bytes = private_key_bytes
    self._private_key = PrivateKey(private_key_bytes)
    self._public_key = self._private_key.public_key
    self._version = version

    # Derive nick from pubkey hash
    # Reference uses COMPRESSED pubkey (33 bytes) - the 0x01 suffix indicates compressed
    pubkey_bytes = self._public_key.format(compressed=True)
    pubkey_hex = binascii.hexlify(pubkey_bytes)
    nick_pkh_raw = hashlib.sha256(pubkey_hex).digest()[:NICK_HASH_LENGTH]
    nick_pkh = base58_encode(nick_pkh_raw)
    nick_pkh += "O" * (NICK_MAX_ENCODED - len(nick_pkh))
    self._nick = f"J{version}{nick_pkh}"
sign_message(message: str, hostid: str = '') -> str

Sign a message for transmission using Bitcoin message signing format.

Args: message: The message content (without pubkey/sig) hostid: Directory server hostid (appended to message before signing)

Returns: Signed message string: " "

Source code in jmcore/src/jmcore/crypto.py
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
def sign_message(self, message: str, hostid: str = "") -> str:
    """
    Sign a message for transmission using Bitcoin message signing format.

    Args:
        message: The message content (without pubkey/sig)
        hostid: Directory server hostid (appended to message before signing)

    Returns:
        Signed message string: "<message> <pubkey_hex> <sig_base64>"
    """
    # Message to sign is message + hostid (as per reference implementation)
    msg_to_sign = message + hostid

    # Hash using Bitcoin message format (double SHA256 with prefix)
    msg_hash = bitcoin_message_hash(msg_to_sign)

    # Sign the pre-hashed message (raw signature, no additional hashing)
    signature = self._private_key.sign(msg_hash, hasher=None)

    # Encode signature as base64
    sig_b64 = base64.b64encode(signature).decode("ascii")

    return f"{message} {self.public_key_hex} {sig_b64}"

Functions

base58_encode(data: bytes) -> str

Encode bytes as Base58 (no checksum). Thin wrapper over the audited base58 library; returns "" for empty input to match the historical JoinMarket behavior.

Source code in jmcore/src/jmcore/crypto.py
27
28
29
30
31
32
33
def base58_encode(data: bytes) -> str:
    """Encode bytes as Base58 (no checksum). Thin wrapper over the
    audited ``base58`` library; returns ``""`` for empty input to match
    the historical JoinMarket behavior."""
    if not data:
        return ""
    return base58.b58encode(data).decode("ascii")

base58check_encode(payload: bytes) -> str

Encode bytes with Base58Check (with double-SHA256 checksum). Delegates to the audited base58 library.

Source code in jmcore/src/jmcore/crypto.py
36
37
38
39
def base58check_encode(payload: bytes) -> str:
    """Encode bytes with Base58Check (with double-SHA256 checksum).
    Delegates to the audited ``base58`` library."""
    return base58.b58encode_check(payload).decode("ascii")

bitcoin_message_hash(message: str) -> bytes

Hash a message using Bitcoin's message signing format.

Format: SHA256(SHA256("\x18Bitcoin Signed Message:\n" + varint(len) + message))

Source code in jmcore/src/jmcore/crypto.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
def bitcoin_message_hash(message: str) -> bytes:
    """
    Hash a message using Bitcoin's message signing format.

    Format: SHA256(SHA256("\\x18Bitcoin Signed Message:\\n" + varint(len) + message))
    """
    prefix = b"\x18Bitcoin Signed Message:\n"

    # Encode message to bytes
    msg_bytes = message.encode("utf-8")

    # Create varint for message length
    msg_len = len(msg_bytes)
    if msg_len < 253:
        varint = bytes([msg_len])
    elif msg_len < 0x10000:
        varint = b"\xfd" + msg_len.to_bytes(2, "little")
    elif msg_len < 0x100000000:
        varint = b"\xfe" + msg_len.to_bytes(4, "little")
    else:
        varint = b"\xff" + msg_len.to_bytes(8, "little")

    # Full message to hash
    full_msg = prefix + varint + msg_bytes

    # Double SHA256
    return hashlib.sha256(hashlib.sha256(full_msg).digest()).digest()

bitcoin_message_hash_bytes(message: bytes) -> bytes

Hash a raw bytes message using Bitcoin's message signing format.

Format: SHA256(SHA256("\x18Bitcoin Signed Message:\n" + varint(len) + message))

This is the same as bitcoin_message_hash but takes raw bytes instead of a string. Used for certificate messages that contain binary data (pubkeys).

Args: message: Raw message bytes (NOT pre-hashed)

Returns: 32-byte message hash

Source code in jmcore/src/jmcore/crypto.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
def bitcoin_message_hash_bytes(message: bytes) -> bytes:
    """
    Hash a raw bytes message using Bitcoin's message signing format.

    Format: SHA256(SHA256("\\x18Bitcoin Signed Message:\\n" + varint(len) + message))

    This is the same as bitcoin_message_hash but takes raw bytes instead of a string.
    Used for certificate messages that contain binary data (pubkeys).

    Args:
        message: Raw message bytes (NOT pre-hashed)

    Returns:
        32-byte message hash
    """
    prefix = b"\x18Bitcoin Signed Message:\n"
    msg_len = len(message)

    # Encode length as Bitcoin varint
    if msg_len < 253:
        varint = bytes([msg_len])
    elif msg_len < 0x10000:
        varint = b"\xfd" + msg_len.to_bytes(2, "little")
    elif msg_len < 0x100000000:
        varint = b"\xfe" + msg_len.to_bytes(4, "little")
    else:
        varint = b"\xff" + msg_len.to_bytes(8, "little")

    full_msg = prefix + varint + message
    return hashlib.sha256(hashlib.sha256(full_msg).digest()).digest()

ecdsa_sign(message: str, private_key_bytes: bytes) -> str

Sign a message with ECDSA using Bitcoin message format.

Args: message: The message to sign (as string) private_key_bytes: 32-byte private key

Returns: Base64-encoded signature

Source code in jmcore/src/jmcore/crypto.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
def ecdsa_sign(message: str, private_key_bytes: bytes) -> str:
    """
    Sign a message with ECDSA using Bitcoin message format.

    Args:
        message: The message to sign (as string)
        private_key_bytes: 32-byte private key

    Returns:
        Base64-encoded signature
    """
    # Hash using Bitcoin message format
    msg_hash = bitcoin_message_hash(message)

    # Sign with coincurve (raw signature, no additional hashing)
    priv_key = PrivateKey(private_key_bytes)
    signature = priv_key.sign(msg_hash, hasher=None)

    return base64.b64encode(signature).decode("ascii")

ecdsa_verify(message: str, signature_b64: str, pubkey_bytes: bytes) -> bool

Verify an ECDSA signature using Bitcoin message format.

Args: message: The signed message (as string) signature_b64: Base64-encoded signature pubkey_bytes: Compressed public key (33 bytes)

Returns: True if signature is valid

Source code in jmcore/src/jmcore/crypto.py
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
def ecdsa_verify(message: str, signature_b64: str, pubkey_bytes: bytes) -> bool:
    """
    Verify an ECDSA signature using Bitcoin message format.

    Args:
        message: The signed message (as string)
        signature_b64: Base64-encoded signature
        pubkey_bytes: Compressed public key (33 bytes)

    Returns:
        True if signature is valid
    """
    try:
        # Hash using Bitcoin message format
        msg_hash = bitcoin_message_hash(message)

        # Decode signature from base64
        signature = base64.b64decode(signature_b64)

        # Verify with coincurve (hasher=None because we already hashed)
        return coincurve_verify(signature, msg_hash, pubkey_bytes, hasher=None)
    except Exception:
        return False

generate_jm_nick(version: int = JM_VERSION) -> str

Source code in jmcore/src/jmcore/crypto.py
65
66
67
68
69
70
71
72
73
74
75
76
77
def generate_jm_nick(version: int = JM_VERSION) -> str:
    privkey_bytes = secrets.token_bytes(32)
    private_key = PrivateKey(privkey_bytes)
    # Use compressed pubkey (33 bytes) - matches reference implementation
    pubkey_bytes = private_key.public_key.format(compressed=True)

    pubkey_hex = binascii.hexlify(pubkey_bytes)
    nick_pkh_raw = hashlib.sha256(pubkey_hex).digest()[:NICK_HASH_LENGTH]
    nick_pkh = base58_encode(nick_pkh_raw)

    nick_pkh += "O" * (NICK_MAX_ENCODED - len(nick_pkh))

    return f"J{version}{nick_pkh}"

get_ascii_cert_msg(cert_pub: bytes, cert_expiry: int) -> bytes

Get the ASCII certificate message for signing/verification.

This is an alternative format where the pubkey is hex-encoded: b'fidelity-bond-cert|' + hexlify(cert_pub) + b'|' + str(cert_expiry).encode('ascii')

Args: cert_pub: Certificate public key (33 bytes) cert_expiry: Certificate expiry (encoded as cert_expiry_encoded, NOT multiplied by 2016)

Returns: Message bytes for signing

Source code in jmcore/src/jmcore/crypto.py
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
def get_ascii_cert_msg(cert_pub: bytes, cert_expiry: int) -> bytes:
    """
    Get the ASCII certificate message for signing/verification.

    This is an alternative format where the pubkey is hex-encoded:
    b'fidelity-bond-cert|' + hexlify(cert_pub) + b'|' + str(cert_expiry).encode('ascii')

    Args:
        cert_pub: Certificate public key (33 bytes)
        cert_expiry: Certificate expiry (encoded as cert_expiry_encoded, NOT multiplied by 2016)

    Returns:
        Message bytes for signing
    """
    return (
        b"fidelity-bond-cert|"
        + binascii.hexlify(cert_pub)
        + b"|"
        + str(cert_expiry).encode("ascii")
    )

get_cert_msg(cert_pub: bytes, cert_expiry: int) -> bytes

Get the binary certificate message for signing/verification.

This is the format used by the reference implementation: b'fidelity-bond-cert|' + cert_pub + b'|' + str(cert_expiry).encode('ascii')

Args: cert_pub: Certificate public key (33 bytes) cert_expiry: Certificate expiry (encoded as cert_expiry_encoded, NOT multiplied by 2016)

Returns: Message bytes for signing

Source code in jmcore/src/jmcore/crypto.py
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
def get_cert_msg(cert_pub: bytes, cert_expiry: int) -> bytes:
    """
    Get the binary certificate message for signing/verification.

    This is the format used by the reference implementation:
    b'fidelity-bond-cert|' + cert_pub + b'|' + str(cert_expiry).encode('ascii')

    Args:
        cert_pub: Certificate public key (33 bytes)
        cert_expiry: Certificate expiry (encoded as cert_expiry_encoded, NOT multiplied by 2016)

    Returns:
        Message bytes for signing
    """
    return b"fidelity-bond-cert|" + cert_pub + b"|" + str(cert_expiry).encode("ascii")

mnemonic_to_seed(mnemonic: str, passphrase: str = '') -> bytes

Convert BIP39 mnemonic to 64-byte seed using PBKDF2-HMAC-SHA512.

Delegates to the audited mnemonic library, which applies the NFKD Unicode normalization to both the mnemonic and passphrase before PBKDF2 as required by BIP39. The previous hand-rolled implementation skipped normalization, so non-ASCII passphrases derived seeds that did not match the BIP39 spec (and did not match the legacy joinmarket-clientserver, which also uses this library). For ASCII passphrases (the overwhelmingly common case) the output is bit-identical to the previous implementation.

Args: mnemonic: Space-separated mnemonic words passphrase: Optional BIP39 passphrase (default empty)

Returns: 64-byte seed

Source code in jmcore/src/jmcore/crypto.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
def mnemonic_to_seed(mnemonic: str, passphrase: str = "") -> bytes:
    """
    Convert BIP39 mnemonic to 64-byte seed using PBKDF2-HMAC-SHA512.

    Delegates to the audited ``mnemonic`` library, which applies the
    NFKD Unicode normalization to both the mnemonic and passphrase
    before PBKDF2 as required by BIP39. The previous hand-rolled
    implementation skipped normalization, so non-ASCII passphrases
    derived seeds that did not match the BIP39 spec (and did not match
    the legacy joinmarket-clientserver, which also uses this library).
    For ASCII passphrases (the overwhelmingly common case) the output
    is bit-identical to the previous implementation.

    Args:
        mnemonic: Space-separated mnemonic words
        passphrase: Optional BIP39 passphrase (default empty)

    Returns:
        64-byte seed
    """
    return Mnemonic.to_seed(mnemonic, passphrase=passphrase)

strip_signature_padding(signature: bytes) -> bytes

Strip padding bytes from a DER signature.

The reference JoinMarket implementation pads signatures to 72 bytes using rjust(72, b'\xff'). This function finds the DER header (0x30) and strips any leading padding bytes.

Args: signature: Possibly padded DER signature

Returns: DER signature with padding removed

Source code in jmcore/src/jmcore/crypto.py
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
def strip_signature_padding(signature: bytes) -> bytes:
    """
    Strip padding bytes from a DER signature.

    The reference JoinMarket implementation pads signatures to 72 bytes using
    rjust(72, b'\\xff'). This function finds the DER header (0x30) and strips
    any leading padding bytes.

    Args:
        signature: Possibly padded DER signature

    Returns:
        DER signature with padding removed
    """
    try:
        # Find the DER sequence header (0x30)
        idx = signature.index(b"\x30")
        return signature[idx:]
    except ValueError:
        # No 0x30 found, try stripping trailing zeros (our legacy format)
        return signature.rstrip(b"\x00")

verify_bitcoin_message_signature(message: bytes, signature: bytes, pubkey_bytes: bytes) -> bool

Verify an ECDSA signature using Bitcoin message signing format.

The message is hashed using Bitcoin's message signing format: SHA256(SHA256("\x18Bitcoin Signed Message:\n" + varint(len) + message))

Args: message: Raw message bytes (NOT pre-hashed) signature: DER-encoded signature (may have padding) pubkey_bytes: Compressed public key (33 bytes)

Returns: True if signature is valid

Source code in jmcore/src/jmcore/crypto.py
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
def verify_bitcoin_message_signature(message: bytes, signature: bytes, pubkey_bytes: bytes) -> bool:
    """
    Verify an ECDSA signature using Bitcoin message signing format.

    The message is hashed using Bitcoin's message signing format:
    SHA256(SHA256("\\x18Bitcoin Signed Message:\\n" + varint(len) + message))

    Args:
        message: Raw message bytes (NOT pre-hashed)
        signature: DER-encoded signature (may have padding)
        pubkey_bytes: Compressed public key (33 bytes)

    Returns:
        True if signature is valid
    """
    try:
        msg_hash = bitcoin_message_hash_bytes(message)
        return verify_raw_ecdsa(msg_hash, signature, pubkey_bytes)
    except Exception:
        return False

verify_fidelity_bond_proof(proof_base64: str, maker_nick: str, taker_nick: str) -> tuple[bool, dict[str, str | int | bytes] | None, str]

Verify a fidelity bond proof by checking both signatures.

The proof structure (252 bytes total): - 72 bytes: Nick signature (signs "taker_nick|maker_nick" with Bitcoin message format) - 72 bytes: Certificate signature (signs cert message with Bitcoin message format) - 33 bytes: Certificate public key - 2 bytes: Certificate expiry (blocks / 2016) - 33 bytes: UTXO public key - 32 bytes: TXID (little-endian) - 4 bytes: Vout (little-endian) - 4 bytes: Locktime (little-endian)

The nick signature message format is: (taker_nick + '|' + maker_nick).encode('ascii')

The certificate signature message has two valid formats: 1. Binary: b'fidelity-bond-cert|' + cert_pub + b'|' + str(cert_expiry).encode('ascii') 2. ASCII: b'fidelity-bond-cert|' + hexlify(cert_pub) + b'|' + str(cert_expiry).encode('ascii')

Both signatures use Bitcoin message signing format (double SHA256 with prefix).

Args: proof_base64: Base64-encoded bond proof maker_nick: Maker's JoinMarket nick taker_nick: Taker's nick (the verifier)

Returns: Tuple of (is_valid, bond_data, error_message) bond_data contains parsed fields if successful

Source code in jmcore/src/jmcore/crypto.py
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
def verify_fidelity_bond_proof(
    proof_base64: str,
    maker_nick: str,
    taker_nick: str,
) -> tuple[bool, dict[str, str | int | bytes] | None, str]:
    """
    Verify a fidelity bond proof by checking both signatures.

    The proof structure (252 bytes total):
    - 72 bytes: Nick signature (signs "taker_nick|maker_nick" with Bitcoin message format)
    - 72 bytes: Certificate signature (signs cert message with Bitcoin message format)
    - 33 bytes: Certificate public key
    - 2 bytes: Certificate expiry (blocks / 2016)
    - 33 bytes: UTXO public key
    - 32 bytes: TXID (little-endian)
    - 4 bytes: Vout (little-endian)
    - 4 bytes: Locktime (little-endian)

    The nick signature message format is:
        (taker_nick + '|' + maker_nick).encode('ascii')

    The certificate signature message has two valid formats:
    1. Binary: b'fidelity-bond-cert|' + cert_pub + b'|' + str(cert_expiry).encode('ascii')
    2. ASCII: b'fidelity-bond-cert|' + hexlify(cert_pub) + b'|' + str(cert_expiry).encode('ascii')

    Both signatures use Bitcoin message signing format (double SHA256 with prefix).

    Args:
        proof_base64: Base64-encoded bond proof
        maker_nick: Maker's JoinMarket nick
        taker_nick: Taker's nick (the verifier)

    Returns:
        Tuple of (is_valid, bond_data, error_message)
        bond_data contains parsed fields if successful
    """
    import struct

    try:
        decoded_data = base64.b64decode(proof_base64)
    except Exception as e:
        return False, None, f"Failed to decode base64: {e}"

    if len(decoded_data) != 252:
        return False, None, f"Invalid proof length: {len(decoded_data)}, expected 252"

    try:
        # Unpack the proof structure
        unpacked = struct.unpack("<72s72s33sH33s32sII", decoded_data)

        nick_sig = unpacked[0]  # 72 bytes (padded DER signature)
        cert_sig = unpacked[1]  # 72 bytes (padded DER signature)
        cert_pub = unpacked[2]  # 33 bytes
        cert_expiry_encoded = unpacked[3]  # 2 bytes (blocks / 2016)
        utxo_pub = unpacked[4]  # 33 bytes
        txid = unpacked[5]  # 32 bytes (little-endian)
        vout = unpacked[6]  # 4 bytes
        locktime = unpacked[7]  # 4 bytes

        cert_expiry = cert_expiry_encoded * 2016

        # Strip leading 0xff padding from signatures (reference impl uses rjust with 0xff)
        try:
            nick_sig_stripped = nick_sig[nick_sig.index(b"\x30") :]
        except ValueError:
            return False, None, "Nick signature DER header not found"

        try:
            cert_sig_stripped = cert_sig[cert_sig.index(b"\x30") :]
        except ValueError:
            return False, None, "Certificate signature DER header not found"

        # 1. Verify nick signature
        # The nick signature proves the maker controls the certificate key
        # It signs "(taker_nick|maker_nick)" with the certificate private key
        nick_msg = (taker_nick + "|" + maker_nick).encode("ascii")

        if not verify_bitcoin_message_signature(nick_msg, nick_sig_stripped, cert_pub):
            return False, None, "Nick signature verification failed"

        # 2. Verify certificate signature
        # The certificate is signed by the UTXO key
        # It signs the cert message (two formats supported for compatibility)
        cert_msg = get_cert_msg(cert_pub, cert_expiry_encoded)
        ascii_cert_msg = get_ascii_cert_msg(cert_pub, cert_expiry_encoded)

        if not verify_bitcoin_message_signature(
            cert_msg, cert_sig_stripped, utxo_pub
        ) and not verify_bitcoin_message_signature(ascii_cert_msg, cert_sig_stripped, utxo_pub):
            return False, None, "Certificate signature verification failed"

        # Both signatures are valid
        bond_data = {
            "utxo_txid": txid.hex(),
            "utxo_vout": vout,
            "locktime": locktime,
            "utxo_pub": utxo_pub.hex(),
            "cert_pub": cert_pub.hex(),
            "cert_expiry": cert_expiry,
            "maker_nick": maker_nick,
            "taker_nick": taker_nick,
        }

        return True, bond_data, ""

    except Exception as e:
        return False, None, f"Failed to verify bond proof: {e}"

verify_raw_ecdsa(message_hash: bytes, signature_der: bytes, pubkey_bytes: bytes) -> bool

Verify an ECDSA signature on a pre-hashed message.

Args: message_hash: The message hash (already SHA256'd) signature_der: DER-encoded signature (may have leading 0xff padding or trailing zeros) pubkey_bytes: Compressed public key (33 bytes)

Returns: True if signature is valid

Source code in jmcore/src/jmcore/crypto.py
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
def verify_raw_ecdsa(message_hash: bytes, signature_der: bytes, pubkey_bytes: bytes) -> bool:
    """
    Verify an ECDSA signature on a pre-hashed message.

    Args:
        message_hash: The message hash (already SHA256'd)
        signature_der: DER-encoded signature (may have leading 0xff padding or trailing zeros)
        pubkey_bytes: Compressed public key (33 bytes)

    Returns:
        True if signature is valid
    """
    try:
        # Strip padding from signature (handles both leading 0xff and trailing 0x00)
        sig = strip_signature_padding(signature_der)
        if len(sig) == 0:
            return False

        # Use PublicKey.verify with hasher=None for raw verification
        pubkey = PublicKey(pubkey_bytes)
        return pubkey.verify(sig, message_hash, hasher=None)
    except Exception:
        return False

verify_signature(public_key_hex: str, message: bytes, signature: bytes) -> bool

Source code in jmcore/src/jmcore/crypto.py
293
294
295
296
297
298
def verify_signature(public_key_hex: str, message: bytes, signature: bytes) -> bool:
    try:
        public_key_bytes = bytes.fromhex(public_key_hex)
        return coincurve_verify(signature, message, public_key_bytes)
    except Exception:
        return False