Skip to content

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

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

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

Conversation

@a1denvalu3

@a1denvalu3 a1denvalu3 commented May 5, 2026

Copy link
Copy Markdown
Collaborator

Caution

This PR bumps Nutshell's version to 0.21

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

codecov Bot commented May 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.56044% with 38 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.35%. Comparing base (903b675) to head (c31c68b).
⚠️ Report is 6 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
cashu/core/crypto/bls_dhke.py 87.80% 10 Missing ⚠️
cashu/wallet/wallet.py 76.31% 9 Missing ⚠️
cashu/mint/ledger.py 76.19% 5 Missing ⚠️
cashu/core/crypto/bls.py 92.72% 4 Missing ⚠️
cashu/mint/verification.py 83.33% 3 Missing ⚠️
cashu/wallet/secrets.py 92.68% 3 Missing ⚠️
cashu/core/crypto/keys.py 96.36% 2 Missing ⚠️
cashu/core/crypto/secp.py 75.00% 1 Missing ⚠️
cashu/wallet/auth/auth.py 66.66% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #999      +/-   ##
==========================================
+ Coverage   75.13%   75.35%   +0.21%     
==========================================
  Files         110      112       +2     
  Lines       12099    12419     +320     
==========================================
+ Hits         9091     9358     +267     
- Misses       3008     3061      +53     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@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
@a1denvalu3 a1denvalu3 marked this pull request as ready for review May 20, 2026 20:35
a1denvalu3 added 14 commits June 8, 2026 09:58
- 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
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.
@a1denvalu3 a1denvalu3 force-pushed the feature/bls12-381-v3-keyset branch from d47dd20 to 9bf2514 Compare June 8, 2026 08:03
@a1denvalu3 a1denvalu3 force-pushed the feature/bls12-381-v3-keyset branch from 9bf2514 to 6a6a964 Compare June 8, 2026 09:46
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