Skip to content

feat(crypto): migrate BDHKE to BLS12-381 (v3 keysets)#999

Open
a1denvalu3 wants to merge 14 commits into
mainfrom
feature/bls12-381-v3-keyset
Open

feat(crypto): migrate BDHKE to BLS12-381 (v3 keysets)#999
a1denvalu3 wants to merge 14 commits into
mainfrom
feature/bls12-381-v3-keyset

Conversation

@a1denvalu3
Copy link
Copy Markdown
Collaborator

Summary

This pull request introduces BLS12-381 cryptography into the Cashu protocol, enabling smaller proofs and paving the way for multi-signature schemes and batch verification.

Core Changes

  • Introduces v3 keysets using the BLS12-381 curve.
  • Adds multiplicative blinding logic (Y * r) to replace legacy additive blinding (Y + r*G).
  • Replaces DLEQ proof requirements with BLS pairing verification (e(C, G2) == e(Y, K2)).
  • Modifies wallet redemption and proof construction steps to unblind signatures cleanly and omit unneeded DLEQ verification logic.
  • Maintains complete backwards compatibility with v1/v2 (secp256k1) keysets.
  • Implements comprehensive batch pairing verification for unblinded BLS signatures.
  • Updates keyset ID generation format to utilize the 02 prefix for BLS keysets.

Testing

  • Updates the wallet and mint CLI tests to dynamically accommodate both secp256k1 and BLS12-381 logic.
  • Adds tests/test_crypto_bls.py test suite specifically for deterministic hash-to-curve testing, verification of individual BLS protocol steps, and batched BLS pairing checks.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 5, 2026

❌ 16 Tests Failed:

Tests completed Failed Passed Skipped
681 16 665 77
View the top 3 failed test(s) by shortest run time
tests.wallet.test_wallet::testactivate_keyset_specific_keyset
Stack Traces | 0.16s run time
wallet1 = <cashu.wallet.wallet.Wallet object at 0x7fd100190460>

    @pytest.mark.asyncio
    async def testactivate_keyset_specific_keyset(wallet1: Wallet):
        await wallet1.activate_keyset()
>       assert list(wallet1.keysets.keys()) == [
            "01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc"
        ]
E       AssertionError: assert ['0200351069d...ecb7db118288'] == ['01d8a63077d...ed06c039f6bc']
E         
E         At index 0 diff: '0200351069db7a17336468dda24c22ce79a0fc1ebaf81b75adff42ecb7db118288' != '01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc'
E         
E         Full diff:
E           [
E         -     '01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc',
E         +     '0200351069db7a17336468dda24c22ce79a0fc1ebaf81b75adff42ecb7db118288',
E           ]

tests/wallet/test_wallet.py:552: AssertionError
tests.mint.test_mint_init::test_decrypt_seed
Stack Traces | 0.211s run time
@pytest.mark.asyncio
    async def test_decrypt_seed():
        ledger = Ledger(
            db=Database("mint", settings.mint_database),
            seed=SEED,
            seed_decryption_key=None,
            derivation_path=DERIVATION_PATH,
            backends={},
            crud=LedgerCrudSqlite(),
        )
        await ledger.init_keysets()
        assert ledger.keyset.seed == SEED
        private_key_1 = (
            ledger.keysets[list(ledger.keysets.keys())[0]].private_keys[1].to_hex()
        )
>       assert (
            private_key_1
            == "8300050453f08e6ead1296bb864e905bd46761beed22b81110fae0751d84604d"
        )
E       AssertionError: assert '1fff4ad9c517...09b611fb73831' == '8300050453f0...ae0751d84604d'
E         
E         - 8300050453f08e6ead1296bb864e905bd46761beed22b81110fae0751d84604d
E         + 1fff4ad9c517453bdaf82e23032c2271db69d719d6d6db629d309b611fb73831

tests/mint/test_mint_init.py:95: AssertionError
tests.wallet.test_wallet_cli::test_send_with_lock_and_refund
Stack Traces | 0.236s run time
mint = <UvicornServer name='UvicornServer-1' pid=2624 parent=2617 started>
cli_prefix = ['--wallet', 'test_cli_wallet', '--host', 'http://localhost:3337', '--tests']

    def test_send_with_lock_and_refund(mint, cli_prefix):
        runner = CliRunner()
        result = runner.invoke(
            cli,
            [*cli_prefix, "locks"],
        )
        assert result.exception is None
        lock = None
        for word in result.output.split(" "):
            word = word.strip()
            if word.startswith("P2PK:"):
                lock = word
                break
        assert lock is not None, "no lock found"
    
        fake_refund_pubkey = "02" + "ef" * 32
        result = runner.invoke(
            cli,
            [
                *cli_prefix,
                "send",
                "10",
                "--lock",
                lock,
                "--refund",
                fake_refund_pubkey,
            ],
        )
>       assert result.exception is None
E       AssertionError: assert BalanceTooLowError('Balance too low') is None
E        +  where BalanceTooLowError('Balance too low') = <Result BalanceTooLowError('Balance too low')>.exception

tests/wallet/test_wallet_cli.py:682: AssertionError
tests.wallet.test_wallet::test_get_keys
Stack Traces | 0.238s run time
wallet1 = <cashu.wallet.wallet.Wallet object at 0x7fd0fffccf40>

    @pytest.mark.asyncio
    async def test_get_keys(wallet1: Wallet):
        assert wallet1.keysets[wallet1.keyset_id].public_keys
        assert len(wallet1.keysets[wallet1.keyset_id].public_keys) == settings.max_order
        keysets = await wallet1._get_keys()
        keyset = keysets[0]
        assert keyset.id is not None
        # assert keyset.id_deprecated == "eGnEWtdJ0PIM"
>       assert (
            keyset.id
            == "01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc"
        )
E       AssertionError: assert '0200351069db...2ecb7db118288' == '01d8a63077d0...1ed06c039f6bc'
E         
E         - 01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc
E         + 0200351069db7a17336468dda24c22ce79a0fc1ebaf81b75adff42ecb7db118288

tests/wallet/test_wallet.py:104: AssertionError
tests.wallet.test_wallet_cli::test_send_with_lock
Stack Traces | 0.238s run time
mint = <UvicornServer name='UvicornServer-1' pid=2624 parent=2617 started>
cli_prefix = ['--wallet', 'test_cli_wallet', '--host', 'http://localhost:3337', '--tests']

    def test_send_with_lock(mint, cli_prefix):
        # call "cashu locks" first and get the lock
        runner = CliRunner()
        result = runner.invoke(
            cli,
            [*cli_prefix, "locks"],
        )
        assert result.exception is None
        print("test_send_with_lock", result.output)
        # iterate through all words and get the word that starts with "P2PK:"
        lock = None
        for word in result.output.split(" "):
            # strip the word
            word = word.strip()
            if word.startswith("P2PK:"):
                lock = word
                break
        assert lock is not None, "no lock found"
        pubkey = lock.split(":")[1]
    
        # now lock the token
        runner = CliRunner()
        result = runner.invoke(
            cli,
            [*cli_prefix, "send", "10", "--lock", lock],
        )
>       assert result.exception is None
E       AssertionError: assert BalanceTooLowError('Balance too low') is None
E        +  where BalanceTooLowError('Balance too low') = <Result BalanceTooLowError('Balance too low')>.exception

tests/wallet/test_wallet_cli.py:582: AssertionError
tests.wallet.test_wallet_restore::test_bump_secret_derivation
Stack Traces | 0.461s run time
wallet3 = <cashu.wallet.wallet.Wallet object at 0x7fd0ffcc3970>

    @pytest.mark.asyncio
    async def test_bump_secret_derivation(wallet3: Wallet):
        await wallet3._init_private_key(
            "half depart obvious quality work element tank gorilla view sugar picture"
            " humble"
        )
        secrets1, rs1, derivation_paths1 = await wallet3.generate_n_secrets(5)
        secrets2, rs2, derivation_paths2 = await wallet3.generate_secrets_from_to(0, 4)
>       assert wallet3.keyset_id == "01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc"
E       AssertionError: assert '0200351069db...2ecb7db118288' == '01d8a63077d0...1ed06c039f6bc'
E         
E         - 01d8a63077d0a51f9855f066409782ffcb322dc8a2265291865221ed06c039f6bc
E         + 0200351069db7a17336468dda24c22ce79a0fc1ebaf81b75adff42ecb7db118288

tests/wallet/test_wallet_restore.py:95: AssertionError
tests.mint.test_mint_db_operations::test_update_blinded_message_signature_before_store_blinded_message_errors
Stack Traces | 0.594s run time
ledger = <cashu.mint.ledger.Ledger object at 0x7f608ae724d0>

    @pytest.mark.asyncio
    async def test_update_blinded_message_signature_before_store_blinded_message_errors(
        ledger: Ledger,
    ):
        from cashu.core.crypto.b_dhke import step1_alice, step2_bob
        from cashu.core.crypto.secp import PublicKey
    
        amount = 8
        # Generate a blinded message that we will NOT store
        B_pub, _ = step1_alice("test_sign_before_store_blinded_message")
        b_hex = B_pub.format().hex()
    
        # Create a valid signature tuple for that blinded message
        priv = ledger.keyset.private_keys[amount]
>       C_point, e, s = step2_bob(PublicKey(bytes.fromhex(b_hex)), priv)

tests/mint/test_mint_db_operations.py:572: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.../core/crypto/b_dhke.py:98: in step2_bob
    C_: PublicKey = B_ * a  # type: ignore
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <coincurve.keys.PublicKey object at 0x7f608b0116c0>
privkey = <cashu.core.crypto.bls.PrivateKey object at 0x7f608ae71960>

    def __mul__(self, privkey):
        if isinstance(privkey, PrivateKey):
            return self.multiply(bytes.fromhex(privkey.to_hex()))
        else:
>           raise TypeError("Can't multiply with non privatekey")
E           TypeError: Can't multiply with non privatekey

.../core/crypto/secp.py:30: TypeError
tests.mint.test_mint_verification::test_verify_proof_bdhke_rejects_invalid_token
Stack Traces | 0.597s run time
self = <cashu.core.crypto.bls.PublicKey object at 0x7f607658f430>
compressed = b'\x02\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde'
point = None, group = 'G1'

    def __init__(self, compressed: bytes = b"", point=None, group="G1"):
        self.group = group
        try:
            if point is not None:
                self.point = point
            elif compressed:
                if self.group == "G1":
>                   self.point = pyblst.BlstP1Element().uncompress(compressed)
E                   ValueError: blst error Blst(BLST_BAD_ENCODING)

.../core/crypto/bls.py:44: ValueError

During handling of the above exception, another exception occurred:

ledger = <cashu.mint.ledger.Ledger object at 0x7f6076425240>

    def test_verify_proof_bdhke_rejects_invalid_token(ledger: Ledger):
        kid = ledger.keyset.id
        p = Proof(
            id=kid,
            amount=8,
            secret="66687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925",
            C="02" + "de" * 32,
        )
>       assert ledger._verify_proof_bdhke(p) is False

tests/mint/test_mint_verification.py:395: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
cashu/mint/verification.py:238: in _verify_proof_bdhke
    C = BlsPublicKey(bytes.fromhex(proof.C))
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <cashu.core.crypto.bls.PublicKey object at 0x7f607658f430>
compressed = b'\x02\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde\xde'
point = None, group = 'G1'

    def __init__(self, compressed: bytes = b"", point=None, group="G1"):
        self.group = group
        try:
            if point is not None:
                self.point = point
            elif compressed:
                if self.group == "G1":
                    self.point = pyblst.BlstP1Element().uncompress(compressed)
                else:
                    self.point = pyblst.BlstP2Element().uncompress(compressed)
            else:
                raise ValueError("Must provide point or compressed bytes")
        except Exception:
>           raise ValueError("The public key could not be parsed or is invalid.")
E           ValueError: The public key could not be parsed or is invalid.

.../core/crypto/bls.py:50: ValueError
tests.mint.test_mint_verification::test_verify_inputs_rejects_invalid_bdhke
Stack Traces | 0.625s run time
self = <cashu.core.crypto.bls.PublicKey object at 0x7f607610ebc0>
compressed = b'\x02\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d'
point = None, group = 'G1'

    def __init__(self, compressed: bytes = b"", point=None, group="G1"):
        self.group = group
        try:
            if point is not None:
                self.point = point
            elif compressed:
                if self.group == "G1":
>                   self.point = pyblst.BlstP1Element().uncompress(compressed)
E                   ValueError: blst error Blst(BLST_BAD_ENCODING)

.../core/crypto/bls.py:44: ValueError

During handling of the above exception, another exception occurred:

ledger = <cashu.mint.ledger.Ledger object at 0x7f6076391240>

    @pytest.mark.asyncio
    async def test_verify_inputs_rejects_invalid_bdhke(ledger: Ledger):
        kid = ledger.keyset.id
        p = Proof(id=kid, amount=8, secret="s", C="02" + "1d" * 32)
        with patch.object(
            ledger.db_read,
            "_verify_proofs_spendable",
            AsyncMock(return_value=True),
        ):
            with pytest.raises(InvalidProofsError):
>               await ledger._verify_inputs([p])

tests/mint/test_mint_verification.py:447: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
cashu/mint/verification.py:97: in _verify_inputs
    if not all([self._verify_proof_bdhke(p) for p in proofs]):
cashu/mint/verification.py:97: in <listcomp>
    if not all([self._verify_proof_bdhke(p) for p in proofs]):
cashu/mint/verification.py:238: in _verify_proof_bdhke
    C = BlsPublicKey(bytes.fromhex(proof.C))
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <cashu.core.crypto.bls.PublicKey object at 0x7f607610ebc0>
compressed = b'\x02\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d\x1d'
point = None, group = 'G1'

    def __init__(self, compressed: bytes = b"", point=None, group="G1"):
        self.group = group
        try:
            if point is not None:
                self.point = point
            elif compressed:
                if self.group == "G1":
                    self.point = pyblst.BlstP1Element().uncompress(compressed)
                else:
                    self.point = pyblst.BlstP2Element().uncompress(compressed)
            else:
                raise ValueError("Must provide point or compressed bytes")
        except Exception:
>           raise ValueError("The public key could not be parsed or is invalid.")
E           ValueError: The public key could not be parsed or is invalid.

.../core/crypto/bls.py:50: ValueError
tests.mint.test_mint_db_operations::test_store_and_sign_blinded_message
Stack Traces | 0.661s run time
ledger = <cashu.mint.ledger.Ledger object at 0x7f608b271210>

    @pytest.mark.asyncio
    async def test_store_and_sign_blinded_message(ledger: Ledger):
        # Localized imports to avoid polluting module scope
        from cashu.core.crypto.b_dhke import step1_alice, step2_bob
        from cashu.core.crypto.secp import PublicKey
    
        # Arrange: prepare a blinded message tied to current active keyset
        amount = 8
        keyset_id = ledger.keyset.id
        B_pubkey, _ = step1_alice("test_store_and_sign_blinded_message")
        B_hex = B_pubkey.format().hex()
    
        # Act: store the blinded message (unsinged promise row)
        await ledger.crud.store_blinded_message(
            db=ledger.db,
            amount=amount,
            b_=B_hex,
            id=keyset_id,
        )
    
        # Act: compute a valid blind signature for the stored row and persist it
        private_key_amount = ledger.keyset.private_keys[amount]
        B_point = PublicKey(bytes.fromhex(B_hex))
>       C_point, e, s = step2_bob(B_point, private_key_amount)

tests/mint/test_mint_db_operations.py:382: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.../core/crypto/b_dhke.py:98: in step2_bob
    C_: PublicKey = B_ * a  # type: ignore
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <coincurve.keys.PublicKey object at 0x7f608b2e9f90>
privkey = <cashu.core.crypto.bls.PrivateKey object at 0x7f608b197580>

    def __mul__(self, privkey):
        if isinstance(privkey, PrivateKey):
            return self.multiply(bytes.fromhex(privkey.to_hex()))
        else:
>           raise TypeError("Can't multiply with non privatekey")
E           TypeError: Can't multiply with non privatekey

.../core/crypto/secp.py:30: TypeError
tests.mint.test_mint_db_operations::test_get_melt_quote_preserves_change_signatures_order
Stack Traces | 0.82s run time
wallet = <cashu.wallet.wallet.Wallet object at 0x7f608b4fc730>
ledger = <cashu.mint.ledger.Ledger object at 0x7f608b0d7550>

    @pytest.mark.asyncio
    async def test_get_melt_quote_preserves_change_signatures_order(
        wallet: Wallet, ledger: Ledger
    ):
        from cashu.core.crypto.b_dhke import step1_alice, step2_bob
        from cashu.core.crypto.secp import PublicKey
    
        amount = 8
        keyset_id = ledger.keyset.id
    
        mint_quote = await wallet.request_mint(64)
        melt_quote = await ledger.melt_quote(
            PostMeltQuoteRequest(request=mint_quote.request, unit="sat")
        )
        melt_id = melt_quote.quote
    
        # Create 5 blinded messages
        b_values = []
        for i in range(5):
            B, _ = step1_alice(f"melt_quote_change_{i}")
            b_values.append(B.format().hex())
    
        # Store them out of order of insertion, but with correct order_index
        # We will insert index 4, 1, 3, 0, 2
        insert_order = [4, 1, 3, 0, 2]
        for idx in insert_order:
            await ledger.crud.store_blinded_message(
                db=ledger.db,
                amount=amount,
                b_=b_values[idx],
                id=keyset_id,
                melt_id=melt_id,
                order_index=idx,
            )
    
            # Sign it right away so it is returned in change
            priv = ledger.keyset.private_keys[amount]
>           _, e, s = step2_bob(PublicKey(bytes.fromhex(b_values[idx])), priv)

tests/mint/test_mint_db_operations.py:692: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.../core/crypto/b_dhke.py:98: in step2_bob
    C_: PublicKey = B_ * a  # type: ignore
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <coincurve.keys.PublicKey object at 0x7f608b5c7a90>
privkey = <cashu.core.crypto.bls.PrivateKey object at 0x7f608b0d5f60>

    def __mul__(self, privkey):
        if isinstance(privkey, PrivateKey):
            return self.multiply(bytes.fromhex(privkey.to_hex()))
        else:
>           raise TypeError("Can't multiply with non privatekey")
E           TypeError: Can't multiply with non privatekey

.../core/crypto/secp.py:30: TypeError
tests.mint.test_mint_db_operations::test_get_blind_signatures_by_melt_id_returns_signed
Stack Traces | 0.83s run time
wallet = <cashu.wallet.wallet.Wallet object at 0x7f608b273010>
ledger = <cashu.mint.ledger.Ledger object at 0x7f608b4fcd30>

    @pytest.mark.asyncio
    async def test_get_blind_signatures_by_melt_id_returns_signed(
        wallet: Wallet, ledger: Ledger
    ):
        from cashu.core.crypto.b_dhke import step1_alice, step2_bob
        from cashu.core.crypto.secp import PublicKey
    
        amount = 4
        keyset_id = ledger.keyset.id
        # Create a real melt quote to satisfy FK on promises.melt_quote
        mint_quote = await wallet.request_mint(64)
        melt_quote = await ledger.melt_quote(
            PostMeltQuoteRequest(request=mint_quote.request, unit="sat")
        )
        melt_id = melt_quote.quote
    
        # Prepare two blinded messages under the same melt_id
        B1, _ = step1_alice("signed_promises_by_melt_id_1")
        B2, _ = step1_alice("signed_promises_by_melt_id_2")
        b1_hex = B1.format().hex()
        b2_hex = B2.format().hex()
    
        await ledger.crud.store_blinded_message(
            db=ledger.db, amount=amount, b_=b1_hex, id=keyset_id, melt_id=melt_id
        )
        await ledger.crud.store_blinded_message(
            db=ledger.db, amount=amount, b_=b2_hex, id=keyset_id, melt_id=melt_id
        )
    
        # Sign only one of them -> should be returned by get_blind_signatures_melt_id
        priv = ledger.keyset.private_keys[amount]
>       C_point, e, s = step2_bob(PublicKey(bytes.fromhex(b1_hex)), priv)

tests/mint/test_mint_db_operations.py:634: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.../core/crypto/b_dhke.py:98: in step2_bob
    C_: PublicKey = B_ * a  # type: ignore
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <coincurve.keys.PublicKey object at 0x7f608b5a1180>
privkey = <cashu.core.crypto.bls.PrivateKey object at 0x7f608b4feb60>

    def __mul__(self, privkey):
        if isinstance(privkey, PrivateKey):
            return self.multiply(bytes.fromhex(privkey.to_hex()))
        else:
>           raise TypeError("Can't multiply with non privatekey")
E           TypeError: Can't multiply with non privatekey

.../core/crypto/secp.py:30: TypeError
tests.mint.test_mint_db_operations::test_get_blinded_messages_by_melt_id_filters_signed
Stack Traces | 0.83s run time
wallet = <cashu.wallet.wallet.Wallet object at 0x7f608b49a140>
ledger = <cashu.mint.ledger.Ledger object at 0x7f608af7d180>

    @pytest.mark.asyncio
    async def test_get_blinded_messages_by_melt_id_filters_signed(
        wallet: Wallet, ledger: Ledger
    ):
        from cashu.core.crypto.b_dhke import step1_alice, step2_bob
        from cashu.core.crypto.secp import PublicKey
    
        amount = 2
        keyset_id = ledger.keyset.id
        # Create a real melt quote to satisfy FK on promises.melt_quote
        mint_quote = await wallet.request_mint(64)
        melt_quote = await ledger.melt_quote(
            PostMeltQuoteRequest(request=mint_quote.request, unit="sat")
        )
        melt_id = melt_quote.quote
    
        B1, _ = step1_alice("filter_by_melt_id_1")
        B2, _ = step1_alice("filter_by_melt_id_2")
        b1_hex = B1.format().hex()
        b2_hex = B2.format().hex()
    
        # Persist two unsigned messages
        await ledger.crud.store_blinded_message(
            db=ledger.db, amount=amount, b_=b1_hex, id=keyset_id, melt_id=melt_id
        )
        await ledger.crud.store_blinded_message(
            db=ledger.db, amount=amount, b_=b2_hex, id=keyset_id, melt_id=melt_id
        )
    
        # Sign one of them (it should no longer be returned by get_blinded_messages_melt_id which filters c_ IS NULL)
        priv = ledger.keyset.private_keys[amount]
>       C_point, e, s = step2_bob(PublicKey(bytes.fromhex(b1_hex)), priv)

tests/mint/test_mint_db_operations.py:511: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.../core/crypto/b_dhke.py:98: in step2_bob
    C_: PublicKey = B_ * a  # type: ignore
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <coincurve.keys.PublicKey object at 0x7f608af34970>
privkey = <cashu.core.crypto.bls.PrivateKey object at 0x7f608af7fa00>

    def __mul__(self, privkey):
        if isinstance(privkey, PrivateKey):
            return self.multiply(bytes.fromhex(privkey.to_hex()))
        else:
>           raise TypeError("Can't multiply with non privatekey")
E           TypeError: Can't multiply with non privatekey

.../core/crypto/secp.py:30: TypeError
tests.mint.test_mint_db_operations::test_get_melt_quote_includes_change_signatures
Stack Traces | 0.851s run time
wallet = <cashu.wallet.wallet.Wallet object at 0x7f608b4fd660>
ledger = <cashu.mint.ledger.Ledger object at 0x7f608b06b4c0>

    @pytest.mark.asyncio
    async def test_get_melt_quote_includes_change_signatures(
        wallet: Wallet, ledger: Ledger
    ):
        from cashu.core.crypto.b_dhke import step1_alice, step2_bob
        from cashu.core.crypto.secp import PublicKey
    
        amount = 8
        keyset_id = ledger.keyset.id
    
        # Create melt quote and attach outputs/promises under its melt_id
        mint_quote = await wallet.request_mint(64)
        melt_quote = await ledger.melt_quote(
            PostMeltQuoteRequest(request=mint_quote.request, unit="sat")
        )
    
        melt_id = melt_quote.quote
    
        # Create two blinded messages, sign one -> becomes change
        B1, _ = step1_alice("melt_quote_change_1")
        B2, _ = step1_alice("melt_quote_change_2")
        b1_hex = B1.format().hex()
        b2_hex = B2.format().hex()
    
        await ledger.crud.store_blinded_message(
            db=ledger.db, amount=amount, b_=b1_hex, id=keyset_id, melt_id=melt_id
        )
        await ledger.crud.store_blinded_message(
            db=ledger.db, amount=amount, b_=b2_hex, id=keyset_id, melt_id=melt_id
        )
    
        # Sign one -> should appear in change loaded by get_melt_quote
        priv = ledger.keyset.private_keys[amount]
>       C_point, e, s = step2_bob(PublicKey(bytes.fromhex(b1_hex)), priv)

tests/mint/test_mint_db_operations.py:749: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.../core/crypto/b_dhke.py:98: in step2_bob
    C_: PublicKey = B_ * a  # type: ignore
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <coincurve.keys.PublicKey object at 0x7f608b271090>
privkey = <cashu.core.crypto.bls.PrivateKey object at 0x7f608b451660>

    def __mul__(self, privkey):
        if isinstance(privkey, PrivateKey):
            return self.multiply(bytes.fromhex(privkey.to_hex()))
        else:
>           raise TypeError("Can't multiply with non privatekey")
E           TypeError: Can't multiply with non privatekey

.../core/crypto/secp.py:30: TypeError
tests.wallet.test_wallet_cli::test_selfpay
Stack Traces | 0.87s run time
cli_prefix = ['--wallet', 'test_cli_wallet', '--host', 'http://localhost:3337', '--tests']

    def test_selfpay(cli_prefix):
        runner = CliRunner()
        result = runner.invoke(
            cli,
            [*cli_prefix, "selfpay"],
        )
>       assert result.exception is None
E       AssertionError: assert Exception('no keyset with ID: 0200351069db7a17') is None
E        +  where Exception('no keyset with ID: 0200351069db7a17') = <Result Exception('no keyset with ID: 0200351069db7a17')>.exception

tests/wallet/test_wallet_cli.py:551: AssertionError
tests.wallet.test_wallet_requests::test_swap_outputs_are_sorted
Stack Traces | 1.99s run time
self = <cashu.core.crypto.bls.PublicKey object at 0x7fd0ffe5ab60>
compressed = b"\x02Q\x0b \x80\x1a/ \xde\x82\x96q:<\x178\xb4\x9c&#\xed?\xc9#.\xfa7\xce\xaa&'e\x0e"
point = None, group = 'G1'

    def __init__(self, compressed: bytes = b"", point=None, group="G1"):
        self.group = group
        try:
            if point is not None:
                self.point = point
            elif compressed:
                if self.group == "G1":
>                   self.point = pyblst.BlstP1Element().uncompress(compressed)
E                   ValueError: blst error Blst(BLST_BAD_ENCODING)

.../core/crypto/bls.py:44: ValueError

During handling of the above exception, another exception occurred:

wallet1 = <cashu.wallet.wallet.Wallet object at 0x7fd0feb452a0>

    @pytest.mark.asyncio
    async def test_swap_outputs_are_sorted(wallet1: Wallet):
        await wallet1.load_mint()
        mint_quote = await wallet1.request_mint(16)
        await pay_if_regtest(mint_quote.request)
        await wallet1.mint(16, quote_id=mint_quote.quote, split=[16])
        assert wallet1.balance == 16
    
        test_url = f"{wallet1.url}/v1/swap"
        key = hash_to_curve("test".encode("utf-8"))
        mock_blind_signature = BlindedSignature(
            id=wallet1.keyset_id,
            amount=8,
            C_=key.format().hex(),
        )
        mock_response_data = {"signatures": [mock_blind_signature.model_dump()]}
        with respx.mock() as mock:
            route = mock.post(test_url).mock(
                return_value=Response(200, json=mock_response_data)
            )
>           await wallet1.select_to_send(wallet1.proofs, 5)

tests/wallet/test_wallet_requests.py:47: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
cashu/wallet/wallet.py:1250: in select_to_send
    _, send_proofs = await self.swap_to_send(
cashu/wallet/wallet.py:1311: in swap_to_send
    keep_proofs, send_proofs = await self.split(
cashu/wallet/wallet.py:737: in split
    new_proofs = await self._construct_proofs(
cashu/wallet/wallet.py:1012: in _construct_proofs
    C_ = BlsPublicKey(bytes.fromhex(promise.C_))
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <cashu.core.crypto.bls.PublicKey object at 0x7fd0ffe5ab60>
compressed = b"\x02Q\x0b \x80\x1a/ \xde\x82\x96q:<\x178\xb4\x9c&#\xed?\xc9#.\xfa7\xce\xaa&'e\x0e"
point = None, group = 'G1'

    def __init__(self, compressed: bytes = b"", point=None, group="G1"):
        self.group = group
        try:
            if point is not None:
                self.point = point
            elif compressed:
                if self.group == "G1":
                    self.point = pyblst.BlstP1Element().uncompress(compressed)
                else:
                    self.point = pyblst.BlstP2Element().uncompress(compressed)
            else:
                raise ValueError("Must provide point or compressed bytes")
        except Exception:
>           raise ValueError("The public key could not be parsed or is invalid.")
E           ValueError: The public key could not be parsed or is invalid.

.../core/crypto/bls.py:50: ValueError

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@ye0man ye0man added this to the 0.21.0 milestone May 8, 2026
@a1denvalu3 a1denvalu3 force-pushed the feature/bls12-381-v3-keyset branch 2 times, most recently from a8f8486 to 0c63c63 Compare May 10, 2026 17:16
robwoodgate added a commit to robwoodgate/nutshell that referenced this pull request May 13, 2026
Four coupled fixes that surface together when a fresh Nutshell 0.21.0
auth mint runs against any OIDC provider (e.g. Keycloak 25+) and a v3
(BLS) keyset is generated on first start. Each fix is small; bundled
because they share one architectural root: AuthLedger inherits the
mint CRUD (LedgerCrudSqlite) and the global `mint_input_fee_ppk`
setting, while the auth migrations chain / response models / user-id
contract never kept up.

1. Force `input_fee_ppk=0` on auth keyset generation.

   Auth proofs are NUT-22 amount-1 bearer tokens — never swapped or
   melted. `AuthLedger.verify_blind_auth` already explicitly skips
   fee calculation ("We do not calculate fees for auth keysets").
   But `Ledger.activate_keyset` reads `settings.mint_input_fee_ppk`
   unconditionally, so any mint with a non-zero global fee bakes
   that value into the auth keyset id — semantically wrong, and
   breaks wallet-side id re-derivation (auth router publishes
   `input_fee_ppk=null`, wallet derives without the suffix → id
   mismatch → keyset rejected as inauthentic). Matches CDK's
   behaviour (crates/cdk/src/mint/builder.rs forces fee=0 for the
   Auth unit).

   Implementation: `LedgerKeysets` exposes a per-instance
   `keyset_input_fee_ppk: Optional[int] = None` defaulting to
   `settings.mint_input_fee_ppk`; `AuthLedger` overrides to `0`.
   No behaviour change for non-auth ledgers.

2. m003: add `final_expiry` column to auth `keysets` table.

   LedgerCrudSqlite.store_keyset INSERTs `final_expiry` (added on
   the mint side in m031 for keysets v2). Auth migrations stopped
   at m002, so v3 keyset generation crashes with
   `no column named final_expiry`. Mirrors mint m031.

3. m004: align auth `promises` table with the mint-side schema.

   The mint side evolved `promises` to add mint_quote / swap_id
   (m023) and melt_quote / signed_at + drop the `c_ NOT NULL`
   constraint (m032-ish). LedgerCrudSqlite.store_promise INSERTs
   the full column set, so auth-side blind minting (first
   exercised by v3 BAT issuance at 0.21+) trips first
   `no column named mint_quote` then
   `NOT NULL constraint failed: promises.c_`. Auth never populates
   any of these new columns, but the schema must accept the
   INSERT. SQLite path rebuilds the table (matching mint m032
   shape); Postgres path uses ALTER chain.

4. Tolerate missing `sub` claim in clear-auth tokens.

   `_get_user` hard-coded `decoded_token["sub"]`, which raises
   KeyError when the IdP omits `sub` from access tokens. Keycloak
   25+ does this by default for public clients (the
   `oidc-subject-mapper` declared in the cashu-realm.json gets
   silently dropped on import). CDK's `verify_cat` doesn't read
   `sub` at all and works against the same realm. Fall back to
   `preferred_username` then `azp` so single-user-per-realm
   rate-limit tracking still works on those setups without
   changing happy-path semantics for IdPs that do ship `sub`.
   Cross-IdP, not Keycloak-specific.

Verified end-to-end against a freshly-built local container and
cashu-ts (v3 BAT path) — wallet OIDC password grant → 3 BATs minted →
auth keyset id verifies (`02 + sha256("1:<G2-pubkey>|unit:auth")` →
`027cbc55...`) → BLS pairing accepts the BATs → mint/swap/receive all
green.

Out of scope: the underlying smell is `AuthLedger` using
`LedgerCrudSqlite` instead of the (existing-but-unused)
`AuthLedgerCrudSqlite`, whose leaner `store_keyset` / `store_promise`
already match the auth m001 schema and would obviate (1)–(3).
Switching requires adding several missing methods to `AuthLedgerCrud`
(`store_blinded_message`, `update_keyset`, `bump_keyset_*`, balance
logs) — too wide for this PR. Worth a follow-up issue.

Refs cashubtc#999.
robwoodgate added a commit to robwoodgate/nutshell that referenced this pull request May 13, 2026
Five coupled fixes that surface together when a fresh Nutshell 0.21.0
auth mint runs against any OIDC provider (e.g. Keycloak 25+) and a v3
(BLS) keyset is generated on first start. Each fix is small; bundled
because they share one architectural root: AuthLedger inherits the
mint CRUD (LedgerCrudSqlite) and the global `mint_input_fee_ppk`
setting, while the auth migrations chain / response models / user-id
contract / auth-side CRUD never kept up.

1. Force `input_fee_ppk=0` on auth keyset generation.

   Auth proofs are NUT-22 amount-1 bearer tokens — never swapped or
   melted. `AuthLedger.verify_blind_auth` already explicitly skips
   fee calculation ("We do not calculate fees for auth keysets").
   But `Ledger.activate_keyset` reads `settings.mint_input_fee_ppk`
   unconditionally, so any mint with a non-zero global fee bakes
   that value into the auth keyset id — semantically wrong, and
   breaks wallet-side id re-derivation (auth router publishes
   `input_fee_ppk=null`, wallet derives without the suffix → id
   mismatch → keyset rejected as inauthentic). Matches CDK's
   behaviour (crates/cdk/src/mint/builder.rs forces fee=0 for the
   Auth unit).

   Implementation: `LedgerKeysets` exposes a per-instance
   `keyset_input_fee_ppk: Optional[int] = None` defaulting to
   `settings.mint_input_fee_ppk`; `AuthLedger` overrides to `0`.
   No behaviour change for non-auth ledgers.

2. m003: add `final_expiry` column to auth `keysets` table.

   LedgerCrudSqlite.store_keyset INSERTs `final_expiry` (added on
   the mint side in m031 for keysets v2). Auth migrations stopped
   at m002, so v3 keyset generation crashes with
   `no column named final_expiry`. Mirrors mint m031.

3. m004: align auth `promises` table with the mint-side schema.

   The mint side evolved `promises` to add mint_quote / swap_id
   (m023) and melt_quote / signed_at + drop the `c_ NOT NULL`
   constraint (m032-ish). LedgerCrudSqlite.store_promise INSERTs
   the full column set, so auth-side blind minting (first
   exercised by v3 BAT issuance at 0.21+) trips first
   `no column named mint_quote` then
   `NOT NULL constraint failed: promises.c_`. Auth never populates
   any of these new columns, but the schema must accept the
   INSERT. SQLite path rebuilds the table (matching mint m032
   shape); Postgres path uses ALTER chain.

4. Tolerate missing `sub` claim in clear-auth tokens.

   `_get_user` hard-coded `decoded_token["sub"]`, which raises
   KeyError when the IdP omits `sub` from access tokens. Keycloak
   25+ does this by default for public clients (the
   `oidc-subject-mapper` declared in the cashu-realm.json gets
   silently dropped on import). CDK's `verify_cat` doesn't read
   `sub` at all and works against the same realm. Fall back to
   `preferred_username` then `azp` so single-user-per-realm
   rate-limit tracking still works on those setups without
   changing happy-path semantics for IdPs that do ship `sub`.
   Cross-IdP, not Keycloak-specific.

5. AuthLedgerCrudSqlite.get_keyset: use MintKeyset.from_row.

   `MintKeyset(**row)` passes `amounts` as the raw stringified-JSON
   stored in SQLite (e.g. `"[1]"`) directly into the constructor.
   `MintKeyset.from_row` does `json.loads(row["amounts"])` first.
   Iteration over `self.amounts` would then walk characters instead
   of elements — producing junk key material. Latent in the current
   architecture because AuthLedger uses LedgerCrudSqlite (whose
   get_keyset is correct) and self.auth_crud is only invoked for
   user CRUD, but a real trap for the eventual switch-to-
   AuthLedgerCrudSqlite cleanup. Spotted by the security-scan bot
   on this PR.

Verified end-to-end against a freshly-built local container and
cashu-ts (v3 BAT path) — wallet OIDC password grant → 3 BATs minted →
auth keyset id verifies (`02 + sha256("1:<G2-pubkey>|unit:auth")` →
`027cbc55...`) → BLS pairing accepts the BATs → mint/swap/receive all
green.

Out of scope: the underlying smell is `AuthLedger` using
`LedgerCrudSqlite` instead of the (existing-but-unused)
`AuthLedgerCrudSqlite`, whose leaner `store_keyset` / `store_promise`
already match the auth m001 schema and would obviate (1)–(3).
Switching requires adding several missing methods to `AuthLedgerCrud`
(`store_blinded_message`, `update_keyset`, `bump_keyset_*`, balance
logs) — too wide for this PR. Worth a follow-up issue.

Refs cashubtc#999.
robwoodgate added a commit to robwoodgate/nutshell that referenced this pull request May 13, 2026
Five coupled fixes that surface together when a fresh Nutshell 0.21.0
auth mint runs against any OIDC provider (e.g. Keycloak 25+) and a v3
(BLS) keyset is generated on first start. Each fix is small; bundled
because they share one architectural root: AuthLedger inherits the
mint CRUD (LedgerCrudSqlite) and the global `mint_input_fee_ppk`
setting, while the auth migrations chain / response models / user-id
contract / auth-side CRUD never kept up.

1. Force `input_fee_ppk=0` on auth keyset generation.

   Auth proofs are NUT-22 amount-1 bearer tokens — never swapped or
   melted. `AuthLedger.verify_blind_auth` already explicitly skips
   fee calculation ("We do not calculate fees for auth keysets").
   But `Ledger.activate_keyset` reads `settings.mint_input_fee_ppk`
   unconditionally, so any mint with a non-zero global fee bakes
   that value into the auth keyset id — semantically wrong, and
   breaks wallet-side id re-derivation (auth router publishes
   `input_fee_ppk=null`, wallet derives without the suffix → id
   mismatch → keyset rejected as inauthentic). Matches CDK's
   behaviour (crates/cdk/src/mint/builder.rs forces fee=0 for the
   Auth unit).

   Implementation: `LedgerKeysets` exposes a per-instance
   `keyset_input_fee_ppk: Optional[int] = None` defaulting to
   `settings.mint_input_fee_ppk`; `AuthLedger` overrides to `0`.
   No behaviour change for non-auth ledgers.

2. m003: add `final_expiry` column to auth `keysets` table.

   LedgerCrudSqlite.store_keyset INSERTs `final_expiry` (added on
   the mint side in m031 for keysets v2). Auth migrations stopped
   at m002, so v3 keyset generation crashes with
   `no column named final_expiry`. Mirrors mint m031.

3. m004: align auth `promises` table with the mint-side schema.

   The mint side evolved `promises` to add mint_quote / swap_id
   (m023) and melt_quote / signed_at + drop the `c_ NOT NULL`
   constraint (m032-ish). LedgerCrudSqlite.store_promise INSERTs
   the full column set, so auth-side blind minting (first
   exercised by v3 BAT issuance at 0.21+) trips first
   `no column named mint_quote` then
   `NOT NULL constraint failed: promises.c_`. Auth never populates
   any of these new columns, but the schema must accept the
   INSERT. SQLite path rebuilds the table (matching mint m032
   shape); Postgres path uses ALTER chain.

4. Tolerate missing `sub` claim in clear-auth tokens.

   `_get_user` hard-coded `decoded_token["sub"]`, which raises
   KeyError when the IdP omits `sub` from access tokens. Keycloak
   25+ does this by default for public clients (the
   `oidc-subject-mapper` declared in the cashu-realm.json gets
   silently dropped on import). CDK's `verify_cat` doesn't read
   `sub` at all and works against the same realm. Fall back to
   `preferred_username` then `azp` so single-user-per-realm
   rate-limit tracking still works on those setups without
   changing happy-path semantics for IdPs that do ship `sub`.
   Cross-IdP, not Keycloak-specific.

5. AuthLedgerCrudSqlite.get_keyset: use MintKeyset.from_row.

   `MintKeyset(**row)` passes `amounts` as the raw stringified-JSON
   stored in SQLite (e.g. `"[1]"`) directly into the constructor.
   `MintKeyset.from_row` does `json.loads(row["amounts"])` first.
   Iteration over `self.amounts` would then walk characters instead
   of elements — producing junk key material. Latent in the current
   architecture because AuthLedger uses LedgerCrudSqlite (whose
   get_keyset is correct) and self.auth_crud is only invoked for
   user CRUD, but a real trap for the eventual switch-to-
   AuthLedgerCrudSqlite cleanup.

Verified end-to-end against a freshly-built local container and
cashu-ts (v3 BAT path) — wallet OIDC password grant → 3 BATs minted →
auth keyset id verifies (`02 + sha256("1:<G2-pubkey>|unit:auth")` →
`027cbc55...`) → BLS pairing accepts the BATs → mint/swap/receive all
green.

Out of scope: the underlying smell is `AuthLedger` using
`LedgerCrudSqlite` instead of the (existing-but-unused)
`AuthLedgerCrudSqlite`, whose leaner `store_keyset` / `store_promise`
already match the auth m001 schema and would obviate (1)–(3).
Switching requires adding several missing methods to `AuthLedgerCrud`
(`store_blinded_message`, `update_keyset`, `bump_keyset_*`, balance
logs) — too wide for this PR. Worth a follow-up issue.

Refs cashubtc#999.
@a1denvalu3 a1denvalu3 force-pushed the feature/bls12-381-v3-keyset branch 2 times, most recently from 86aa13e to c894cf3 Compare May 20, 2026 16:19
- Use single miller loop accumulation by negating the signature point
- Verify against the identity element (BlstFP12Element)
- Applies to both single and batch pairing verification functions
- Add NUT-00 round-trip test vectors to  for v3 (BLS12-381)
- Add NUT-02 keyset ID test vectors to  for v3 keysets
- Add NUT-13 secret and blinding factor derivation test vector to
- Add TRACE level logging in core BLS operations (bls_dhke.py, keys.py, secrets.py) for tracking blinding factor reduction, derivation, and verification states
- Hoist in-line imports to module level
@a1denvalu3 a1denvalu3 marked this pull request as ready for review May 20, 2026 20:35
Moved _G2_HEX string definition and uncompression step to global scope in bls.py to avoid repeated initialization and uncompression in pairing and batch pairing verification functions. Imported the cached G2 point directly into bls_dhke.py.
Added is_infinity method to PublicKey class and updated step2_bob to formally verify the blinded message is not the point at infinity instead of checking the serialized hex string against a hardcoded constant.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

2 participants