Skip to content

Commit 0afe16c

Browse files
committed
base64 keysets detection
1 parent 517d5ff commit 0afe16c

File tree

3 files changed

+86
-3
lines changed

3 files changed

+86
-3
lines changed

cashu/core/crypto/keys.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,49 @@ def derive_keyset_short_id(keyset_id: str) -> str:
121121
raise ValueError(f"Unsupported keyset version in ID: {keyset_id}")
122122

123123

124+
def is_base64_keyset_id(keyset_id: str) -> bool:
125+
"""
126+
Check if a keyset ID is a legacy base64 format (pre-0.15.0).
127+
128+
Base64 keyset IDs:
129+
- Don't start with "00" or "01" version prefix
130+
- Are typically 12 characters long
131+
- Are valid base64 strings
132+
133+
Args:
134+
keyset_id: The keyset ID to check
135+
136+
Returns:
137+
True if the keyset ID is base64 format, False otherwise
138+
"""
139+
# If it starts with a known version prefix, it's not base64
140+
if keyset_id.startswith("00") or keyset_id.startswith("01"):
141+
return False
142+
143+
# Try to decode as base64 to confirm
144+
try:
145+
base64.b64decode(keyset_id)
146+
return True
147+
except Exception:
148+
return False
149+
150+
124151
def get_keyset_id_version(keyset_id: str) -> str:
125-
"""Extract the version from a keyset ID."""
152+
"""
153+
Extract the version from a keyset ID.
154+
155+
Returns:
156+
- "00" for version 0 (hex keyset IDs with 00 prefix)
157+
- "01" for version 1 (v2 keyset IDs with 01 prefix)
158+
- "base64" for legacy base64 keyset IDs (pre-0.15.0)
159+
"""
126160
if len(keyset_id) < 2:
127161
raise ValueError("Invalid keyset ID: too short")
162+
163+
# Check if it's a legacy base64 keyset ID
164+
if is_base64_keyset_id(keyset_id):
165+
return "base64"
166+
128167
return keyset_id[:2]
129168

130169

cashu/wallet/secrets.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ async def generate_determinstic_secret(
114114
one as the blinding factor) using versioned derivation.
115115
116116
NUT-13: Uses keyset version to determine derivation method:
117+
- Version "base64" (ancient, pre-0.15.0): BIP32 derivation
117118
- Version "00" (legacy): BIP32 derivation
118119
- Version "01" (v2): HMAC-SHA256 derivation
119120
"""
@@ -123,8 +124,8 @@ async def generate_determinstic_secret(
123124
version = get_keyset_id_version(keyset_id)
124125
logger.trace(f"Keyset {keyset_id} version: {version}")
125126

126-
if version == "00":
127-
# Legacy BIP32 derivation for version 00 keysets
127+
if version == "base64" or version == "00":
128+
# BIP32 derivation for base64 (ancient) and version 00 keysets
128129
return await self._derive_secret_bip32(counter, keyset_id)
129130
elif version == "01":
130131
# HMAC-SHA256 derivation for version 01 keysets (per NUT-13 test vectors)

tests/wallet/test_wallet_keysets_v2.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from cashu.core.crypto.keys import (
1717
derive_keyset_short_id,
1818
get_keyset_id_version,
19+
is_base64_keyset_id,
1920
is_keyset_id_v2,
2021
)
2122
from cashu.wallet.keyset_manager import KeysetManager
@@ -26,6 +27,7 @@
2627
MNEMONIC = "half depart obvious quality work element tank gorilla view sugar picture humble"
2728
LEGACY_V1_KEYSET_ID = "009a1f293253e41e"
2829
V2_KEYSET_ID = "016d1ce32977b2d8a340479336a77dc18db8da3e782c5083a6f33d70bc158056d1"
30+
BASE64_KEYSET_ID = "yjzQhxghPdrr"
2931

3032

3133
@pytest.mark.asyncio
@@ -370,3 +372,44 @@ async def test_error_handling():
370372

371373
with pytest.raises(ValueError, match="Unsupported keyset version"):
372374
await secrets.generate_determinstic_secret(1)
375+
376+
377+
def test_base64_keyset_id_detection():
378+
"""Test detection of base64 keyset IDs (ancient API v0 format)."""
379+
# Test base64 keyset ID is detected correctly
380+
assert is_base64_keyset_id(BASE64_KEYSET_ID)
381+
assert get_keyset_id_version(BASE64_KEYSET_ID) == "base64"
382+
383+
# Test v1 keyset is not detected as base64
384+
assert not is_base64_keyset_id(LEGACY_V1_KEYSET_ID)
385+
assert get_keyset_id_version(LEGACY_V1_KEYSET_ID) == "00"
386+
387+
# Test v2 keyset is not detected as base64
388+
assert not is_base64_keyset_id(V2_KEYSET_ID)
389+
assert get_keyset_id_version(V2_KEYSET_ID) == "01"
390+
391+
# Test that base64 ID is not considered v2
392+
assert not is_keyset_id_v2(BASE64_KEYSET_ID)
393+
394+
395+
@pytest.mark.asyncio
396+
async def test_base64_keyset_secret_derivation():
397+
"""Test that base64 keyset IDs use BIP32 derivation (legacy method)."""
398+
secrets = WalletSecrets()
399+
secrets.keyset_id = BASE64_KEYSET_ID
400+
secrets.seed = b"test_seed_for_base64"
401+
secrets.bip32 = BIP32.from_seed(secrets.seed)
402+
403+
# Test secret derivation
404+
secret, r, path = await secrets.generate_determinstic_secret(1)
405+
406+
# Should use BIP32 derivation for base64 keysets
407+
assert "m/129372'" in path
408+
assert len(secret) == 32
409+
assert len(r) == 32
410+
411+
# Verify the base64 keyset ID gets converted to integer correctly
412+
import base64
413+
keyset_id_int = int.from_bytes(base64.b64decode(BASE64_KEYSET_ID), "big") % (2**31 - 1)
414+
expected_path = f"m/129372'/0'/{keyset_id_int}'/1'"
415+
assert expected_path in path

0 commit comments

Comments
 (0)