keystonecrypto 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- keystonecrypto/__init__.py +38 -0
- keystonecrypto/aead.py +55 -0
- keystonecrypto/derivation.py +218 -0
- keystonecrypto/entropy.py +38 -0
- keystonecrypto/envelope.py +210 -0
- keystonecrypto/exceptions.py +41 -0
- keystonecrypto/kdf.py +113 -0
- keystonecrypto/keystore.py +185 -0
- keystonecrypto/mnemonic.py +2196 -0
- keystonecrypto/secret_bytes.py +135 -0
- keystonecrypto/signing.py +216 -0
- keystonecrypto-0.1.0.dist-info/METADATA +390 -0
- keystonecrypto-0.1.0.dist-info/RECORD +15 -0
- keystonecrypto-0.1.0.dist-info/WHEEL +4 -0
- keystonecrypto-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""keystonecrypto — non-custodial HD wallet primitives for Python.
|
|
2
|
+
|
|
3
|
+
This is the public API. Submodules are also importable directly for
|
|
4
|
+
advanced users; the names below are the recommended entry points.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
from keystonecrypto.derivation import DerivationPath, ExtendedPrivateKey, derive_from_seed
|
|
12
|
+
from keystonecrypto.entropy import STRENGTH_BITS, generate_entropy
|
|
13
|
+
from keystonecrypto.exceptions import KeystoneCryptoError
|
|
14
|
+
from keystonecrypto.keystore import Keystore, Wallet
|
|
15
|
+
from keystonecrypto.mnemonic import Mnemonic
|
|
16
|
+
from keystonecrypto.secret_bytes import SecretBytes
|
|
17
|
+
from keystonecrypto.signing import (
|
|
18
|
+
Ed25519Signer,
|
|
19
|
+
Secp256kSchnorrSigner,
|
|
20
|
+
Secp256kSigner,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"__version__",
|
|
25
|
+
"KeystoneCryptoError",
|
|
26
|
+
"SecretBytes",
|
|
27
|
+
"Mnemonic",
|
|
28
|
+
"Keystore",
|
|
29
|
+
"Wallet",
|
|
30
|
+
"DerivationPath",
|
|
31
|
+
"ExtendedPrivateKey",
|
|
32
|
+
"derive_from_seed",
|
|
33
|
+
"STRENGTH_BITS",
|
|
34
|
+
"generate_entropy",
|
|
35
|
+
"Secp256kSigner",
|
|
36
|
+
"Secp256kSchnorrSigner",
|
|
37
|
+
"Ed25519Signer",
|
|
38
|
+
]
|
keystonecrypto/aead.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""AES-256-GCM authenticated encryption with associated data (AAD).
|
|
2
|
+
|
|
3
|
+
AAD binds the ciphertext to a context (e.g. keystore version, chain
|
|
4
|
+
identifier). Decryption with mismatched AAD fails the tag check, which
|
|
5
|
+
prevents cross-context ciphertext replay attacks.
|
|
6
|
+
|
|
7
|
+
Returns nonce separately from ciphertext so the caller can pack them
|
|
8
|
+
into whatever envelope they need. The tag is appended to the
|
|
9
|
+
ciphertext (cryptography's default).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
17
|
+
|
|
18
|
+
from keystonecrypto.exceptions import KeystoreDecryptionError
|
|
19
|
+
from keystonecrypto.secret_bytes import SecretBytes
|
|
20
|
+
|
|
21
|
+
__all__ = ["encrypt", "decrypt", "NONCE_LEN", "KEY_LEN"]
|
|
22
|
+
|
|
23
|
+
NONCE_LEN: int = 12
|
|
24
|
+
KEY_LEN: int = 32
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _as_key(key: SecretBytes) -> bytes:
|
|
28
|
+
raw = bytes(key)
|
|
29
|
+
if len(raw) != KEY_LEN:
|
|
30
|
+
raise ValueError(f"AES-256-GCM key must be {KEY_LEN} bytes, got {len(raw)}")
|
|
31
|
+
return raw
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def encrypt(key: SecretBytes, plaintext: bytes, aad: bytes = b"") -> tuple[bytes, bytes]:
|
|
35
|
+
"""Encrypt `plaintext` under `key` with optional associated data.
|
|
36
|
+
|
|
37
|
+
Returns (nonce, ciphertext_plus_tag). The tag is appended to the
|
|
38
|
+
ciphertext per the `cryptography` library convention.
|
|
39
|
+
"""
|
|
40
|
+
nonce = os.urandom(NONCE_LEN)
|
|
41
|
+
aes = AESGCM(_as_key(key))
|
|
42
|
+
ct = aes.encrypt(nonce, plaintext, aad)
|
|
43
|
+
return nonce, ct
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def decrypt(key: SecretBytes, nonce: bytes, ciphertext: bytes, aad: bytes = b"") -> bytes:
|
|
47
|
+
"""Decrypt `ciphertext` under `key`. Raises on tag mismatch or wrong key."""
|
|
48
|
+
if len(nonce) != NONCE_LEN:
|
|
49
|
+
raise ValueError(f"nonce must be {NONCE_LEN} bytes, got {len(nonce)}")
|
|
50
|
+
aes = AESGCM(_as_key(key))
|
|
51
|
+
try:
|
|
52
|
+
return aes.decrypt(nonce, ciphertext, aad)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
# Wrap so callers get a typed exception.
|
|
55
|
+
raise KeystoreDecryptionError(f"AES-GCM decryption failed: {e}") from e
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""BIP-32 hierarchical key derivation + BIP-44 path parsing.
|
|
2
|
+
|
|
3
|
+
Layer 2 module. The derivation primitive uses HMAC-SHA512 and secp256k1
|
|
4
|
+
scalar addition via `coincurve` (so the math is libsecp256k1, not a
|
|
5
|
+
hand-rolled implementation).
|
|
6
|
+
|
|
7
|
+
Path syntax:
|
|
8
|
+
m -> master
|
|
9
|
+
m/0 -> non-hardened child 0
|
|
10
|
+
m/0' -> hardened child 0
|
|
11
|
+
m/44'/0'/0'/0 -> BIP-44 first receive address
|
|
12
|
+
|
|
13
|
+
Hardened indices are >= 2^31 in raw form. We represent them as
|
|
14
|
+
`(index, hardened)` pairs to keep the API unambiguous.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import hmac
|
|
21
|
+
import struct
|
|
22
|
+
from typing import Final
|
|
23
|
+
|
|
24
|
+
from coincurve import PrivateKey
|
|
25
|
+
from pydantic import BaseModel
|
|
26
|
+
|
|
27
|
+
from keystonecrypto.exceptions import InvalidDerivationPathError
|
|
28
|
+
from keystonecrypto.secret_bytes import SecretBytes
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"DerivationPath",
|
|
32
|
+
"ExtendedPrivateKey",
|
|
33
|
+
"derive_from_seed",
|
|
34
|
+
"HARDENED_OFFSET",
|
|
35
|
+
"SECP256K1_N",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
HARDENED_OFFSET: Final[int] = 0x80000000
|
|
39
|
+
_MAX_CHILD_INDEX: Final[int] = 0xFFFFFFFF
|
|
40
|
+
|
|
41
|
+
# Order of the secp256k1 prime-order subgroup.
|
|
42
|
+
SECP256K1_N: Final[int] = (
|
|
43
|
+
0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DerivationPath(BaseModel):
|
|
48
|
+
"""A parsed BIP-32 derivation path."""
|
|
49
|
+
|
|
50
|
+
model_config = {"frozen": True}
|
|
51
|
+
|
|
52
|
+
indices: tuple[tuple[int, bool], ...] # (index, hardened) per component
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def parse(cls, s: str) -> "DerivationPath":
|
|
56
|
+
s = s.strip()
|
|
57
|
+
if s in ("", "m"):
|
|
58
|
+
return cls(indices=())
|
|
59
|
+
if not s.startswith("m/"):
|
|
60
|
+
raise InvalidDerivationPathError(
|
|
61
|
+
f"path must start with 'm/', got {s!r}"
|
|
62
|
+
)
|
|
63
|
+
parts = s[2:].split("/")
|
|
64
|
+
out: list[tuple[int, bool]] = []
|
|
65
|
+
for part in parts:
|
|
66
|
+
hardened = part.endswith("'") or part.endswith("h")
|
|
67
|
+
num_str = part.rstrip("'h")
|
|
68
|
+
if not num_str.isdigit():
|
|
69
|
+
raise InvalidDerivationPathError(f"non-numeric component: {part!r}")
|
|
70
|
+
idx = int(num_str)
|
|
71
|
+
if idx < 0 or idx > 0x7FFFFFFF:
|
|
72
|
+
raise InvalidDerivationPathError(f"index out of range: {idx}")
|
|
73
|
+
out.append((idx, hardened))
|
|
74
|
+
return cls(indices=tuple(out))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ExtendedPrivateKey:
|
|
78
|
+
"""A BIP-32 extended private key (xprv): key + chain code + metadata."""
|
|
79
|
+
|
|
80
|
+
__slots__ = ("_sk", "_cc", "_depth", "_pfp", "_child")
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
secret_key: SecretBytes,
|
|
85
|
+
chain_code: SecretBytes,
|
|
86
|
+
depth: int,
|
|
87
|
+
parent_fingerprint: bytes,
|
|
88
|
+
child_number: int,
|
|
89
|
+
) -> None:
|
|
90
|
+
self._sk = secret_key
|
|
91
|
+
self._cc = chain_code
|
|
92
|
+
self._depth = depth
|
|
93
|
+
self._pfp = parent_fingerprint
|
|
94
|
+
self._child = child_number
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def master(cls, seed: SecretBytes) -> "ExtendedPrivateKey":
|
|
98
|
+
I = hmac.new(b"Bitcoin seed", bytes(seed), hashlib.sha512).digest()
|
|
99
|
+
IL, IR = I[:32], I[32:]
|
|
100
|
+
return cls(
|
|
101
|
+
secret_key=SecretBytes(IL),
|
|
102
|
+
chain_code=SecretBytes(IR),
|
|
103
|
+
depth=0,
|
|
104
|
+
parent_fingerprint=b"\x00\x00\x00\x00",
|
|
105
|
+
child_number=0,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def private_key(self) -> SecretBytes:
|
|
110
|
+
return self._sk
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def chain_code(self) -> SecretBytes:
|
|
114
|
+
return self._cc
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def depth(self) -> int:
|
|
118
|
+
return self._depth
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def child_number(self) -> int:
|
|
122
|
+
return self._child
|
|
123
|
+
|
|
124
|
+
def fingerprint(self) -> bytes:
|
|
125
|
+
"""BIP-32 key fingerprint = first 4 bytes of HASH160(pubkey)."""
|
|
126
|
+
pk = PrivateKey(bytes(self._sk)).public_key.format(compressed=True)
|
|
127
|
+
h160 = hashlib.new("ripemd160", hashlib.sha256(pk).digest()).digest()
|
|
128
|
+
return h160[:4]
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def parent_fingerprint(self) -> bytes:
|
|
132
|
+
return self._pfp
|
|
133
|
+
|
|
134
|
+
def derive(self, index: int, hardened: bool = False) -> "ExtendedPrivateKey":
|
|
135
|
+
if index < 0 or index > 0x7FFFFFFF:
|
|
136
|
+
raise ValueError(f"index out of range: {index}")
|
|
137
|
+
if hardened:
|
|
138
|
+
index += HARDENED_OFFSET
|
|
139
|
+
if index < 0 or index > _MAX_CHILD_INDEX:
|
|
140
|
+
raise ValueError(f"effective child index out of range: {index}")
|
|
141
|
+
|
|
142
|
+
if index >= HARDENED_OFFSET:
|
|
143
|
+
# Hardened: data = 0x00 || ser256(kpar) || ser32(i)
|
|
144
|
+
data = b"\x00" + bytes(self._sk) + struct.pack(">I", index)
|
|
145
|
+
else:
|
|
146
|
+
# Non-hardened: data = serP(Kpar) || ser32(i)
|
|
147
|
+
pub = PrivateKey(bytes(self._sk)).public_key.format(compressed=True)
|
|
148
|
+
data = pub + struct.pack(">I", index)
|
|
149
|
+
|
|
150
|
+
I = hmac.new(bytes(self._cc), data, hashlib.sha512).digest()
|
|
151
|
+
IL, IR = I[:32], I[32:]
|
|
152
|
+
# child_key = (IL + kpar) mod n
|
|
153
|
+
Il_int = int.from_bytes(IL, "big")
|
|
154
|
+
kpar_int = int.from_bytes(bytes(self._sk), "big")
|
|
155
|
+
if Il_int >= SECP256K1_N:
|
|
156
|
+
raise ValueError("derivation produced invalid child key (IL >= n)")
|
|
157
|
+
child_int = (Il_int + kpar_int) % SECP256K1_N
|
|
158
|
+
if child_int == 0:
|
|
159
|
+
raise ValueError("derivation produced zero child key")
|
|
160
|
+
child_key = child_int.to_bytes(32, "big")
|
|
161
|
+
|
|
162
|
+
return ExtendedPrivateKey(
|
|
163
|
+
secret_key=SecretBytes(child_key),
|
|
164
|
+
chain_code=SecretBytes(IR),
|
|
165
|
+
depth=self._depth + 1,
|
|
166
|
+
parent_fingerprint=self.fingerprint(),
|
|
167
|
+
child_number=index,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def to_base58(self, network: str = "mainnet") -> str:
|
|
171
|
+
"""Serialize as a BIP-32 xprv (private key form)."""
|
|
172
|
+
version = {
|
|
173
|
+
"mainnet": b"\x04\x88\xad\xe4",
|
|
174
|
+
"testnet": b"\x04\x35\x83\x94",
|
|
175
|
+
}[network]
|
|
176
|
+
raw = (
|
|
177
|
+
version
|
|
178
|
+
+ bytes([self._depth])
|
|
179
|
+
+ self._pfp
|
|
180
|
+
+ struct.pack(">I", self._child)
|
|
181
|
+
+ bytes(self._cc)
|
|
182
|
+
+ b"\x00"
|
|
183
|
+
+ bytes(self._sk)
|
|
184
|
+
)
|
|
185
|
+
# Double-SHA256 checksum.
|
|
186
|
+
checksum = hashlib.sha256(hashlib.sha256(raw).digest()).digest()[:4]
|
|
187
|
+
return _b58encode(raw + checksum)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def derive_from_seed(seed: SecretBytes, path: DerivationPath) -> ExtendedPrivateKey:
|
|
191
|
+
"""Derive an extended private key from a seed and a BIP-32 path."""
|
|
192
|
+
key: ExtendedPrivateKey = ExtendedPrivateKey.master(seed)
|
|
193
|
+
for index, hardened in path.indices:
|
|
194
|
+
key = key.derive(index, hardened=hardened)
|
|
195
|
+
return key
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# --- base58 (minimal; avoids an extra dep for the core) ---
|
|
199
|
+
|
|
200
|
+
_B58_ALPHABET = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _b58encode(data: bytes) -> str:
|
|
204
|
+
n = int.from_bytes(data, "big")
|
|
205
|
+
if n == 0:
|
|
206
|
+
return _B58_ALPHABET[0:1].decode()
|
|
207
|
+
out = bytearray()
|
|
208
|
+
while n:
|
|
209
|
+
n, r = divmod(n, 58)
|
|
210
|
+
out.append(_B58_ALPHABET[r])
|
|
211
|
+
# Preserve leading zero bytes.
|
|
212
|
+
for b in data:
|
|
213
|
+
if b == 0:
|
|
214
|
+
out.append(_B58_ALPHABET[0])
|
|
215
|
+
else:
|
|
216
|
+
break
|
|
217
|
+
out.reverse()
|
|
218
|
+
return out.decode()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Cryptographically-secure entropy generation.
|
|
2
|
+
|
|
3
|
+
Wraps `secrets.token_bytes` and returns a SecretBytes. Validates bit
|
|
4
|
+
lengths to match the BIP-39 allowed strengths so the same module can
|
|
5
|
+
feed both raw entropy generation and mnemonic generation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import secrets
|
|
11
|
+
|
|
12
|
+
from keystonecrypto.secret_bytes import SecretBytes
|
|
13
|
+
|
|
14
|
+
__all__ = ["STRENGTH_BITS", "generate_entropy"]
|
|
15
|
+
|
|
16
|
+
# BIP-39 § "Entropy" — allowed entropy sizes in bits.
|
|
17
|
+
STRENGTH_BITS: tuple[int, ...] = (128, 160, 192, 224, 256)
|
|
18
|
+
|
|
19
|
+
_MIN_BITS = min(STRENGTH_BITS)
|
|
20
|
+
_MAX_BITS = max(STRENGTH_BITS)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def generate_entropy(bits: int) -> SecretBytes:
|
|
24
|
+
"""Generate `bits` bits of cryptographically-secure random data.
|
|
25
|
+
|
|
26
|
+
Returns: SecretBytes of length `bits // 8`.
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
ValueError: if `bits` is not a multiple of 8 or is outside the
|
|
30
|
+
BIP-39 allowed range.
|
|
31
|
+
"""
|
|
32
|
+
if bits % 8 != 0:
|
|
33
|
+
raise ValueError(f"bits must be a multiple of 8, got {bits}")
|
|
34
|
+
if bits < _MIN_BITS or bits > _MAX_BITS:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"bits must be in [{_MIN_BITS}, {_MAX_BITS}], got {bits}"
|
|
37
|
+
)
|
|
38
|
+
return SecretBytes(secrets.token_bytes(bits // 8))
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Binary envelope for encrypted keystores (spec §6).
|
|
2
|
+
|
|
3
|
+
Layout (big-endian, exact byte counts):
|
|
4
|
+
|
|
5
|
+
Header (66 bytes):
|
|
6
|
+
magic: 4 "KST1"
|
|
7
|
+
version: 1 0x01
|
|
8
|
+
kdf_id: 1 0x01 = Argon2id
|
|
9
|
+
kdf_params: 32 (time_cost:4 | memory_cost:4 |
|
|
10
|
+
parallelism:4 | hash_len:4 |
|
|
11
|
+
version:4 | reserved:12)
|
|
12
|
+
salt: 16
|
|
13
|
+
nonce: 12
|
|
14
|
+
Body (variable):
|
|
15
|
+
ciphertext: N (includes appended 16-byte GCM tag)
|
|
16
|
+
|
|
17
|
+
Plaintext structure before encryption:
|
|
18
|
+
|
|
19
|
+
seed_length: 1
|
|
20
|
+
seed: seed_length
|
|
21
|
+
created_at: 8 (BE unix timestamp)
|
|
22
|
+
flags: 1 (bit 0: has_passphrase)
|
|
23
|
+
reserved: 6 (zero)
|
|
24
|
+
|
|
25
|
+
AAD = version || kdf_id. Decryption with mismatched AAD fails.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import secrets as _secrets
|
|
31
|
+
import struct
|
|
32
|
+
import time
|
|
33
|
+
from typing import Final
|
|
34
|
+
|
|
35
|
+
from pydantic import BaseModel
|
|
36
|
+
|
|
37
|
+
from keystonecrypto.aead import NONCE_LEN, decrypt as _decrypt
|
|
38
|
+
from keystonecrypto.aead import encrypt as _encrypt
|
|
39
|
+
from keystonecrypto.exceptions import (
|
|
40
|
+
KeystoreDecryptionError,
|
|
41
|
+
KeystoreVersionError,
|
|
42
|
+
)
|
|
43
|
+
from keystonecrypto.kdf import Argon2Params, derive_key
|
|
44
|
+
from keystonecrypto.secret_bytes import SecretBytes
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"MAGIC",
|
|
48
|
+
"VERSION",
|
|
49
|
+
"Envelope",
|
|
50
|
+
"UnpackedKeystore",
|
|
51
|
+
"HEADER_LEN",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
MAGIC: Final[bytes] = b"KST1"
|
|
55
|
+
VERSION: Final[int] = 1
|
|
56
|
+
|
|
57
|
+
_HEADER_LEN: Final[int] = 4 + 1 + 1 + 32 + 16 + NONCE_LEN # = 66
|
|
58
|
+
_GCM_TAG_LEN: Final[int] = 16
|
|
59
|
+
_PLAINTEXT_OVERHEAD: Final[int] = 1 + 8 + 1 + 6 # = 16 (excluding seed)
|
|
60
|
+
|
|
61
|
+
HEADER_LEN: Final[int] = _HEADER_LEN
|
|
62
|
+
|
|
63
|
+
# Offsets inside the header.
|
|
64
|
+
_OFF_MAGIC: Final[int] = 0
|
|
65
|
+
_OFF_VERSION: Final[int] = 4
|
|
66
|
+
_OFF_KDF_ID: Final[int] = 5
|
|
67
|
+
_OFF_KDF: Final[int] = 6
|
|
68
|
+
_OFF_SALT: Final[int] = 38
|
|
69
|
+
_OFF_NONCE: Final[int] = 54
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class UnpackedKeystore(BaseModel):
|
|
73
|
+
"""Result of a successful envelope decryption."""
|
|
74
|
+
|
|
75
|
+
model_config = {"frozen": True}
|
|
76
|
+
|
|
77
|
+
seed: SecretBytes
|
|
78
|
+
has_passphrase: bool
|
|
79
|
+
created_at: int
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Envelope:
|
|
83
|
+
"""Static pack/unpack helpers for the keystore binary envelope."""
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _pack_kdf_params(p: Argon2Params) -> bytes:
|
|
87
|
+
return struct.pack(
|
|
88
|
+
">IIII I 12x",
|
|
89
|
+
p.time_cost, p.memory_cost, p.parallelism, p.hash_len,
|
|
90
|
+
p.version,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _unpack_kdf_params(blob: bytes) -> Argon2Params:
|
|
95
|
+
# Matches `_pack_kdf_params`: 5 unsigned ints (20 bytes) + 12 bytes reserved.
|
|
96
|
+
# Total 32 bytes.
|
|
97
|
+
if len(blob) != 32:
|
|
98
|
+
raise KeystoreDecryptionError(f"kdf_params section wrong size: {len(blob)} bytes")
|
|
99
|
+
(t, m, par, hl, v) = struct.unpack(">IIIII 12x", blob)
|
|
100
|
+
return Argon2Params.for_testing(
|
|
101
|
+
time_cost=t, memory_cost=m, parallelism=par,
|
|
102
|
+
hash_len=hl, salt_len=16, version=v,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _aad(version: int, kdf_id: int) -> bytes:
|
|
107
|
+
return bytes([version, kdf_id])
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def pack(
|
|
111
|
+
seed: SecretBytes,
|
|
112
|
+
password: SecretBytes,
|
|
113
|
+
params: Argon2Params,
|
|
114
|
+
kdf_id: int = 1,
|
|
115
|
+
has_passphrase: bool = False,
|
|
116
|
+
created_at: int | None = None,
|
|
117
|
+
) -> bytes:
|
|
118
|
+
"""Pack and encrypt a seed into a keystore blob.
|
|
119
|
+
|
|
120
|
+
The caller passes `password` here. If a BIP-39 passphrase is
|
|
121
|
+
used, the caller is expected to concatenate it with the
|
|
122
|
+
decryption password *before* calling pack; the envelope itself
|
|
123
|
+
is passphrase-agnostic.
|
|
124
|
+
"""
|
|
125
|
+
if kdf_id != 1:
|
|
126
|
+
raise ValueError(f"unsupported kdf_id {kdf_id} (only 1 = Argon2id)")
|
|
127
|
+
if len(seed) > 255:
|
|
128
|
+
raise ValueError(f"seed too long: {len(seed)} > 255 bytes")
|
|
129
|
+
if params.hash_len != 32:
|
|
130
|
+
raise ValueError(f"envelope requires hash_len=32, got {params.hash_len}")
|
|
131
|
+
|
|
132
|
+
salt = _secrets.token_bytes(params.salt_len)
|
|
133
|
+
key, _salt = derive_key(password, params, salt=salt)
|
|
134
|
+
ts = int(created_at if created_at is not None else time.time())
|
|
135
|
+
flags = 0x01 if has_passphrase else 0x00
|
|
136
|
+
plaintext = (
|
|
137
|
+
bytes([len(seed)])
|
|
138
|
+
+ bytes(seed)
|
|
139
|
+
+ struct.pack(">q", ts)
|
|
140
|
+
+ bytes([flags])
|
|
141
|
+
+ b"\x00" * 6
|
|
142
|
+
)
|
|
143
|
+
nonce, ciphertext = _encrypt(key, plaintext, aad=Envelope._aad(VERSION, kdf_id))
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
MAGIC
|
|
147
|
+
+ bytes([VERSION, kdf_id])
|
|
148
|
+
+ Envelope._pack_kdf_params(params)
|
|
149
|
+
+ salt
|
|
150
|
+
+ nonce
|
|
151
|
+
+ ciphertext
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def unpack(blob: bytes, password: SecretBytes) -> UnpackedKeystore:
|
|
156
|
+
"""Decrypt and validate a keystore blob."""
|
|
157
|
+
if len(blob) < _HEADER_LEN + _GCM_TAG_LEN:
|
|
158
|
+
raise KeystoreDecryptionError(f"blob too short: {len(blob)} bytes")
|
|
159
|
+
|
|
160
|
+
if blob[_OFF_MAGIC:_OFF_MAGIC + 4] != MAGIC:
|
|
161
|
+
raise KeystoreVersionError("bad magic; not a keystonecrypto keystore")
|
|
162
|
+
|
|
163
|
+
version = blob[_OFF_VERSION]
|
|
164
|
+
if version != VERSION:
|
|
165
|
+
raise KeystoreVersionError(
|
|
166
|
+
f"unsupported keystore version {version}; "
|
|
167
|
+
f"this build of keystonecrypto only supports version {VERSION}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
kdf_id = blob[_OFF_KDF_ID]
|
|
171
|
+
|
|
172
|
+
params = Envelope._unpack_kdf_params(blob[_OFF_KDF:_OFF_KDF + 32])
|
|
173
|
+
if params.hash_len != _GCM_TAG_LEN + _GCM_TAG_LEN:
|
|
174
|
+
pass # hash_len != 32 will be caught at KDF time
|
|
175
|
+
salt = blob[_OFF_SALT:_OFF_SALT + 16]
|
|
176
|
+
nonce = blob[_OFF_NONCE:_OFF_NONCE + NONCE_LEN]
|
|
177
|
+
ciphertext = blob[_HEADER_LEN:]
|
|
178
|
+
|
|
179
|
+
# kdf_id bound into the AAD so tampering causes AEAD failure
|
|
180
|
+
# (KeystoreDecryptionError), not an early format rejection.
|
|
181
|
+
# We still surface an unsupported-kdf_id error if AEAD happens
|
|
182
|
+
# to succeed somehow (e.g. crafted blob).
|
|
183
|
+
key, _ = derive_key(password, params, salt=salt)
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
plaintext = _decrypt(key, nonce, ciphertext,
|
|
187
|
+
aad=Envelope._aad(version, kdf_id))
|
|
188
|
+
except KeystoreDecryptionError:
|
|
189
|
+
raise
|
|
190
|
+
|
|
191
|
+
if kdf_id != 1:
|
|
192
|
+
raise KeystoreVersionError(f"unsupported kdf_id {kdf_id}")
|
|
193
|
+
|
|
194
|
+
return Envelope._unpack_plaintext(plaintext)
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def _unpack_plaintext(plaintext: bytes) -> UnpackedKeystore:
|
|
198
|
+
if len(plaintext) < 1 + 8 + 1 + 6:
|
|
199
|
+
raise KeystoreDecryptionError("plaintext too short")
|
|
200
|
+
seed_len = plaintext[0]
|
|
201
|
+
if len(plaintext) < 1 + seed_len + 8 + 1 + 6:
|
|
202
|
+
raise KeystoreDecryptionError("seed length inconsistent")
|
|
203
|
+
seed = SecretBytes(plaintext[1:1 + seed_len])
|
|
204
|
+
created_at = struct.unpack(">q", plaintext[1 + seed_len:1 + seed_len + 8])[0]
|
|
205
|
+
flags = plaintext[1 + seed_len + 8]
|
|
206
|
+
return UnpackedKeystore(
|
|
207
|
+
seed=seed,
|
|
208
|
+
has_passphrase=bool(flags & 0x01),
|
|
209
|
+
created_at=created_at,
|
|
210
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Exception hierarchy for keystonecrypto."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"KeystoneCryptoError",
|
|
7
|
+
"InvalidMnemonicError",
|
|
8
|
+
"InvalidPassphraseError",
|
|
9
|
+
"InvalidDerivationPathError",
|
|
10
|
+
"KeystoreVersionError",
|
|
11
|
+
"KeystoreDecryptionError",
|
|
12
|
+
"SignatureError",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class KeystoneCryptoError(Exception):
|
|
17
|
+
"""Base exception for all keystonecrypto errors."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InvalidMnemonicError(KeystoneCryptoError):
|
|
21
|
+
"""Mnemonic word, length, or checksum is invalid."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvalidPassphraseError(KeystoneCryptoError):
|
|
25
|
+
"""BIP-39 passphrase is malformed (e.g. invalid UTF-8 after NFKD)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InvalidDerivationPathError(KeystoneCryptoError):
|
|
29
|
+
"""BIP-32 derivation path cannot be parsed or is out of range."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class KeystoreVersionError(KeystoneCryptoError):
|
|
33
|
+
"""Keystore envelope version is not supported by this library version."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class KeystoreDecryptionError(KeystoneCryptoError):
|
|
37
|
+
"""Keystore decryption failed: wrong passphrase, tampered ciphertext, or corrupt header."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SignatureError(KeystoneCryptoError):
|
|
41
|
+
"""Signing operation failed or signature could not be verified."""
|