Skip to content

Conversation

@lollerfirst
Copy link
Collaborator

@lollerfirst lollerfirst commented Oct 7, 2025

Keyset ID Version 2 Implementation

Warning

This PR bumps Nutshell's version to 0.19.0

Overview

This PR implements the Keyset ID Version 2 specification, which changes how keyset IDs are derived and introduces support for short keyset IDs. This change improves determinism, security, and functionality of keysets while maintaining backward compatibility.

Key Changes

1. New Keyset ID Derivation

  • Version-based ID derivation:

    • Version < 0.15.0: Base64 IDs (legacy format, 12 chars)
    • Version 0.15.0-0.18.1: V1 IDs (prefix 00, 16 chars)
    • Version ≥ 0.19.0: V2 IDs (prefix 01, 66 chars)
  • V2 Keyset ID calculation:

    • Prefixes 01 to indicate V2 format
    • Includes unit information in the hash (unit:{unit_name})
    • Optionally includes expiration timestamp (final_expiry:{timestamp})
    • Uses full SHA256 hash (32 bytes) instead of truncated hash
    • Properly sorted keys for consistent results

2. Short Keyset ID Support

  • Implemented short V2 keyset IDs (16 chars) for token efficiency
  • Added ID expansion functionality to map short→full IDs
  • Updated token redemption code to handle V2 short IDs
  • Added comprehensive detection and validation of different ID formats

3. Keyset Rotation Improvements

  • Enhanced rotate_next_keyset to support the final_expiry parameter
  • Ensures V1 keysets rotate properly into V2 keysets based on version
  • Maintains backward compatibility with existing keysets

4. DB Fetching Rearrangement

  • Improved keyset fetching from DB to use derivation path instead of ID
  • Added proper support for final_expiry field in keyset database tables
  • Updated DB schema to store and retrieve the new field
  • Fixed CRUD operations to handle V2 keyset format

5. Version-based Secret Derivation

  • Implemented keyset version-based secret derivation mechanism (NUT-13)
  • Different derivation methods are used based on keyset version:
    • Base64 keysets (pre-0.15.0) and V1 keysets (00): BIP32 derivation path
    • V2 keysets (01): HMAC-SHA256 with specific domain separation
  • HMAC-SHA256 derivation for V2 keysets:
    • Uses formula: message = "Cashu_KDF_HMAC_SHA256" || keyset_id || counter
    • Secret = HMAC-SHA256(seed, message || 0x00)
    • Blinding factor = HMAC-SHA256(seed, message || 0x01)
  • Ensures deterministic secret generation while improving security

Technical Details

Keyset ID Derivation Changes

The V2 keyset ID derivation now includes:

  1. Sorting public keys by amount in ascending order
  2. Concatenating all public key bytes without separators
  3. Adding unit:{unit_name} to the byte array
  4. Optionally adding final_expiry:{timestamp} if provided
  5. Computing SHA256 hash of the entire byte array
  6. Prefixing with 01 to indicate V2 format

This creates a more robust, deterministic ID that properly includes all relevant keyset information, unlike previous versions that only used public keys.

Secret Derivation Implementation

The wallet now detects keyset version and uses the appropriate secret derivation method:

  1. For V2 keysets (version 01):

    # Derive message components
    keyset_id_bytes = bytes.fromhex(keyset_id)
    counter_bytes = counter.to_bytes(8, byteorder="big", signed=False)
    base = b"Cashu_KDF_HMAC_SHA256" + keyset_id_bytes + counter_bytes
    
    # Derive secret and blinding factor
    secret = hmac.new(seed, base + b"\x00", hashlib.sha256).digest()
    r = hmac.new(seed, base + b"\x01", hashlib.sha256).digest()
  2. For V1 keysets (version 00) and Base64 keysets:

    # Convert keyset ID to integer
    keyest_id_int = int.from_bytes(bytes.fromhex(keyset_id), "big") % (2**31 - 1)
    
    # Generate BIP32 derivation paths
    token_derivation_path = f"m/129372'/0'/{keyest_id_int}'/{counter}'"
    secret_derivation_path = f"{token_derivation_path}/0"
    r_derivation_path = f"{token_derivation_path}/1"
    
    # Derive keys using BIP32
    secret = bip32.get_privkey_from_path(secret_derivation_path)
    r = bip32.get_privkey_from_path(r_derivation_path)

The secret derivation mechanism is critical for wallet restoration and token recovery. This change ensures compatibility with all keyset formats while introducing a more robust derivation method for V2 keysets.

Rotate-Next-Keyset Behavior

When rotate_next_keyset is called:

  1. It finds the highest counter keyset for the specified unit
  2. Creates a new derivation path, increasing the counter by one
  3. Generates a new keyset with the latest ID v2 format
  4. The new final_expiry parameter is passed through to the new keyset
  5. The old keyset is deactivated

Keyset Fetching Rearrangement

Previously, keysets were fetched from DB by their ID, which could cause issues when trying to load a keyset whose ID calculation might have changed. Now:

  1. Keysets are primarily fetched by derivation path in the activate_keyset method
  2. This allows proper loading of keysets regardless of ID format changes
  3. New keysets are created only when no matching derivation path is found

Backward Compatibility

  • All existing keysets continue to work correctly
  • Older version tokens can be redeemed without issues
  • Automatic detection of different keyset ID formats
  • V2 short IDs can be expanded to full IDs for operations

Testing

  • Comprehensive test suite added for all new functionality
  • Test vectors for all keyset ID formats and secret derivation
  • Tests for rotation behavior between V1 and V2 keysets
  • Validation of proper short ID expansion
  • Verified secret derivation matches NUT-13 specification

@lollerfirst lollerfirst marked this pull request as draft October 8, 2025 16:50
@lollerfirst lollerfirst closed this Oct 9, 2025
@lollerfirst lollerfirst reopened this Oct 10, 2025
@lollerfirst lollerfirst marked this pull request as ready for review October 10, 2025 12:53
@codecov
Copy link

codecov bot commented Oct 13, 2025

Codecov Report

❌ Patch coverage is 70.65868% with 49 lines in your changes missing coverage. Please review.
✅ Project coverage is 49.23%. Comparing base (4a4b7f7) to head (c929f1b).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
cashu/mint/management_rpc/protos/management_pb2.py 0.00% 11 Missing ⚠️
cashu/wallet/wallet_deprecated.py 0.00% 9 Missing ⚠️
cashu/core/base.py 46.66% 8 Missing ⚠️
cashu/wallet/helpers.py 33.33% 6 Missing ⚠️
cashu/mint/management_rpc/cli/cli.py 0.00% 5 Missing ⚠️
cashu/core/crypto/keys.py 90.32% 3 Missing ⚠️
cashu/wallet/keyset_manager.py 90.00% 3 Missing ⚠️
cashu/mint/management_rpc/management_rpc.py 0.00% 2 Missing ⚠️
cashu/wallet/transactions.py 83.33% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #798      +/-   ##
==========================================
+ Coverage   47.84%   49.23%   +1.39%     
==========================================
  Files          90       92       +2     
  Lines       10592    11026     +434     
==========================================
+ Hits         5068     5429     +361     
- Misses       5524     5597      +73     

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

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@callebtc callebtc self-requested a review October 19, 2025 11:50
@lollerfirst lollerfirst added this to the 0.18.0 milestone Oct 20, 2025
@lollerfirst lollerfirst modified the milestones: 0.18.0, 0.18.1 Nov 1, 2025
@lollerfirst lollerfirst requested a review from asmogo November 1, 2025 18:42
@lollerfirst lollerfirst modified the milestones: 0.18.1, 0.18.2 Nov 5, 2025
@lollerfirst lollerfirst self-assigned this Nov 7, 2025
@lollerfirst lollerfirst added mint About the Nutshell mint ready Reviewed, tested, ready to merge labels Nov 7, 2025
Keysets v2: align with NUT-13 HMAC derivation (4-byte counter, single HMAC split); implement wallet-side short ID manager and token helpers; apply default final_expiry on rotation; keep mint agnostic of s_id

alignment

NUT-02/13 alignment:
- Keys API: include final_expiry in /v1/keys and /v1/keys/{id}
- Derivation: keyset ID v2 per spec (unit + optional final_expiry; SHA256; v=01)
- NUT-13 v2 secrets: switch to HMAC-SHA256 with 8-byte counter and type byte (00/01)
- Tests: update wallet v2 tests to HMAC-SHA256; derive seed from BIP-39 mnemonic; fix TokenV4 short ID expansion test to use KeysetManager directly; update API expectations for final_expiry

Remove mint_use_keysets_v2 flag; always generate v2 keyset IDs for new keysets. Keep optional default final_expiry via settings. Update keyset rotation/activation to use default expiry unconditionally. Update docs.

wallet: log debug when resolving short keyset id to full id (cache hit).

wallet: integrate v2 short keyset IDs directly in TokenV4 creation; deprecate WalletTokensV2 helper; tests: remove tokens_v2 dependency, assert short-ID serialization; secrets: NUT-13 v2 docstring to HMAC-SHA256; proofs: fix helper method refactor regression; cleanup

remove deprecated cashu/wallet/tokens_v2.py; short-ID handling now in WalletProofs._make_tokenv4

Summary of changes on keysets-v2
- Removed deprecated helper:
  - cashu/wallet/tokens_v2.py deleted.
- Integrated short-ID handling into wallet TokenV4 creation:
  - cashu/wallet/proofs.py: _make_tokenv4 now converts v2 full keyset IDs to short IDs during token creation via KeysetManager; ensured _get_proofs_mint_unit remains correct.
- Updated tests to remove tokens_v2 dependency:
  - tests/wallet/test_wallet_keysets_v2.py: test_token_serialization_with_short_ids now uses WalletProofs to assert short-ID serialization.
- Docstring correction:
  - cashu/wallet/secrets.py: NUT-13 v2 derivation is HMAC-SHA256 (not SHA512).

Remote
- Branch keysets-v2 is now ahead by 2 commits and pushed to origin.

format

move tests to v2 keyset id

fixed test_mint_api

more fixes

bump nutshell version to 0.18 => derive ID v1/v2 based on nutshell version of the keyset: if not specified the nutshell version is 0.18 and the keyset ID is v2

more fixes

Fix keyset version tests: update assertions for v1/v2 ID expectations

- Change test_keyset_0_15_0 and test_keyset_0_15_0_encrypted to expect V1_KEYSET_ID (version 0.15.0 uses v1 IDs)
- Update test_keyset_short_id to properly assert V1_KEYSET_ID for legacy keysets
- Simplify test_keyset_v2_deterministic to use version 0.18.0 which uses v2 IDs
- Remove redundant test_keyset_v1_v2_compatibility test (covered by other tests)
- Update test section header to 'KEYSET IDs NUT-02 TEST VECTORS'

Add comprehensive keyset ID test vectors and version behavior tests

New tests added:
1. test_keyset_versions_produce_correct_id_format: Tests that different
   nutshell versions (0.11, 0.14, 0.15, 0.17, 0.18, 0.19) produce the
   correct ID format (base64 for <0.15, v1 hex for 0.15-0.17, v2 hex for >=0.18)

2. test_keyset_id_v1_test_vectors: Implements NUT-02 test vectors for v1
   keyset ID derivation with two test vectors (small 4-key keyset and
   large 64-key keyset)

3. test_keyset_id_v2_test_vectors: Implements NUT-02 test vectors for v2
   keyset ID derivation with three test vectors testing different
   combinations of keyset sizes, units, and final_expiry values

These tests ensure compatibility with the NUT specifications and verify
that the keyset ID derivation algorithms produce stable, interoperable
results across different implementations.

Test vectors source: https://github.com/cashubtc/nuts/pull/182/files

Fix test_keyset_version_detection for v1 vs v2 ID distinction

Corrected the test to properly distinguish between v1 and v2 keyset IDs:
- V1 IDs (version 0.15-0.17) start with '00' and are NOT detected as v2
- V2 IDs (version 0.18+) start with '01' and ARE detected as v2

This test was incorrectly expecting a v1 keyset (version 0.15.0) to be
identified as v2.

more fixes

Add v2 short keyset ID expansion support for token redemption

Fix for TokenV3 and TokenV4 redemption with v2 short keyset IDs:

- Added _expand_short_keyset_ids() method to WalletProofs class
  - Expands v2 short IDs (16 chars, '01' + 7 bytes) to full IDs (66 chars)
  - Uses KeysetManager to map short->full IDs from loaded keysets
  - Handles expansion failures gracefully with warning logging

- Updated redeem_TokenV3() and redeem_TokenV4() in helpers.py
  - Calls _expand_short_keyset_ids() after load_mint() to ensure
    all keyset IDs in proofs are expanded before redemption

- Added comprehensive test: test_proof_short_id_expansion()
  - Verifies short ID expansion works correctly
  - Tests that proofs with v2 short IDs get expanded to full IDs

- Fixed existing tests in test_wallet_keysets_v2.py
  - Removed incorrect 'await' calls on synchronous KeysetManager methods
  - Fixed test_token_v4_short_keyset_expansion to use proper v2 short ID
  - Fixed test_nut13_spec_compliance to use actual V2_KEYSET_ID constant

- Updated test_wallet_cli.py token to use v2 short keyset IDs

This ensures wallets can properly handle TokenV3/V4 tokens containing
v2 short keyset IDs, which are used to reduce token size.

feat: improve v2 keyset ID handling and fee calculation

- Add short-to-full keyset ID expansion in wallet helpers
- Enhance KeysetManager with better documentation and type hints
- Fix fee calculation to handle short keyset IDs gracefully
- Add keyset ID expansion before proof redemption in CLI
- Update tests to reflect new keyset ID handling behavior
- Add fallback logic for missing keysets in fee calculations

format

fixes

base64 keysets detection

remove weird import

cannot import Unit in keys -> fix

is_base64_keyset_id fixes

simplify code and move imports on top of file

remove unnecessary ID logic in get_fees_for_keyset and get_fees_for_proofs

format

move import on top of file, error if we can't map short IDs to full IDs

bugfix: activate keysets from the DB if any

update `rotate_next_keyset` and mint management rpc with final_expiry field
@lollerfirst lollerfirst modified the milestones: 0.18.2, 0.19.0 Nov 8, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

mint About the Nutshell mint ready Reviewed, tested, ready to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant