feat(crypto): migrate BDHKE to BLS12-381 (v3 keysets)#999
Open
a1denvalu3 wants to merge 14 commits into
Open
Conversation
❌ 16 Tests Failed:
View the top 3 failed test(s) by shortest run time
To view more test analytics, go to the Test Analytics Dashboard |
a8f8486 to
0c63c63
Compare
This was referenced May 13, 2026
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.
10 tasks
86aa13e to
c894cf3
Compare
- 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
3 tasks
…ndom scalars for batch verification
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
v3keysets using the BLS12-381 curve.Y * r) to replace legacy additive blinding (Y + r*G).e(C, G2) == e(Y, K2)).v1/v2(secp256k1) keysets.02prefix for BLS keysets.Testing
secp256k1andBLS12-381logic.tests/test_crypto_bls.pytest suite specifically for deterministic hash-to-curve testing, verification of individual BLS protocol steps, and batched BLS pairing checks.