skeleton_key 0.1.0
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.
- checksums.yaml +7 -0
- data/README.md +542 -0
- data/bin/console +8 -0
- data/bin/lint +10 -0
- data/bin/setup +21 -0
- data/lib/skeleton_key/chains/bitcoin/account.rb +101 -0
- data/lib/skeleton_key/chains/bitcoin/account_derivation.rb +127 -0
- data/lib/skeleton_key/chains/bitcoin/support/outputs.rb +77 -0
- data/lib/skeleton_key/chains/bitcoin/support/paths.rb +34 -0
- data/lib/skeleton_key/chains/bitcoin/support/versioning.rb +87 -0
- data/lib/skeleton_key/chains/bitcoin/support.rb +48 -0
- data/lib/skeleton_key/chains/ethereum/account.rb +191 -0
- data/lib/skeleton_key/chains/ethereum/support.rb +143 -0
- data/lib/skeleton_key/chains/solana/account.rb +117 -0
- data/lib/skeleton_key/chains/solana/support.rb +27 -0
- data/lib/skeleton_key/codecs/base58.rb +64 -0
- data/lib/skeleton_key/codecs/base58_check.rb +42 -0
- data/lib/skeleton_key/codecs/bech32.rb +182 -0
- data/lib/skeleton_key/constants.rb +68 -0
- data/lib/skeleton_key/core/entropy.rb +37 -0
- data/lib/skeleton_key/derivation/bip32.rb +182 -0
- data/lib/skeleton_key/derivation/path.rb +112 -0
- data/lib/skeleton_key/derivation/slip10.rb +89 -0
- data/lib/skeleton_key/errors.rb +158 -0
- data/lib/skeleton_key/keyring.rb +63 -0
- data/lib/skeleton_key/recovery/bip39.rb +212 -0
- data/lib/skeleton_key/recovery/bip39_english.txt +2048 -0
- data/lib/skeleton_key/recovery/slip39.rb +220 -0
- data/lib/skeleton_key/recovery/slip39_support/bit_packing.rb +37 -0
- data/lib/skeleton_key/recovery/slip39_support/checksum.rb +53 -0
- data/lib/skeleton_key/recovery/slip39_support/cipher.rb +81 -0
- data/lib/skeleton_key/recovery/slip39_support/decoder.rb +109 -0
- data/lib/skeleton_key/recovery/slip39_support/encoder.rb +48 -0
- data/lib/skeleton_key/recovery/slip39_support/generated_set.rb +39 -0
- data/lib/skeleton_key/recovery/slip39_support/generator.rb +156 -0
- data/lib/skeleton_key/recovery/slip39_support/interpolation.rb +71 -0
- data/lib/skeleton_key/recovery/slip39_support/protocol.rb +34 -0
- data/lib/skeleton_key/recovery/slip39_support/secret_recovery.rb +74 -0
- data/lib/skeleton_key/recovery/slip39_support/share.rb +50 -0
- data/lib/skeleton_key/recovery/slip39_wordlist.txt +1024 -0
- data/lib/skeleton_key/seed.rb +127 -0
- data/lib/skeleton_key/skeleton_key.code-workspace +11 -0
- data/lib/skeleton_key/utils/encoding.rb +134 -0
- data/lib/skeleton_key/utils/hashing.rb +238 -0
- data/lib/skeleton_key/version.rb +8 -0
- data/lib/skeleton_key.rb +66 -0
- metadata +107 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2f7a0cbc245df26ea9b06a09ea21cc92336a5539853e235b33df9b49e65ab738
|
|
4
|
+
data.tar.gz: 055e8323ee4457a6704d422f110d6a7140b4541e5be1a951591c7d78e74b7134
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6baf89deae1dce76f2552a1133d3d42638e2604783f782c46f83357f49cad785742a859affdfdeaa393f721ffa9502fd413fe53937288d9deaf6a23d39a3f848
|
|
7
|
+
data.tar.gz: cb22f9883391cbc02a3589101ff8830bcd8beed1ea78f83b4094cb9ab002310414e06571da5ada1b5151d0e730708bc3ba888eccc399ad816e0627d6a6c2e080
|
data/README.md
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
# SkeletonKey
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
██████████████
|
|
5
|
+
██████▓░░░░░░░░░▓██████
|
|
6
|
+
███░░░░▓████████████▓░░░░███
|
|
7
|
+
██░░▓██▓░░░██░░░██░░░▓██▓░░██
|
|
8
|
+
██░██░░██░░░██░░░██░░░██░░██░██
|
|
9
|
+
██░██░░██░░░██░░░██░░░██░░██░██
|
|
10
|
+
██░░▓██▓░░░██░░░██░░░▓██▓░░██
|
|
11
|
+
███░░░░▓████████████▓░░░░███
|
|
12
|
+
██████▓░░░░░░░░░▓██████
|
|
13
|
+
████████████████
|
|
14
|
+
██░░██
|
|
15
|
+
██░░██ ╔═╗╦╔═╔═╗╦ ╔═╗╔╦╗╔═╗╔╗╔
|
|
16
|
+
██░░██ ╚═╗╠╩╗║╣ ║ ║╣ ║ ║ ║║║║
|
|
17
|
+
██░░██ ╚═╝╩ ╩╚═╝╩═╝╚═╝ ╩ ╚═╝╝╚╝
|
|
18
|
+
██░░██ ╦╔═╔═╗╦ ╦
|
|
19
|
+
██░░██ ╠╩╗║╣ ╚╦╝
|
|
20
|
+
██░░██ ╩ ╩╚═╝ ╩
|
|
21
|
+
██░░██
|
|
22
|
+
██░░████ Zero-dependency deterministic wallet
|
|
23
|
+
██░░░░██ recovery & key derivation for
|
|
24
|
+
██░░████
|
|
25
|
+
██░░██
|
|
26
|
+
██░░██████
|
|
27
|
+
██░░░░░░██
|
|
28
|
+
██░░██████
|
|
29
|
+
██░░██
|
|
30
|
+
██████
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
[](https://badge.fury.io/rb/skeleton_key )
|
|
34
|
+
[](LICENSE)
|
|
35
|
+
[](https://www.ruby-lang.org )
|
|
36
|
+
[](SECURITY.md)
|
|
37
|
+
|
|
38
|
+
SkeletonKey is a Ruby library for deterministic wallet recovery and key derivation across Bitcoin, Ethereum, and Solana. It is designed around a strict boundary:
|
|
39
|
+
|
|
40
|
+
- recovery formats in the recovery layer
|
|
41
|
+
- shared seed and derivation primitives in the shared layer
|
|
42
|
+
- chain-specific address and serialization behavior in chain modules
|
|
43
|
+
|
|
44
|
+
This repository is safety-critical. A small bug in recovery, derivation, encoding, or serialization can produce valid-looking but wrong keys.
|
|
45
|
+
|
|
46
|
+
## What SkeletonKey Does
|
|
47
|
+
|
|
48
|
+
SkeletonKey takes a root secret and turns it into chain-specific accounts and addresses:
|
|
49
|
+
|
|
50
|
+
- generate a new BIP39 mnemonic
|
|
51
|
+
- recover a seed from a BIP39 mnemonic
|
|
52
|
+
- generate new SLIP-0039 shares
|
|
53
|
+
- recover a master secret from SLIP-0039 shares
|
|
54
|
+
- normalize raw seeds into a canonical `SkeletonKey::Seed`
|
|
55
|
+
- derive Bitcoin, Ethereum, and Solana accounts from one seed
|
|
56
|
+
- validate behavior against large golden-master fixture sets
|
|
57
|
+
|
|
58
|
+
Current supported standards and conventions:
|
|
59
|
+
|
|
60
|
+
- BIP39 recovery
|
|
61
|
+
- SLIP-0039 recovery
|
|
62
|
+
- BIP32 secp256k1 derivation
|
|
63
|
+
- SLIP-0010 Ed25519 derivation
|
|
64
|
+
- Bitcoin BIP32, BIP44, BIP49, BIP84, BIP141
|
|
65
|
+
- Ethereum BIP32 and BIP44
|
|
66
|
+
- Solana hardened BIP44-style paths
|
|
67
|
+
|
|
68
|
+
## Installation
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
bin/setup
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Or, if the environment is already prepared:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
bundle install
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Developer Quick Start
|
|
81
|
+
|
|
82
|
+
Open a console with the library loaded:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
bin/console
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Generate a random keyring:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
keyring = SkeletonKey::Keyring.new
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Initialize from an existing hex seed:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
keyring = SkeletonKey::Keyring.new(
|
|
98
|
+
seed: "13e3e43b779fc6cda3bd9a1e762768dd3e273389adb81787adbe880341609e88"
|
|
99
|
+
)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Derive default chain accounts:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
bitcoin = keyring.bitcoin
|
|
106
|
+
ethereum = keyring.ethereum
|
|
107
|
+
solana = keyring.solana
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Recovery Experience
|
|
111
|
+
|
|
112
|
+
### BIP39
|
|
113
|
+
|
|
114
|
+
Generate a new mnemonic:
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
mnemonic = SkeletonKey::Recovery::Bip39.generate(word_count: 24)
|
|
118
|
+
|
|
119
|
+
mnemonic.phrase
|
|
120
|
+
mnemonic.words
|
|
121
|
+
mnemonic.seed
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Generate deterministically from explicit entropy:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
mnemonic = SkeletonKey::Recovery::Bip39.generate(
|
|
128
|
+
word_count: 12,
|
|
129
|
+
entropy: ("\x00".b * 16)
|
|
130
|
+
)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Or convert entropy directly:
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
mnemonic = SkeletonKey::Recovery::Bip39.from_entropy("00000000000000000000000000000000")
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Use [`SkeletonKey::Recovery::Bip39`](/home/sebscholl/Code/skeleton-key/lib/skeleton_key/recovery/bip39.rb) when you want explicit mnemonic validation and seed recovery:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
bip39 = SkeletonKey::Recovery::Bip39.new(
|
|
143
|
+
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
seed = bip39.seed
|
|
147
|
+
keyring = SkeletonKey::Keyring.new(seed: seed)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
You can also pass a mnemonic directly to `Seed.import` or `Keyring.new`:
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
seed = SkeletonKey::Seed.import(
|
|
154
|
+
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
keyring = SkeletonKey::Keyring.new(
|
|
158
|
+
seed: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
159
|
+
)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
What BIP39 validation currently enforces:
|
|
163
|
+
|
|
164
|
+
- official BIP39 word counts only: `12`, `15`, `18`, `21`, `24`
|
|
165
|
+
- English wordlist membership
|
|
166
|
+
- checksum validation
|
|
167
|
+
- PBKDF2 seed reconstruction
|
|
168
|
+
|
|
169
|
+
### SLIP-0039
|
|
170
|
+
|
|
171
|
+
Generate a single-group SLIP-0039 share set:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
share_set = SkeletonKey::Recovery::Slip39.generate(
|
|
175
|
+
master_secret: "00112233445566778899aabbccddeeff",
|
|
176
|
+
member_threshold: 3,
|
|
177
|
+
member_count: 5,
|
|
178
|
+
passphrase: "",
|
|
179
|
+
extendable: true,
|
|
180
|
+
iteration_exponent: 1
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
share_set.mnemonic_groups
|
|
184
|
+
share_set.all_shares
|
|
185
|
+
share_set.recovery_set
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Generate a multi-group share set:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
share_set = SkeletonKey::Recovery::Slip39.generate(
|
|
192
|
+
master_secret: "00112233445566778899aabbccddeeff",
|
|
193
|
+
group_threshold: 2,
|
|
194
|
+
groups: [
|
|
195
|
+
{ member_threshold: 2, member_count: 3 },
|
|
196
|
+
{ member_threshold: 3, member_count: 5 },
|
|
197
|
+
{ member_threshold: 2, member_count: 4 }
|
|
198
|
+
],
|
|
199
|
+
passphrase: "PASS8",
|
|
200
|
+
extendable: false,
|
|
201
|
+
iteration_exponent: 2
|
|
202
|
+
)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`master_secret` may be raw bytes, hex, octets, or a `SkeletonKey::Seed`, but SLIP-0039 generation only accepts master-secret lengths of `16`, `24`, or `32` bytes.
|
|
206
|
+
|
|
207
|
+
Use [`SkeletonKey::Recovery::Slip39`](/home/sebscholl/Code/skeleton-key/lib/skeleton_key/recovery/slip39.rb) when recovering from Shamir shares:
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
shares = [
|
|
211
|
+
"share one ...",
|
|
212
|
+
"share two ...",
|
|
213
|
+
"share three ..."
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
seed = SkeletonKey::Recovery::Slip39.recover(shares, passphrase: "")
|
|
217
|
+
keyring = SkeletonKey::Keyring.new(seed: seed)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Important DX rule: pass a **flat array of share strings**. Do not group them yourself. Group membership is inferred from the metadata encoded inside each share.
|
|
221
|
+
|
|
222
|
+
Multi-group recovery uses the same interface:
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
shares = [
|
|
226
|
+
group_0_share_0,
|
|
227
|
+
group_0_share_1,
|
|
228
|
+
group_2_share_0,
|
|
229
|
+
group_2_share_1
|
|
230
|
+
]
|
|
231
|
+
|
|
232
|
+
seed = SkeletonKey::Recovery::Slip39.recover(shares, passphrase: "PASS8")
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Important safety note: `Slip39.recover` validates the share set, but a wrong passphrase is not guaranteed to raise. It can yield a different valid-length secret. If passphrase correctness matters operationally, verify the recovered seed against a known address, fingerprint, or other expected identifier.
|
|
236
|
+
|
|
237
|
+
## Ruby Secret Handling
|
|
238
|
+
|
|
239
|
+
SkeletonKey is intentionally a sharp library: the point is to recover and export key material so the caller can hand it to signing, wallet, or custody code. That is useful, but it carries Ruby-specific constraints.
|
|
240
|
+
|
|
241
|
+
Ruby does not provide hard guarantees for secure memory erasure:
|
|
242
|
+
|
|
243
|
+
- strings are garbage-collected
|
|
244
|
+
- sensitive values may be copied during encoding, packing, or concatenation
|
|
245
|
+
- intermediate buffers may exist outside the object you hold
|
|
246
|
+
|
|
247
|
+
SkeletonKey therefore does not claim guaranteed zeroization. The correct posture is best-effort operational hygiene:
|
|
248
|
+
|
|
249
|
+
- keep mnemonics, seeds, private keys, and WIFs in scope for as little time as possible
|
|
250
|
+
- avoid logging, inspecting, or serializing secret-bearing objects in development tools
|
|
251
|
+
- prefer process boundaries and short-lived workers for sensitive workflows
|
|
252
|
+
- hand recovered keys directly to downstream signing code instead of caching them in application state
|
|
253
|
+
- verify recovered BIP39 or SLIP-0039 material against known addresses or fingerprints before using it operationally
|
|
254
|
+
|
|
255
|
+
If your threat model requires hard memory guarantees, Ruby is the wrong layer to trust with long-lived secret custody.
|
|
256
|
+
|
|
257
|
+
## Keyring Experience
|
|
258
|
+
|
|
259
|
+
[`SkeletonKey::Keyring`](/home/sebscholl/Code/skeleton-key/lib/skeleton_key/keyring.rb) is the main developer entry point. It accepts normalized seed material and exposes chain-specific account builders.
|
|
260
|
+
|
|
261
|
+
```ruby
|
|
262
|
+
keyring = SkeletonKey::Keyring.new(seed: seed)
|
|
263
|
+
|
|
264
|
+
btc = keyring.bitcoin(purpose: 84, coin_type: 0, account_index: 0, network: :mainnet)
|
|
265
|
+
eth = keyring.ethereum(purpose: 44, coin_type: 60, account_index: 0)
|
|
266
|
+
sol = keyring.solana(account_index: 0)
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Supported seed input shapes:
|
|
270
|
+
|
|
271
|
+
- `nil` for a new random seed
|
|
272
|
+
- `SkeletonKey::Seed`
|
|
273
|
+
- raw bytes
|
|
274
|
+
- hex seed string
|
|
275
|
+
- array of octets
|
|
276
|
+
- BIP39 mnemonic string
|
|
277
|
+
|
|
278
|
+
### End-to-End Example
|
|
279
|
+
|
|
280
|
+
This is the simplest full-circle flow for manual testing in `bin/console`:
|
|
281
|
+
|
|
282
|
+
```ruby
|
|
283
|
+
mnemonic = SkeletonKey::Recovery::Bip39.generate(word_count: 12)
|
|
284
|
+
|
|
285
|
+
puts mnemonic.phrase
|
|
286
|
+
puts mnemonic.seed.hex
|
|
287
|
+
|
|
288
|
+
keyring = SkeletonKey::Keyring.new(seed: mnemonic.seed)
|
|
289
|
+
|
|
290
|
+
bitcoin = keyring.bitcoin(purpose: 84, coin_type: 0, account_index: 0, network: :mainnet)
|
|
291
|
+
ethereum = keyring.ethereum(purpose: 44, coin_type: 60, account_index: 0)
|
|
292
|
+
solana = keyring.solana(account_index: 0)
|
|
293
|
+
|
|
294
|
+
btc_node = bitcoin.address(change: 0, index: 0)
|
|
295
|
+
eth_node = ethereum.address(change: 0, index: 0)
|
|
296
|
+
sol_node = solana.address(change: 0)
|
|
297
|
+
|
|
298
|
+
puts btc_node[:path]
|
|
299
|
+
puts btc_node[:address]
|
|
300
|
+
puts btc_node[:wif]
|
|
301
|
+
|
|
302
|
+
puts eth_node[:path]
|
|
303
|
+
puts eth_node[:address]
|
|
304
|
+
puts eth_node[:private_key]
|
|
305
|
+
|
|
306
|
+
puts sol_node[:path]
|
|
307
|
+
puts sol_node[:address]
|
|
308
|
+
puts sol_node[:private_key]
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
If you want to verify that the mnemonic alone is sufficient, reconstruct the same keyring from the phrase:
|
|
312
|
+
|
|
313
|
+
```ruby
|
|
314
|
+
phrase = mnemonic.phrase
|
|
315
|
+
|
|
316
|
+
recovered = SkeletonKey::Keyring.new(seed: phrase)
|
|
317
|
+
recovered_btc = recovered.bitcoin(purpose: 84, coin_type: 0, account_index: 0, network: :mainnet)
|
|
318
|
+
recovered_eth = recovered.ethereum(purpose: 44, coin_type: 60, account_index: 0)
|
|
319
|
+
recovered_sol = recovered.solana(account_index: 0)
|
|
320
|
+
|
|
321
|
+
expect_btc = recovered_btc.address(change: 0, index: 0)
|
|
322
|
+
expect_eth = recovered_eth.address(change: 0, index: 0)
|
|
323
|
+
expect_sol = recovered_sol.address(change: 0)
|
|
324
|
+
|
|
325
|
+
puts expect_btc[:address] == btc_node[:address]
|
|
326
|
+
puts expect_eth[:address] == eth_node[:address]
|
|
327
|
+
puts expect_sol[:address] == sol_node[:address]
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
All three comparisons should print `true`.
|
|
331
|
+
|
|
332
|
+
## Bitcoin Experience
|
|
333
|
+
|
|
334
|
+
Bitcoin accounts are created through [`SkeletonKey::Chains::Bitcoin::Account`](/home/sebscholl/Code/skeleton-key/lib/skeleton_key/chains/bitcoin/account.rb).
|
|
335
|
+
|
|
336
|
+
Examples:
|
|
337
|
+
|
|
338
|
+
```ruby
|
|
339
|
+
account = keyring.bitcoin(purpose: 84, coin_type: 0, account_index: 0, network: :mainnet)
|
|
340
|
+
|
|
341
|
+
account.xprv
|
|
342
|
+
account.xpub
|
|
343
|
+
account.path
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Derive an address:
|
|
347
|
+
|
|
348
|
+
```ruby
|
|
349
|
+
node = account.address(change: 0, index: 0)
|
|
350
|
+
|
|
351
|
+
node[:path]
|
|
352
|
+
node[:address]
|
|
353
|
+
node[:wif]
|
|
354
|
+
node[:privkey]
|
|
355
|
+
node[:pubkey]
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
Derive a branch extended keypair:
|
|
359
|
+
|
|
360
|
+
```ruby
|
|
361
|
+
branch = account.branch_extended_keys(change: 0)
|
|
362
|
+
|
|
363
|
+
branch[:path]
|
|
364
|
+
branch[:xprv]
|
|
365
|
+
branch[:xpub]
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Supported Bitcoin purposes:
|
|
369
|
+
|
|
370
|
+
- `32`: legacy root-branch BIP32 vectors
|
|
371
|
+
- `44`: BIP44 P2PKH
|
|
372
|
+
- `49`: BIP49 wrapped SegWit
|
|
373
|
+
- `84`: BIP84 native SegWit
|
|
374
|
+
- `141`: native SegWit root-branch vectors
|
|
375
|
+
|
|
376
|
+
## Ethereum Experience
|
|
377
|
+
|
|
378
|
+
Ethereum accounts are created through [`SkeletonKey::Chains::Ethereum::Account`](/home/sebscholl/Code/skeleton-key/lib/skeleton_key/chains/ethereum/account.rb).
|
|
379
|
+
|
|
380
|
+
```ruby
|
|
381
|
+
account = keyring.ethereum(purpose: 44, coin_type: 60, account_index: 0)
|
|
382
|
+
|
|
383
|
+
account.path
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
Derive an address:
|
|
387
|
+
|
|
388
|
+
```ruby
|
|
389
|
+
node = account.address(change: 0, index: 0)
|
|
390
|
+
|
|
391
|
+
node[:path]
|
|
392
|
+
node[:private_key] # hex, no 0x
|
|
393
|
+
node[:public_key] # 64-byte uncompressed payload, hex
|
|
394
|
+
node[:compressed_public_key]
|
|
395
|
+
node[:address] # EIP-55 checksummed 0x...
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Derive branch extended keys:
|
|
399
|
+
|
|
400
|
+
```ruby
|
|
401
|
+
branch = account.branch_extended_keys(change: 0)
|
|
402
|
+
|
|
403
|
+
branch[:path]
|
|
404
|
+
branch[:xprv]
|
|
405
|
+
branch[:xpub]
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Supported Ethereum purposes:
|
|
409
|
+
|
|
410
|
+
- `32`: legacy BIP32 root mode
|
|
411
|
+
- `44`: BIP44 `m/44'/60'/account'/change/index`
|
|
412
|
+
|
|
413
|
+
## Solana Experience
|
|
414
|
+
|
|
415
|
+
Solana accounts are created through [`SkeletonKey::Chains::Solana::Account`](/home/sebscholl/Code/skeleton-key/lib/skeleton_key/chains/solana/account.rb).
|
|
416
|
+
|
|
417
|
+
```ruby
|
|
418
|
+
account = keyring.solana(account_index: 0)
|
|
419
|
+
account.path
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Derive a wallet-style Solana address:
|
|
423
|
+
|
|
424
|
+
```ruby
|
|
425
|
+
node = account.address(change: 0)
|
|
426
|
+
|
|
427
|
+
node[:path]
|
|
428
|
+
node[:private_key] # 32-byte private seed, hex
|
|
429
|
+
node[:public_key] # 32-byte Ed25519 public key, hex
|
|
430
|
+
node[:address] # Base58-encoded Solana address
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
Derive deeper hardened children:
|
|
434
|
+
|
|
435
|
+
```ruby
|
|
436
|
+
node = account.address(change: 0, index: 15)
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
Solana in SkeletonKey is hardened-only. There is no supported unhardened child derivation path.
|
|
440
|
+
|
|
441
|
+
## Architecture
|
|
442
|
+
|
|
443
|
+
The architectural reference lives in [ARCHITECTURE.md](ARCHITECTURE.md). In short:
|
|
444
|
+
|
|
445
|
+
- recovery layer:
|
|
446
|
+
- BIP39
|
|
447
|
+
- SLIP-0039
|
|
448
|
+
- shared layer:
|
|
449
|
+
- seed normalization
|
|
450
|
+
- entropy
|
|
451
|
+
- BIP32
|
|
452
|
+
- SLIP-0010
|
|
453
|
+
- generic extended-key serialization
|
|
454
|
+
- Bitcoin layer:
|
|
455
|
+
- WIF
|
|
456
|
+
- Base58Check
|
|
457
|
+
- Bech32
|
|
458
|
+
- script/address semantics
|
|
459
|
+
- Ethereum layer:
|
|
460
|
+
- path conventions
|
|
461
|
+
- Keccak address derivation
|
|
462
|
+
- EIP-55 checksums
|
|
463
|
+
- Solana layer:
|
|
464
|
+
- hardened path conventions
|
|
465
|
+
- Ed25519 key generation
|
|
466
|
+
- Base58 address encoding
|
|
467
|
+
|
|
468
|
+
Rules that matter:
|
|
469
|
+
|
|
470
|
+
- Bitcoin address logic must not leak into shared derivation code.
|
|
471
|
+
- Ethereum must not inherit Bitcoin encodings or Bitcoin-style field naming.
|
|
472
|
+
- Solana must not inherit secp256k1 assumptions or Ethereum hashing rules.
|
|
473
|
+
|
|
474
|
+
## Testing and Validation
|
|
475
|
+
|
|
476
|
+
Run the full suite:
|
|
477
|
+
|
|
478
|
+
```bash
|
|
479
|
+
bundle exec rspec
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
Run a focused file:
|
|
483
|
+
|
|
484
|
+
```bash
|
|
485
|
+
bundle exec rspec spec/lib/skeleton_key/recovery/slip39_spec.rb
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
Run all integration vectors:
|
|
489
|
+
|
|
490
|
+
```bash
|
|
491
|
+
bundle exec rspec spec/integration/vectors
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
Fixture layout:
|
|
495
|
+
|
|
496
|
+
- `spec/fixtures/recovery/`: BIP39 and SLIP-0039 recovery goldens
|
|
497
|
+
- `spec/fixtures/vectors/bitcoin/`: Bitcoin derivation vectors
|
|
498
|
+
- `spec/fixtures/vectors/ethereum/`: Ethereum derivation vectors
|
|
499
|
+
- `spec/fixtures/vectors/solana/`: Solana derivation vectors
|
|
500
|
+
- `spec/fixtures/codecs/`: Base58/Base58Check/Bech32 codec goldens
|
|
501
|
+
|
|
502
|
+
The preferred validation model in this repository is external golden-master comparison against established tools and independently generated corpora.
|
|
503
|
+
|
|
504
|
+
## Fixture Policy
|
|
505
|
+
|
|
506
|
+
Golden-master fixtures in this repository are frozen validation artifacts, not routine developer outputs.
|
|
507
|
+
|
|
508
|
+
- do not casually regenerate fixtures as part of ordinary feature work
|
|
509
|
+
- treat fixture diffs as safety-critical review items
|
|
510
|
+
- when a fixture must change, document the external source and validation reason in the commit or PR
|
|
511
|
+
- prefer adding new coverage over rewriting existing canonical corpora
|
|
512
|
+
|
|
513
|
+
## Repository Layout
|
|
514
|
+
|
|
515
|
+
Key directories:
|
|
516
|
+
|
|
517
|
+
- `lib/skeleton_key/recovery/`: BIP39 and SLIP-0039 recovery
|
|
518
|
+
- `lib/skeleton_key/derivation/`: BIP32, SLIP-0010, derivation paths
|
|
519
|
+
- `lib/skeleton_key/chains/bitcoin/`: Bitcoin account and support logic
|
|
520
|
+
- `lib/skeleton_key/chains/ethereum/`: Ethereum account and support logic
|
|
521
|
+
- `lib/skeleton_key/chains/solana/`: Solana account and support logic
|
|
522
|
+
- `lib/skeleton_key/codecs/`: local Base58, Base58Check, Bech32 codecs
|
|
523
|
+
- `spec/lib/`: unit specs
|
|
524
|
+
- `spec/integration/`: vector compliance specs
|
|
525
|
+
- `spec/support/`: shared spec helpers
|
|
526
|
+
|
|
527
|
+
## Contributing
|
|
528
|
+
|
|
529
|
+
Read these first:
|
|
530
|
+
|
|
531
|
+
- [AGENTS.md](AGENTS.md)
|
|
532
|
+
- [ARCHITECTURE.md](ARCHITECTURE.md)
|
|
533
|
+
- [DOCUMENTATION_STYLE.md](DOCUMENTATION_STYLE.md)
|
|
534
|
+
|
|
535
|
+
Before submitting changes:
|
|
536
|
+
|
|
537
|
+
1. keep the architecture boundary intact
|
|
538
|
+
2. add or update golden fixtures when behavior changes
|
|
539
|
+
3. add unit and integration coverage
|
|
540
|
+
4. run `bundle exec rspec`
|
|
541
|
+
|
|
542
|
+
If you change recovery, derivation, encoding, key serialization, or address construction, external vector proof is required.
|
data/bin/console
ADDED
data/bin/lint
ADDED
data/bin/setup
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
# Ensure we're at the gem's root
|
|
7
|
+
APP_ROOT = File.expand_path("..", __dir__)
|
|
8
|
+
Dir.chdir(APP_ROOT)
|
|
9
|
+
|
|
10
|
+
# 1. Install dependencies
|
|
11
|
+
system("bundle install") || abort("❌ Bundle install failed")
|
|
12
|
+
|
|
13
|
+
# 2. Set up local development environment
|
|
14
|
+
FileUtils.mkdir_p("tmp")
|
|
15
|
+
|
|
16
|
+
# 3. Optionally run binstubs install
|
|
17
|
+
system("bundle binstubs bundler --force")
|
|
18
|
+
|
|
19
|
+
puts "✅ Setup complete! You can now run:"
|
|
20
|
+
puts " bin/console # start a console with SkeletonKey loaded"
|
|
21
|
+
puts " rake # run the default rake task (tests/lint)"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkeletonKey
|
|
4
|
+
module Chains
|
|
5
|
+
module Bitcoin
|
|
6
|
+
# Represents a Bitcoin HD account node derived via BIP32/BIP84.
|
|
7
|
+
#
|
|
8
|
+
# Handles derivation of xprv/xpub for a given account path.
|
|
9
|
+
#
|
|
10
|
+
# @example Default mainnet account
|
|
11
|
+
# acct = SkeletonKey::Chains::Bitcoin::Account.new(seed: seed.bytes)
|
|
12
|
+
# acct.xprv # => "xprv..."
|
|
13
|
+
# acct.xpub # => "xpub..."
|
|
14
|
+
class Account
|
|
15
|
+
include Support
|
|
16
|
+
include AccountDerivation
|
|
17
|
+
include Derivation::BIP32
|
|
18
|
+
|
|
19
|
+
attr_reader :purpose, :coin_type, :account_index, :network, :derived
|
|
20
|
+
|
|
21
|
+
LEGACY_BIP32_PURPOSE = 32
|
|
22
|
+
LEGACY_BIP141_PURPOSE = 141
|
|
23
|
+
DEFAULT_PURPOSE = 84
|
|
24
|
+
MAINNET_COIN = 0
|
|
25
|
+
TESTNET_COIN = 1
|
|
26
|
+
|
|
27
|
+
# @param seed [String] raw seed bytes
|
|
28
|
+
# @param purpose [Integer] derivation purpose (44, 49, 84)
|
|
29
|
+
# @param coin_type [Integer] 0 = mainnet, 1 = testnet
|
|
30
|
+
# @param account_index [Integer] account number (hardened)
|
|
31
|
+
# @param network [:mainnet, :testnet]
|
|
32
|
+
def initialize(seed:, purpose: DEFAULT_PURPOSE, coin_type: MAINNET_COIN, account_index: 0, network: :mainnet)
|
|
33
|
+
@purpose = purpose
|
|
34
|
+
@coin_type = coin_type
|
|
35
|
+
@account_index = account_index
|
|
36
|
+
@network = network
|
|
37
|
+
|
|
38
|
+
@derived = derive_from_seed(
|
|
39
|
+
seed,
|
|
40
|
+
purpose: purpose,
|
|
41
|
+
coin_type: coin_type,
|
|
42
|
+
account: account_index
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [String] BIP32 extended private key (xprv, zprv if re-encoded)
|
|
47
|
+
def xprv
|
|
48
|
+
@derived[:xprv]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [String] BIP32 extended public key (xpub, zpub if re-encoded)
|
|
52
|
+
def xpub
|
|
53
|
+
@derived[:xpub]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [String] BIP32 path
|
|
57
|
+
def path
|
|
58
|
+
return "m" if legacy_root_branch?
|
|
59
|
+
|
|
60
|
+
"m/#{purpose}'/#{coin_type}'/#{account_index}'"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Derive a child address from this account.
|
|
64
|
+
#
|
|
65
|
+
# @param change [Integer] 0 = external, 1 = internal/change
|
|
66
|
+
# @param index [Integer] address index
|
|
67
|
+
# @return [Hash] derived address details (privkey, pubkey, wif, bech32, etc.)
|
|
68
|
+
def address(change: 0, index: 0, hardened_change: false, hardened_index: false)
|
|
69
|
+
derive_address_from_account(
|
|
70
|
+
change: change,
|
|
71
|
+
index: index,
|
|
72
|
+
hardened_change: hardened_change,
|
|
73
|
+
hardened_index: hardened_index
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def branch_extended_keys(change: 0, hardened_change: false)
|
|
78
|
+
derive_branch_extended_keys(change: change, hardened_change: hardened_change)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def legacy_bip32?
|
|
84
|
+
purpose == LEGACY_BIP32_PURPOSE
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def legacy_bip141?
|
|
88
|
+
purpose == LEGACY_BIP141_PURPOSE
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def legacy_root_branch?
|
|
92
|
+
legacy_root_branch_purpose?(purpose)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def legacy_root_branch_purpose?(value)
|
|
96
|
+
[LEGACY_BIP32_PURPOSE, LEGACY_BIP141_PURPOSE].include?(value)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|