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
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkeletonKey
|
|
4
|
+
module Chains
|
|
5
|
+
module Ethereum
|
|
6
|
+
##
|
|
7
|
+
# Ethereum-specific serialization and address helpers.
|
|
8
|
+
#
|
|
9
|
+
# This module owns the Ethereum-facing parts of account derivation:
|
|
10
|
+
# - extended key version bytes used by the current API surface
|
|
11
|
+
# - EIP-55 checksum address rendering
|
|
12
|
+
# - rendering of Ethereum child paths below an account node
|
|
13
|
+
#
|
|
14
|
+
# It intentionally does not own the underlying secp256k1 arithmetic.
|
|
15
|
+
module Support
|
|
16
|
+
extend Utils::Hashing
|
|
17
|
+
extend Utils::Encoding
|
|
18
|
+
|
|
19
|
+
XPUB_VERSION = 0x0488B21E
|
|
20
|
+
XPRV_VERSION = 0x0488ADE4
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Returns the four-byte version used for serialized extended private keys.
|
|
25
|
+
#
|
|
26
|
+
# @return [String] big-endian 4-byte version
|
|
27
|
+
def extended_private_version
|
|
28
|
+
[XPRV_VERSION].pack("N")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns the four-byte version used for serialized extended public keys.
|
|
32
|
+
#
|
|
33
|
+
# @return [String] big-endian 4-byte version
|
|
34
|
+
def extended_public_version
|
|
35
|
+
[XPUB_VERSION].pack("N")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Converts an uncompressed secp256k1 public key into a checksummed
|
|
39
|
+
# Ethereum address per EIP-55.
|
|
40
|
+
#
|
|
41
|
+
# @param pubkey_uncompressed [String] 65-byte uncompressed public key
|
|
42
|
+
# @return [String] checksummed `0x...` Ethereum address
|
|
43
|
+
def to_checksum_address(pubkey_uncompressed)
|
|
44
|
+
address_bytes = keccak256(pubkey_uncompressed.byteslice(1, 64)).byteslice(-20, 20)
|
|
45
|
+
address_hex = bytes_to_hex(address_bytes)
|
|
46
|
+
address_hash = bytes_to_hex(keccak256(address_hex))
|
|
47
|
+
|
|
48
|
+
checksummed = address_hex.chars.each_with_index.map do |char, idx|
|
|
49
|
+
next char if char.match?(/[0-9]/)
|
|
50
|
+
|
|
51
|
+
address_hash[idx].hex >= 8 ? char.upcase : char
|
|
52
|
+
end.join
|
|
53
|
+
|
|
54
|
+
"0x#{checksummed}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Derives a child node below the current account/root node and returns the
|
|
58
|
+
# Ethereum-facing representation of that child.
|
|
59
|
+
#
|
|
60
|
+
# @param change [Integer] branch index
|
|
61
|
+
# @param index [Integer] child index within the branch
|
|
62
|
+
# @param hardened_change [Boolean] whether the branch step is hardened
|
|
63
|
+
# @param hardened_index [Boolean] whether the address step is hardened
|
|
64
|
+
# @return [Hash] rendered path, private key, public keys, address, and chain code
|
|
65
|
+
def derive_address_from_node(change: 0, index: 0, hardened_change: false, hardened_index: false)
|
|
66
|
+
k_int, chain_code = derived[:k_int], derived[:c]
|
|
67
|
+
change_index = hardened_change ? change | Derivation::Path::HARDENED_FLAG : change
|
|
68
|
+
address_index = hardened_index ? index | Derivation::Path::HARDENED_FLAG : index
|
|
69
|
+
|
|
70
|
+
k_int, chain_code = ckd_priv(k_int, chain_code, change_index)
|
|
71
|
+
k_int, chain_code = ckd_priv(k_int, chain_code, address_index)
|
|
72
|
+
|
|
73
|
+
pubkey_compressed = privkey_to_pubkey_compressed(k_int)
|
|
74
|
+
pubkey_uncompressed = privkey_to_pubkey_uncompressed(k_int)
|
|
75
|
+
ethereum_public_key = pubkey_uncompressed.byteslice(1, 64)
|
|
76
|
+
|
|
77
|
+
{
|
|
78
|
+
path: build_derived_path(change: change, index: index, hardened_change: hardened_change, hardened_index: hardened_index),
|
|
79
|
+
private_key: ser256(k_int).unpack1("H*"),
|
|
80
|
+
public_key: ethereum_public_key.unpack1("H*"),
|
|
81
|
+
compressed_public_key: pubkey_compressed.unpack1("H*"),
|
|
82
|
+
address: to_checksum_address(pubkey_uncompressed),
|
|
83
|
+
chain_code: chain_code,
|
|
84
|
+
privkey: k_int,
|
|
85
|
+
pubkey: ethereum_public_key
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Derives and serializes a branch node directly beneath the current
|
|
90
|
+
# account/root prefix.
|
|
91
|
+
#
|
|
92
|
+
# @param change [Integer] branch index
|
|
93
|
+
# @param hardened_change [Boolean] whether the branch child is hardened
|
|
94
|
+
# @return [Hash] branch path with serialized xprv/xpub
|
|
95
|
+
def derive_branch_extended_keys(change: 0, hardened_change: false)
|
|
96
|
+
parent_key = derived[:k_int]
|
|
97
|
+
parent_chain_code = derived[:c]
|
|
98
|
+
parent_pubkey = privkey_to_pubkey_compressed(parent_key)
|
|
99
|
+
child_num = hardened_change ? change | Derivation::Path::HARDENED_FLAG : change
|
|
100
|
+
branch_key, branch_chain_code = ckd_priv(parent_key, parent_chain_code, child_num)
|
|
101
|
+
branch_pubkey = privkey_to_pubkey_compressed(branch_key)
|
|
102
|
+
|
|
103
|
+
{
|
|
104
|
+
path: branch_derived_path(change: change, hardened_change: hardened_change),
|
|
105
|
+
xprv: serialize_xprv(
|
|
106
|
+
branch_key,
|
|
107
|
+
branch_chain_code,
|
|
108
|
+
depth: legacy_root_branch? ? 1 : 4,
|
|
109
|
+
parent_fpr: fingerprint_from_pubkey(parent_pubkey),
|
|
110
|
+
child_num: child_num,
|
|
111
|
+
version: extended_private_version
|
|
112
|
+
),
|
|
113
|
+
xpub: serialize_xpub(
|
|
114
|
+
branch_pubkey,
|
|
115
|
+
branch_chain_code,
|
|
116
|
+
depth: legacy_root_branch? ? 1 : 4,
|
|
117
|
+
parent_fpr: fingerprint_from_pubkey(parent_pubkey),
|
|
118
|
+
child_num: child_num,
|
|
119
|
+
version: extended_public_version
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Renders the derived child path in canonical BIP32 string form.
|
|
125
|
+
#
|
|
126
|
+
# @return [String]
|
|
127
|
+
def build_derived_path(change:, index:, hardened_change:, hardened_index:)
|
|
128
|
+
rendered_change = hardened_change ? "#{change}'" : change.to_s
|
|
129
|
+
rendered_index = hardened_index ? "#{index}'" : index.to_s
|
|
130
|
+
"#{path}/#{rendered_change}/#{rendered_index}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Renders the derived branch path in canonical BIP32 string form.
|
|
134
|
+
#
|
|
135
|
+
# @return [String]
|
|
136
|
+
def branch_derived_path(change:, hardened_change:)
|
|
137
|
+
rendered_change = hardened_change ? "#{change}'" : change.to_s
|
|
138
|
+
"#{path}/#{rendered_change}"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkeletonKey
|
|
4
|
+
module Chains
|
|
5
|
+
module Solana
|
|
6
|
+
##
|
|
7
|
+
# Solana account derivation using hardened SLIP-0010 Ed25519 paths.
|
|
8
|
+
#
|
|
9
|
+
# Solana in SkeletonKey is intentionally hardened-only. This class owns the
|
|
10
|
+
# Solana path convention and turns a shared seed into Ed25519 key material
|
|
11
|
+
# and Base58 addresses, while the underlying SLIP-0010 math remains in the
|
|
12
|
+
# derivation layer.
|
|
13
|
+
#
|
|
14
|
+
# @example Derive the default wallet-style Solana address
|
|
15
|
+
# account = SkeletonKey::Chains::Solana::Account.new(seed: seed.bytes)
|
|
16
|
+
# account.address(change: 0)
|
|
17
|
+
class Account
|
|
18
|
+
include Support
|
|
19
|
+
include Derivation::SLIP10
|
|
20
|
+
|
|
21
|
+
# @return [Integer] derivation purpose, fixed at 44
|
|
22
|
+
# @return [Integer] SLIP-0044 coin type, fixed at 501 for Solana
|
|
23
|
+
# @return [Integer] account index within the Solana namespace
|
|
24
|
+
# @return [Hash] cached account-level key seed and chain code
|
|
25
|
+
attr_reader :purpose, :coin_type, :account_index, :derived
|
|
26
|
+
|
|
27
|
+
DEFAULT_PURPOSE = 44
|
|
28
|
+
SOLANA_COIN = 501
|
|
29
|
+
LEGACY_CHANGELESS_PURPOSE = 44
|
|
30
|
+
|
|
31
|
+
# @param seed [String] canonical seed bytes
|
|
32
|
+
# @param purpose [Integer] derivation purpose, fixed at 44
|
|
33
|
+
# @param coin_type [Integer] SLIP-0044 coin type, fixed at 501
|
|
34
|
+
# @param account_index [Integer] hardened account index
|
|
35
|
+
# @raise [Errors::UnsupportedPurposeError] if non-Solana path parameters are requested
|
|
36
|
+
def initialize(seed:, purpose: DEFAULT_PURPOSE, coin_type: SOLANA_COIN, account_index: 0)
|
|
37
|
+
@purpose = purpose
|
|
38
|
+
@coin_type = coin_type
|
|
39
|
+
@account_index = account_index
|
|
40
|
+
@derived = derive_from_seed(seed, purpose: purpose, coin_type: coin_type, account: account_index)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns the hardened account prefix for future child derivation.
|
|
44
|
+
#
|
|
45
|
+
# @return [String]
|
|
46
|
+
def path
|
|
47
|
+
derived[:path_prefix]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Derives a hardened Solana child path below the account prefix.
|
|
51
|
+
#
|
|
52
|
+
# In practice this supports both the shorter account-root convention and
|
|
53
|
+
# the wallet-style `.../change'/index'` convention used by many tools.
|
|
54
|
+
#
|
|
55
|
+
# @param change [Integer, nil] hardened child directly beneath the account
|
|
56
|
+
# @param index [Integer, nil] hardened child beneath the change node
|
|
57
|
+
# @return [Hash] path, private key, public key, address, and chain code
|
|
58
|
+
def address(change: 0, index: nil)
|
|
59
|
+
key_seed, chain_code = derived[:key_seed], derived[:chain_code]
|
|
60
|
+
current_path = path
|
|
61
|
+
|
|
62
|
+
unless change.nil?
|
|
63
|
+
current_path = "#{current_path}/#{change}'"
|
|
64
|
+
key_seed, chain_code = ckd_priv(key_seed, chain_code, hardened(change))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
unless index.nil?
|
|
68
|
+
current_path = "#{current_path}/#{index}'"
|
|
69
|
+
key_seed, chain_code = ckd_priv(key_seed, chain_code, hardened(index))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private_key, public_key = keypair_from_seed(key_seed)
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
path: current_path,
|
|
76
|
+
private_key: private_key.unpack1("H*"),
|
|
77
|
+
public_key: public_key.unpack1("H*"),
|
|
78
|
+
address: to_address(public_key),
|
|
79
|
+
chain_code: chain_code
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def derive_from_seed(seed_bytes, purpose:, coin_type:, account:)
|
|
86
|
+
raise Errors::UnsupportedPurposeError.new(purpose) unless purpose == 44
|
|
87
|
+
raise Errors::UnsupportedPurposeError.new(coin_type) unless coin_type == SOLANA_COIN
|
|
88
|
+
|
|
89
|
+
key_seed, chain_code = master_from_seed(seed_bytes)
|
|
90
|
+
# Every Solana child step is hardened. The path prefix stored here is
|
|
91
|
+
# the account node from which wallet-style descendants are derived.
|
|
92
|
+
[
|
|
93
|
+
hardened(purpose),
|
|
94
|
+
hardened(coin_type),
|
|
95
|
+
hardened(account)
|
|
96
|
+
].each do |index|
|
|
97
|
+
key_seed, chain_code = ckd_priv(key_seed, chain_code, index)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
{
|
|
101
|
+
path_prefix: "m/#{purpose}'/#{coin_type}'/#{account}'",
|
|
102
|
+
key_seed: key_seed,
|
|
103
|
+
chain_code: chain_code
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Encodes a child index as a hardened SLIP-0010 index.
|
|
108
|
+
#
|
|
109
|
+
# @param index [Integer]
|
|
110
|
+
# @return [Integer]
|
|
111
|
+
def hardened(index)
|
|
112
|
+
index | Derivation::SLIP10::HARDENED_FLAG
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkeletonKey
|
|
4
|
+
module Chains
|
|
5
|
+
module Solana
|
|
6
|
+
##
|
|
7
|
+
# Solana-specific address helpers.
|
|
8
|
+
#
|
|
9
|
+
# Solana addresses are the raw Ed25519 public key bytes encoded with the
|
|
10
|
+
# Bitcoin-style Base58 alphabet. No hashing or script construction is
|
|
11
|
+
# applied at the address layer.
|
|
12
|
+
module Support
|
|
13
|
+
extend Utils::Encoding
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Encodes a 32-byte Ed25519 public key as a Solana address.
|
|
18
|
+
#
|
|
19
|
+
# @param public_key_bytes [String] 32-byte Ed25519 public key
|
|
20
|
+
# @return [String] Base58-encoded Solana address
|
|
21
|
+
def to_address(public_key_bytes)
|
|
22
|
+
Codecs::Base58.encode(public_key_bytes)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkeletonKey
|
|
4
|
+
module Codecs
|
|
5
|
+
##
|
|
6
|
+
# Raw Base58 codec using the Bitcoin alphabet.
|
|
7
|
+
#
|
|
8
|
+
# This module performs plain Base58 conversion only. It does not append or
|
|
9
|
+
# validate checksums; callers that need Base58Check should use
|
|
10
|
+
# {Base58Check}. The implementation preserves leading zero bytes as `"1"`
|
|
11
|
+
# characters to match Bitcoin-compatible tooling.
|
|
12
|
+
module Base58
|
|
13
|
+
ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".freeze
|
|
14
|
+
INDEXES = ALPHABET.chars.each_with_index.to_h.freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Encodes raw bytes as a Base58 string.
|
|
19
|
+
#
|
|
20
|
+
# @param bytes [String] binary payload
|
|
21
|
+
# @return [String] Base58 string
|
|
22
|
+
def encode(bytes)
|
|
23
|
+
return "" if bytes.empty?
|
|
24
|
+
|
|
25
|
+
zero_prefixes = bytes.bytes.take_while(&:zero?).size
|
|
26
|
+
value = bytes.unpack1("H*").to_i(16)
|
|
27
|
+
encoded = +""
|
|
28
|
+
|
|
29
|
+
while value.positive?
|
|
30
|
+
value, remainder = value.divmod(58)
|
|
31
|
+
encoded.prepend(ALPHABET[remainder])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
("1" * zero_prefixes) + encoded
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Decodes a Base58 string into raw bytes.
|
|
38
|
+
#
|
|
39
|
+
# @param encoded [String] Base58 string
|
|
40
|
+
# @return [String] decoded binary payload
|
|
41
|
+
# @raise [Errors::InvalidBase58Error] if the string contains invalid characters
|
|
42
|
+
def decode(encoded)
|
|
43
|
+
raise Errors::InvalidBase58Error if encoded.nil?
|
|
44
|
+
raise Errors::InvalidBase58Error unless encoded.is_a?(String)
|
|
45
|
+
raise Errors::InvalidBase58Error if encoded.empty?
|
|
46
|
+
|
|
47
|
+
value = 0
|
|
48
|
+
encoded.each_char do |char|
|
|
49
|
+
digit = INDEXES[char]
|
|
50
|
+
raise Errors::InvalidBase58Error unless digit
|
|
51
|
+
|
|
52
|
+
value = (value * 58) + digit
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
hex = value.to_s(16)
|
|
56
|
+
hex = "0#{hex}" if hex.length.odd?
|
|
57
|
+
decoded = hex.empty? ? +"".b : [hex].pack("H*")
|
|
58
|
+
|
|
59
|
+
leading_zeroes = encoded.each_char.take_while { |char| char == "1" }.size
|
|
60
|
+
("\x00".b * leading_zeroes) + decoded
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkeletonKey
|
|
4
|
+
module Codecs
|
|
5
|
+
##
|
|
6
|
+
# Base58Check codec used by Bitcoin-facing serialization.
|
|
7
|
+
#
|
|
8
|
+
# Base58Check appends a four-byte double-SHA256 checksum to the payload
|
|
9
|
+
# before Base58 encoding. This module owns checksum construction and
|
|
10
|
+
# verification, while {Base58} handles the underlying alphabet conversion.
|
|
11
|
+
module Base58Check
|
|
12
|
+
extend Utils::Hashing
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Encodes a payload with a four-byte checksum.
|
|
17
|
+
#
|
|
18
|
+
# @param payload [String] raw bytes to encode
|
|
19
|
+
# @return [String] Base58Check string
|
|
20
|
+
def encode(payload)
|
|
21
|
+
Base58.encode(payload + checksum(payload))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Decodes and verifies a Base58Check string.
|
|
25
|
+
#
|
|
26
|
+
# @param encoded [String] Base58Check string
|
|
27
|
+
# @return [String] decoded payload without checksum bytes
|
|
28
|
+
# @raise [Errors::InvalidBase58Error] if the string is malformed
|
|
29
|
+
# @raise [Errors::InvalidChecksumError] if the checksum does not match
|
|
30
|
+
def decode(encoded)
|
|
31
|
+
decoded = Base58.decode(encoded)
|
|
32
|
+
raise Errors::InvalidBase58Error if decoded.bytesize < 4
|
|
33
|
+
|
|
34
|
+
payload = decoded.byteslice(0, decoded.bytesize - 4)
|
|
35
|
+
observed_checksum = decoded.byteslice(-4, 4)
|
|
36
|
+
raise Errors::InvalidChecksumError unless checksum(payload) == observed_checksum
|
|
37
|
+
|
|
38
|
+
payload
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkeletonKey
|
|
4
|
+
module Codecs
|
|
5
|
+
##
|
|
6
|
+
# Local Bech32 and Bech32m codec implementation.
|
|
7
|
+
#
|
|
8
|
+
# This module implements the exact checksum and charset rules needed for
|
|
9
|
+
# Bitcoin SegWit address encoding. It is intentionally generic at the codec
|
|
10
|
+
# boundary: HRP handling, checksum creation, and bit conversion live here,
|
|
11
|
+
# while script semantics remain in the Bitcoin layer.
|
|
12
|
+
module Bech32
|
|
13
|
+
CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l".freeze
|
|
14
|
+
CHARSET_INDEX = CHARSET.chars.each_with_index.to_h.freeze
|
|
15
|
+
GENERATOR = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3].freeze
|
|
16
|
+
SEPARATOR = "1"
|
|
17
|
+
MAX_LENGTH = 90
|
|
18
|
+
|
|
19
|
+
module Encoding
|
|
20
|
+
BECH32 = :bech32
|
|
21
|
+
BECH32M = :bech32m
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Encodes an HRP and 5-bit data words into a Bech32 or Bech32m string.
|
|
27
|
+
#
|
|
28
|
+
# @param hrp [String] human-readable prefix
|
|
29
|
+
# @param data [Array<Integer>] data words in the range 0..31
|
|
30
|
+
# @param encoding [Symbol] {Encoding::BECH32} or {Encoding::BECH32M}
|
|
31
|
+
# @return [String] encoded Bech32 string
|
|
32
|
+
def encode(hrp, data, encoding = Encoding::BECH32)
|
|
33
|
+
validate_hrp!(hrp)
|
|
34
|
+
validate_data!(data)
|
|
35
|
+
|
|
36
|
+
hrp = hrp.downcase
|
|
37
|
+
combined = data + create_checksum(hrp, data, encoding)
|
|
38
|
+
"#{hrp}#{SEPARATOR}#{combined.map { |value| CHARSET[value] }.join}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Decodes a Bech32/Bech32m string and validates its checksum.
|
|
42
|
+
#
|
|
43
|
+
# @param bech [String] encoded Bech32 string
|
|
44
|
+
# @return [Array(String, Array<Integer>, Symbol)] HRP, data words, encoding type
|
|
45
|
+
# @raise [Errors::InvalidBech32Error] if the string is malformed or the checksum fails
|
|
46
|
+
def decode(bech)
|
|
47
|
+
raise Errors::InvalidBech32Error unless bech.is_a?(String)
|
|
48
|
+
raise Errors::InvalidBech32Error if bech.empty? || bech.length > MAX_LENGTH
|
|
49
|
+
|
|
50
|
+
has_lower = bech.match?(/[a-z]/)
|
|
51
|
+
has_upper = bech.match?(/[A-Z]/)
|
|
52
|
+
raise Errors::InvalidBech32Error if has_lower && has_upper
|
|
53
|
+
|
|
54
|
+
normalized = bech.downcase
|
|
55
|
+
separator_index = normalized.rindex(SEPARATOR)
|
|
56
|
+
raise Errors::InvalidBech32Error if separator_index.nil?
|
|
57
|
+
raise Errors::InvalidBech32Error if separator_index < 1
|
|
58
|
+
raise Errors::InvalidBech32Error if separator_index + 7 > normalized.length
|
|
59
|
+
|
|
60
|
+
hrp = normalized[0...separator_index]
|
|
61
|
+
data_part = normalized[(separator_index + 1)..]
|
|
62
|
+
raise Errors::InvalidBech32Error if hrp.empty? || data_part.empty?
|
|
63
|
+
|
|
64
|
+
data = data_part.chars.map do |char|
|
|
65
|
+
value = CHARSET_INDEX[char]
|
|
66
|
+
raise Errors::InvalidBech32Error unless value
|
|
67
|
+
|
|
68
|
+
value
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
encoding = verify_checksum(hrp, data)
|
|
72
|
+
raise Errors::InvalidBech32Error unless encoding
|
|
73
|
+
|
|
74
|
+
[hrp, data[0...-6], encoding]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Converts a stream of fixed-width integers into another width.
|
|
78
|
+
#
|
|
79
|
+
# This is the normalization step required when moving between raw bytes
|
|
80
|
+
# (8-bit values) and Bech32 witness program words (5-bit values).
|
|
81
|
+
#
|
|
82
|
+
# @param data [Array<Integer>] source values
|
|
83
|
+
# @param from_bits [Integer] bit width of each source value
|
|
84
|
+
# @param to_bits [Integer] desired bit width of each output value
|
|
85
|
+
# @param pad [Boolean] whether trailing zero padding is permitted
|
|
86
|
+
# @return [Array<Integer>]
|
|
87
|
+
# @raise [Errors::InvalidConvertBitsError] if the input cannot be losslessly converted
|
|
88
|
+
def convert_bits(data, from_bits, to_bits, pad)
|
|
89
|
+
acc = 0
|
|
90
|
+
bits = 0
|
|
91
|
+
result = []
|
|
92
|
+
maxv = (1 << to_bits) - 1
|
|
93
|
+
max_acc = (1 << (from_bits + to_bits - 1)) - 1
|
|
94
|
+
|
|
95
|
+
data.each do |value|
|
|
96
|
+
raise Errors::InvalidConvertBitsError if value.negative? || (value >> from_bits) != 0
|
|
97
|
+
|
|
98
|
+
acc = ((acc << from_bits) | value) & max_acc
|
|
99
|
+
bits += from_bits
|
|
100
|
+
while bits >= to_bits
|
|
101
|
+
bits -= to_bits
|
|
102
|
+
result << ((acc >> bits) & maxv)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if pad
|
|
107
|
+
result << ((acc << (to_bits - bits)) & maxv) if bits.positive?
|
|
108
|
+
elsif bits >= from_bits || ((acc << (to_bits - bits)) & maxv) != 0
|
|
109
|
+
raise Errors::InvalidConvertBitsError
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
result
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Creates the six checksum words for the given payload.
|
|
116
|
+
#
|
|
117
|
+
# @return [Array<Integer>]
|
|
118
|
+
def create_checksum(hrp, data, encoding)
|
|
119
|
+
constant = checksum_constant(encoding)
|
|
120
|
+
values = hrp_expand(hrp) + data
|
|
121
|
+
polymod = polymod(values + [0, 0, 0, 0, 0, 0]) ^ constant
|
|
122
|
+
6.times.map { |idx| (polymod >> (5 * (5 - idx))) & 31 }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Verifies the checksum and returns the detected Bech32 encoding family.
|
|
126
|
+
#
|
|
127
|
+
# @return [Symbol, nil]
|
|
128
|
+
def verify_checksum(hrp, data)
|
|
129
|
+
value = polymod(hrp_expand(hrp) + data)
|
|
130
|
+
return Encoding::BECH32 if value == 1
|
|
131
|
+
return Encoding::BECH32M if value == 0x2bc830a3
|
|
132
|
+
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Returns the checksum constant for the selected encoding family.
|
|
137
|
+
#
|
|
138
|
+
# @return [Integer]
|
|
139
|
+
def checksum_constant(encoding)
|
|
140
|
+
case encoding
|
|
141
|
+
when Encoding::BECH32 then 1
|
|
142
|
+
when Encoding::BECH32M then 0x2bc830a3
|
|
143
|
+
else
|
|
144
|
+
raise Errors::InvalidBech32Error, "unsupported bech32 encoding"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Computes the Bech32 polymod checksum accumulator.
|
|
149
|
+
#
|
|
150
|
+
# @return [Integer]
|
|
151
|
+
def polymod(values)
|
|
152
|
+
chk = 1
|
|
153
|
+
values.each do |value|
|
|
154
|
+
top = chk >> 25
|
|
155
|
+
chk = ((chk & 0x1ffffff) << 5) ^ value
|
|
156
|
+
5.times do |idx|
|
|
157
|
+
chk ^= GENERATOR[idx] if ((top >> idx) & 1) == 1
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
chk
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Expands the HRP into the form required by the Bech32 checksum.
|
|
164
|
+
#
|
|
165
|
+
# @return [Array<Integer>]
|
|
166
|
+
def hrp_expand(hrp)
|
|
167
|
+
hrp.bytes.map { |byte| byte >> 5 } + [0] + hrp.bytes.map { |byte| byte & 31 }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def validate_hrp!(hrp)
|
|
171
|
+
raise Errors::InvalidBech32Error unless hrp.is_a?(String)
|
|
172
|
+
raise Errors::InvalidBech32Error if hrp.empty?
|
|
173
|
+
raise Errors::InvalidBech32Error unless hrp.bytes.all? { |byte| byte.between?(33, 126) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def validate_data!(data)
|
|
177
|
+
raise Errors::InvalidBech32Error unless data.is_a?(Array)
|
|
178
|
+
raise Errors::InvalidBech32Error unless data.all? { |value| value.is_a?(Integer) && value.between?(0, 31) }
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkeletonKey
|
|
4
|
+
##
|
|
5
|
+
# Core constants shared across the SkeletonKey library.
|
|
6
|
+
#
|
|
7
|
+
# These constants define valid seed and entropy lengths
|
|
8
|
+
# according to BIP-39 (mnemonics), SLIP-39 (Shamir mnemonics),
|
|
9
|
+
# and common cryptographic practice.
|
|
10
|
+
#
|
|
11
|
+
# @see https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki BIP-39
|
|
12
|
+
# @see https://github.com/satoshilabs/slips/blob/master/slip-0039.md SLIP-39
|
|
13
|
+
module Constants
|
|
14
|
+
##
|
|
15
|
+
# Valid entropy lengths for mnemonic generation.
|
|
16
|
+
#
|
|
17
|
+
# BIP-39 specifies entropy sizes of 128–256 bits,
|
|
18
|
+
# in steps of 32 bits. These map to 12–24 words.
|
|
19
|
+
#
|
|
20
|
+
# @return [Array<Integer>] allowed entropy sizes in bytes
|
|
21
|
+
ENTROPY_LENGTHS = [16, 20, 24, 28, 32].freeze
|
|
22
|
+
|
|
23
|
+
##
|
|
24
|
+
# Valid BIP39 mnemonic lengths in words.
|
|
25
|
+
#
|
|
26
|
+
# BIP39 supports 12, 15, 18, 21, and 24 word phrases.
|
|
27
|
+
#
|
|
28
|
+
# @return [Array<Integer>] allowed mnemonic word counts
|
|
29
|
+
MNEMONIC_WORD_COUNTS = [12, 15, 18, 21, 24].freeze
|
|
30
|
+
|
|
31
|
+
##
|
|
32
|
+
# Valid master secret lengths for SLIP-39.
|
|
33
|
+
#
|
|
34
|
+
# SLIP-39 supports secrets of 128, 192, or 256 bits,
|
|
35
|
+
# which are represented as 16, 24, or 32 bytes.
|
|
36
|
+
#
|
|
37
|
+
# @return [Array<Integer>] allowed SLIP-39 master secret sizes
|
|
38
|
+
SLIP39_SECRET_LENGTHS = [16, 24, 32].freeze
|
|
39
|
+
|
|
40
|
+
##
|
|
41
|
+
# Valid seed lengths for wallet derivation.
|
|
42
|
+
#
|
|
43
|
+
# - BIP-39 seeds are produced by PBKDF2 and are always 64 bytes (512 bits).
|
|
44
|
+
# - SLIP-39 master secrets are used directly and may be 16, 24, or 32 bytes.
|
|
45
|
+
#
|
|
46
|
+
# @return [Array<Integer>] allowed seed sizes in bytes
|
|
47
|
+
SEED_LENGTHS = (SLIP39_SECRET_LENGTHS + [64]).freeze
|
|
48
|
+
|
|
49
|
+
##
|
|
50
|
+
# Standard length of a private key (secp256k1 or Ed25519).
|
|
51
|
+
#
|
|
52
|
+
# Both Bitcoin/Ethereum (secp256k1) and Solana (Ed25519)
|
|
53
|
+
# use 32-byte private keys.
|
|
54
|
+
#
|
|
55
|
+
# @return [Integer]
|
|
56
|
+
PRIVATE_KEY_LENGTH = 32
|
|
57
|
+
|
|
58
|
+
##
|
|
59
|
+
# Standard length of a public key (compressed Ed25519 or secp256k1).
|
|
60
|
+
#
|
|
61
|
+
# - Ed25519 public keys are 32 bytes.
|
|
62
|
+
# - Compressed secp256k1 public keys are 33 bytes,
|
|
63
|
+
# but the underlying X coordinate is 32 bytes.
|
|
64
|
+
#
|
|
65
|
+
# @return [Integer]
|
|
66
|
+
PUBLIC_KEY_LENGTH = 32
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module SkeletonKey
|
|
2
|
+
module Core
|
|
3
|
+
##
|
|
4
|
+
# Cryptographically secure entropy generation.
|
|
5
|
+
#
|
|
6
|
+
# This module is the raw randomness boundary for SkeletonKey. It generates
|
|
7
|
+
# entropy suitable for seed creation and mnemonic generation, but it does
|
|
8
|
+
# not interpret that entropy as a chain-specific key or address.
|
|
9
|
+
module Entropy
|
|
10
|
+
# Generates cryptographically secure random entropy
|
|
11
|
+
#
|
|
12
|
+
# @param bytes [Integer] the number of random bytes to generate (default: 32)
|
|
13
|
+
# @param format [Symbol] the format of the output (:bytes, :octets, :hex)
|
|
14
|
+
# @return [String, Array<Integer>] the generated entropy in the specified format
|
|
15
|
+
# @raise [ArgumentError] if bytes is not a valid entropy length or format is invalid
|
|
16
|
+
def self.generate(bytes: 32, format: :bytes)
|
|
17
|
+
raise SkeletonKey::Errors::InvalidEntropyLengthError unless valid_entropy_lengths.include?(bytes)
|
|
18
|
+
raw = SecureRandom.random_bytes(bytes)
|
|
19
|
+
|
|
20
|
+
case format
|
|
21
|
+
when :bytes then raw.freeze # binary string (32 bytes)
|
|
22
|
+
when :octets then raw.bytes.freeze # array of byte values (32 integers)
|
|
23
|
+
when :hex then raw.unpack1("H*") # 64 hex chars for 32 bytes
|
|
24
|
+
else
|
|
25
|
+
raise SkeletonKey::Errors::InvalidEntropyFormatError
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns the valid byte sizes for entropy generation
|
|
30
|
+
#
|
|
31
|
+
# @return [Array<Integer>] valid byte sizes
|
|
32
|
+
def self.valid_entropy_lengths
|
|
33
|
+
SkeletonKey::Constants::ENTROPY_LENGTHS
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|