SkeletonKey
██████████████
██████▓░░░░░░░░░▓██████
███░░░░▓████████████▓░░░░███
██░░▓██▓░░░██░░░██░░░▓██▓░░██
██░██░░██░░░██░░░██░░░██░░██░██
██░██░░██░░░██░░░██░░░██░░██░██
██░░▓██▓░░░██░░░██░░░▓██▓░░██
███░░░░▓████████████▓░░░░███
██████▓░░░░░░░░░▓██████
████████████████
██░░██
██░░██ ╔═╗╦╔═╔═╗╦ ╔═╗╔╦╗╔═╗╔╗╔
██░░██ ╚═╗╠╩╗║╣ ║ ║╣ ║ ║ ║║║║
██░░██ ╚═╝╩ ╩╚═╝╩═╝╚═╝ ╩ ╚═╝╝╚╝
██░░██ ╦╔═╔═╗╦ ╦
██░░██ ╠╩╗║╣ ╚╦╝
██░░██ ╩ ╩╚═╝ ╩
██░░██
██░░████ Zero-dependency deterministic wallet
██░░░░██ recovery & key derivation for
██░░████
██░░██
██░░██████
██░░░░░░██
██░░██████
██░░██
██████
SkeletonKey is a zero-dependency Ruby library for deterministic wallet recovery and key derivation across Bitcoin, Ethereum, and Solana. It is designed around a strict boundary:
- recovery formats in the recovery layer
- shared seed and derivation primitives in the shared layer
- chain-specific address and serialization behavior in chain modules
This repository is safety-critical. A small bug in recovery, derivation, encoding, or serialization can produce valid-looking but wrong keys.
What SkeletonKey Does
SkeletonKey takes a root secret and turns it into chain-specific accounts and addresses:
- generate a new BIP39 mnemonic
- recover a seed from a BIP39 mnemonic
- generate new SLIP-0039 shares
- recover a master secret from SLIP-0039 shares
- normalize raw seeds into a canonical
SkeletonKey::Seed - derive Bitcoin, Ethereum, and Solana accounts from one seed
- validate behavior against large golden-master fixture sets
Current supported standards and conventions:
- BIP39 recovery
- SLIP-0039 recovery
- BIP32 secp256k1 derivation
- SLIP-0010 Ed25519 derivation
- Bitcoin BIP32, BIP44, BIP49, BIP84, BIP141
- Ethereum BIP32 and BIP44
- Solana hardened BIP44-style paths
Installation
bin/setupOr, if the environment is already prepared:
bundle installDeveloper Quick Start
Open a console with the library loaded:
bin/consoleGenerate a random keyring:
keyring = SkeletonKey::Keyring.newInitialize from a mnemonic with a BIP39 passphrase:
keyring = SkeletonKey::Keyring.new(
seed: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
passphrase: "TREZOR"
)Initialize from an existing hex seed:
keyring = SkeletonKey::Keyring.new(
seed: "13e3e43b779fc6cda3bd9a1e762768dd3e273389adb81787adbe880341609e88"
)Derive default chain accounts:
bitcoin = keyring.bitcoin
ethereum = keyring.ethereum
solana = keyring.solanaRecovery Experience
BIP39
Generate a new mnemonic:
mnemonic = SkeletonKey::Recovery::Bip39.generate(word_count: 24)
mnemonic.phrase
mnemonic.words
mnemonic.seedGenerate deterministically from explicit entropy:
mnemonic = SkeletonKey::Recovery::Bip39.generate(
word_count: 12,
entropy: ("\x00".b * 16)
)Or convert entropy directly:
mnemonic = SkeletonKey::Recovery::Bip39.from_entropy("00000000000000000000000000000000")Use SkeletonKey::Recovery::Bip39 when you want explicit mnemonic validation and seed recovery:
bip39 = SkeletonKey::Recovery::Bip39.new(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
)
seed = bip39.seed
keyring = SkeletonKey::Keyring.new(seed: seed)You can also pass a mnemonic directly to Seed.import or Keyring.new:
seed = SkeletonKey::Seed.import(
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
passphrase: "TREZOR"
)
keyring = SkeletonKey::Keyring.new(
seed: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
passphrase: "TREZOR"
)The BIP39 passphrase is separate from the mnemonic. It is not an extra mnemonic word.
What BIP39 validation currently enforces:
- official BIP39 word counts only:
12,15,18,21,24 - English wordlist membership
- checksum validation
- PBKDF2 seed reconstruction
SLIP-0039
Generate a single-group SLIP-0039 share set:
share_set = SkeletonKey::Recovery::Slip39.generate(
master_secret: "00112233445566778899aabbccddeeff",
member_threshold: 3,
member_count: 5,
passphrase: "",
extendable: true,
iteration_exponent: 1
)
share_set.mnemonic_groups
share_set.all_shares
share_set.recovery_setGenerate a multi-group share set:
share_set = SkeletonKey::Recovery::Slip39.generate(
master_secret: "00112233445566778899aabbccddeeff",
group_threshold: 2,
groups: [
{ member_threshold: 2, member_count: 3 },
{ member_threshold: 3, member_count: 5 },
{ member_threshold: 2, member_count: 4 }
],
passphrase: "PASS8",
extendable: false,
iteration_exponent: 2
)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.
Use SkeletonKey::Recovery::Slip39 when recovering from Shamir shares:
shares = [
"share one ...",
"share two ...",
"share three ..."
]
seed = SkeletonKey::Recovery::Slip39.recover(shares, passphrase: "")
keyring = SkeletonKey::Keyring.new(seed: seed)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.
Multi-group recovery uses the same interface:
shares = [
group_0_share_0,
group_0_share_1,
group_2_share_0,
group_2_share_1
]
seed = SkeletonKey::Recovery::Slip39.recover(shares, passphrase: "PASS8")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.
Ruby Secret Handling
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.
Ruby does not provide hard guarantees for secure memory erasure:
- strings are garbage-collected
- sensitive values may be copied during encoding, packing, or concatenation
- intermediate buffers may exist outside the object you hold
SkeletonKey therefore does not claim guaranteed zeroization. The correct posture is best-effort operational hygiene:
- keep mnemonics, seeds, private keys, and WIFs in scope for as little time as possible
- avoid logging, inspecting, or serializing secret-bearing objects in development tools
- prefer process boundaries and short-lived workers for sensitive workflows
- hand recovered keys directly to downstream signing code instead of caching them in application state
- verify recovered BIP39 or SLIP-0039 material against known addresses or fingerprints before using it operationally
If your threat model requires hard memory guarantees, Ruby is the wrong layer to trust with long-lived secret custody.
Keyring Experience
SkeletonKey::Keyring is the main developer entry point. It accepts normalized seed material and exposes chain-specific account builders.
keyring = SkeletonKey::Keyring.new(seed: seed)
btc = keyring.bitcoin(purpose: 84, coin_type: 0, account_index: 0, network: :mainnet)
eth = keyring.ethereum(purpose: 44, coin_type: 60, account_index: 0)
sol = keyring.solana(account_index: 0)Supported seed input shapes:
-
nilfor a new random seed SkeletonKey::Seed- raw bytes
- hex seed string
- array of octets
- BIP39 mnemonic string
End-to-End Example
This is the simplest full-circle flow for manual testing in bin/console:
mnemonic = SkeletonKey::Recovery::Bip39.generate(word_count: 12)
puts mnemonic.phrase
puts mnemonic.seed.hex
keyring = SkeletonKey::Keyring.new(seed: mnemonic.seed)
bitcoin = keyring.bitcoin(purpose: 84, coin_type: 0, account_index: 0, network: :mainnet)
ethereum = keyring.ethereum(purpose: 44, coin_type: 60, account_index: 0)
solana = keyring.solana(account_index: 0)
btc_node = bitcoin.address(change: 0, index: 0)
eth_node = ethereum.address(change: 0, index: 0)
sol_node = solana.address(change: 0)
puts btc_node[:path]
puts btc_node[:address]
puts btc_node[:wif]
puts eth_node[:path]
puts eth_node[:address]
puts eth_node[:private_key]
puts sol_node[:path]
puts sol_node[:address]
puts sol_node[:private_key]If you want to verify that the mnemonic alone is sufficient, reconstruct the same keyring from the phrase:
phrase = mnemonic.phrase
recovered = SkeletonKey::Keyring.new(seed: phrase)
recovered_btc = recovered.bitcoin(purpose: 84, coin_type: 0, account_index: 0, network: :mainnet)
recovered_eth = recovered.ethereum(purpose: 44, coin_type: 60, account_index: 0)
recovered_sol = recovered.solana(account_index: 0)
expect_btc = recovered_btc.address(change: 0, index: 0)
expect_eth = recovered_eth.address(change: 0, index: 0)
expect_sol = recovered_sol.address(change: 0)
puts expect_btc[:address] == btc_node[:address]
puts expect_eth[:address] == eth_node[:address]
puts expect_sol[:address] == sol_node[:address]All three comparisons should print true.
Bitcoin Experience
Bitcoin accounts are created through SkeletonKey::Chains::Bitcoin::Account.
Examples:
account = keyring.bitcoin(purpose: 84, coin_type: 0, account_index: 0, network: :mainnet)
account.xprv
account.xpub
account.pathDerive an address:
node = account.address(change: 0, index: 0)
node[:path]
node[:address]
node[:wif]
node[:privkey]
node[:pubkey]Derive a branch extended keypair:
branch = account.branch_extended_keys(change: 0)
branch[:path]
branch[:xprv]
branch[:xpub]Supported Bitcoin purposes:
-
32: legacy root-branch BIP32 vectors -
44: BIP44 P2PKH -
49: BIP49 wrapped SegWit -
84: BIP84 native SegWit -
141: native SegWit root-branch vectors
Ethereum Experience
Ethereum accounts are created through SkeletonKey::Chains::Ethereum::Account.
account = keyring.ethereum(purpose: 44, coin_type: 60, account_index: 0)
account.pathDerive an address:
node = account.address(change: 0, index: 0)
node[:path]
node[:private_key] # hex, no 0x
node[:public_key] # 64-byte uncompressed payload, hex
node[:compressed_public_key]
node[:address] # EIP-55 checksummed 0x...Derive branch extended keys:
branch = account.branch_extended_keys(change: 0)
branch[:path]
branch[:xprv]
branch[:xpub]Supported Ethereum purposes:
-
32: legacy BIP32 root mode -
44: BIP44m/44'/60'/account'/change/index
Solana Experience
Solana accounts are created through SkeletonKey::Chains::Solana::Account.
account = keyring.solana(account_index: 0)
account.pathDerive a wallet-style Solana address:
node = account.address(change: 0)
node[:path]
node[:private_key] # 32-byte private seed, hex
node[:public_key] # 32-byte Ed25519 public key, hex
node[:address] # Base58-encoded Solana addressDerive deeper hardened children:
node = account.address(change: 0, index: 15)Match the default no-path behavior of solana-keygen new:
account = keyring.solana(derivation_path: nil)
node = account.address
node[:path] # nil
node[:private_key] # first 32 bytes of the canonical seed, hex
node[:public_key] # 32-byte Ed25519 public key, hex
node[:address] # Base58-encoded Solana addressSolana in SkeletonKey is hardened-only. There is no supported unhardened child derivation path.
Architecture
The architectural reference lives in ARCHITECTURE.md. In short:
- recovery layer:
- BIP39
- SLIP-0039
- shared layer:
- seed normalization
- entropy
- BIP32
- SLIP-0010
- generic extended-key serialization
- Bitcoin layer:
- WIF
- Base58Check
- Bech32
- script/address semantics
- Ethereum layer:
- path conventions
- Keccak address derivation
- EIP-55 checksums
- Solana layer:
- hardened path conventions
- Ed25519 key generation
- Base58 address encoding
Rules that matter:
- Bitcoin address logic must not leak into shared derivation code.
- Ethereum must not inherit Bitcoin encodings or Bitcoin-style field naming.
- Solana must not inherit secp256k1 assumptions or Ethereum hashing rules.
Testing and Validation
Run the full suite:
bundle exec rspecRun a focused file:
bundle exec rspec spec/lib/skeleton_key/recovery/slip39_spec.rbRun all integration vectors:
bundle exec rspec spec/integration/vectorsFixture layout:
-
spec/fixtures/recovery/: BIP39 and SLIP-0039 recovery goldens -
spec/fixtures/vectors/bitcoin/: Bitcoin derivation vectors -
spec/fixtures/vectors/ethereum/: Ethereum derivation vectors -
spec/fixtures/vectors/solana/: Solana derivation vectors -
spec/fixtures/codecs/: Base58/Base58Check/Bech32 codec goldens
The preferred validation model in this repository is external golden-master comparison against established tools and independently generated corpora.
Fixture Policy
Golden-master fixtures in this repository are frozen validation artifacts, not routine developer outputs.
- do not casually regenerate fixtures as part of ordinary feature work
- treat fixture diffs as safety-critical review items
- when a fixture must change, document the external source and validation reason in the commit or PR
- prefer adding new coverage over rewriting existing canonical corpora
Repository Layout
Key directories:
-
lib/skeleton_key/recovery/: BIP39 and SLIP-0039 recovery -
lib/skeleton_key/derivation/: BIP32, SLIP-0010, derivation paths -
lib/skeleton_key/chains/bitcoin/: Bitcoin account and support logic -
lib/skeleton_key/chains/ethereum/: Ethereum account and support logic -
lib/skeleton_key/chains/solana/: Solana account and support logic -
lib/skeleton_key/codecs/: local Base58, Base58Check, Bech32 codecs -
spec/lib/: unit specs -
spec/integration/: vector compliance specs -
spec/support/: shared spec helpers
Contributing
Read these first:
Before submitting changes:
- keep the architecture boundary intact
- add or update golden fixtures when behavior changes
- add unit and integration coverage
- run
bundle exec rspec
If you change recovery, derivation, encoding, key serialization, or address construction, external vector proof is required.