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,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
7
|
+
module SkeletonKey
|
|
8
|
+
module Recovery
|
|
9
|
+
##
|
|
10
|
+
# BIP39 mnemonic validation and seed recovery.
|
|
11
|
+
#
|
|
12
|
+
# This class owns the *recovery* side of BIP39:
|
|
13
|
+
# - mnemonic generation from entropy
|
|
14
|
+
# - phrase normalization
|
|
15
|
+
# - word membership validation against the canonical English wordlist
|
|
16
|
+
# - checksum validation
|
|
17
|
+
# - PBKDF2 seed derivation
|
|
18
|
+
#
|
|
19
|
+
# It does not perform downstream HD derivation. Callers should treat the
|
|
20
|
+
# returned {Seed} as the boundary between recovery and derivation layers.
|
|
21
|
+
#
|
|
22
|
+
# @example Recover a seed from a 12-word mnemonic
|
|
23
|
+
# bip39 = SkeletonKey::Recovery::Bip39.new("abandon abandon ... about")
|
|
24
|
+
# seed = bip39.seed
|
|
25
|
+
class Bip39
|
|
26
|
+
include Utils::Hashing
|
|
27
|
+
extend Utils::Encoding
|
|
28
|
+
|
|
29
|
+
WORDLIST_PATH = File.expand_path("bip39_english.txt", __dir__)
|
|
30
|
+
|
|
31
|
+
# The normalized BIP39 phrase as a single space-delimited string.
|
|
32
|
+
#
|
|
33
|
+
# @return [String]
|
|
34
|
+
attr_reader :phrase
|
|
35
|
+
|
|
36
|
+
# @param phrase [String] mnemonic phrase to validate and normalize
|
|
37
|
+
# @raise [Errors::InvalidMnemonicError] if the phrase fails BIP39 checks
|
|
38
|
+
def initialize(phrase)
|
|
39
|
+
@phrase = normalize_phrase(phrase)
|
|
40
|
+
validate!
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns the mnemonic words after normalization.
|
|
44
|
+
#
|
|
45
|
+
# @return [Array<String>]
|
|
46
|
+
def words
|
|
47
|
+
phrase.split(" ")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Derives the BIP39 seed using PBKDF2-HMAC-SHA512.
|
|
51
|
+
#
|
|
52
|
+
# BIP39 seed derivation is intentionally separate from wordlist and
|
|
53
|
+
# checksum validation. This method assumes the phrase has already been
|
|
54
|
+
# validated during initialization.
|
|
55
|
+
#
|
|
56
|
+
# @param passphrase [String] optional BIP39 passphrase
|
|
57
|
+
# @return [Seed] recovered 64-byte BIP39 seed
|
|
58
|
+
def seed(passphrase: "")
|
|
59
|
+
seed_bytes = OpenSSL::PKCS5.pbkdf2_hmac(
|
|
60
|
+
normalized_utf8(phrase),
|
|
61
|
+
normalized_utf8("mnemonic#{passphrase}"),
|
|
62
|
+
2048,
|
|
63
|
+
64,
|
|
64
|
+
"sha512"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
Seed.import_from_bytes(seed_bytes)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class << self
|
|
71
|
+
# Generates a new BIP39 mnemonic for the requested word count.
|
|
72
|
+
#
|
|
73
|
+
# Supply `entropy` to deterministically reproduce a mnemonic. If
|
|
74
|
+
# omitted, fresh entropy is generated internally.
|
|
75
|
+
#
|
|
76
|
+
# @param word_count [Integer] one of the supported BIP39 word counts
|
|
77
|
+
# @param entropy [String, Array<Integer>, nil] optional explicit entropy
|
|
78
|
+
# @return [Bip39]
|
|
79
|
+
# @raise [Errors::InvalidMnemonicConfigurationError] if the word count is unsupported
|
|
80
|
+
# @raise [Errors::InvalidEntropyLengthError] if explicit entropy has the wrong size
|
|
81
|
+
def generate(word_count: 12, entropy: nil)
|
|
82
|
+
entropy_bytes =
|
|
83
|
+
if entropy.nil?
|
|
84
|
+
Core::Entropy.generate(bytes: entropy_length_for_word_count(word_count))
|
|
85
|
+
else
|
|
86
|
+
normalize_entropy(entropy, expected_bytes: entropy_length_for_word_count(word_count))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
from_entropy(entropy_bytes)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Converts entropy into a validated BIP39 mnemonic.
|
|
93
|
+
#
|
|
94
|
+
# @param entropy [String, Array<Integer>] entropy bytes, hex, or octets
|
|
95
|
+
# @return [Bip39]
|
|
96
|
+
# @raise [Errors::InvalidEntropyLengthError] if the entropy length is unsupported
|
|
97
|
+
def from_entropy(entropy)
|
|
98
|
+
entropy_bytes = normalize_entropy(entropy)
|
|
99
|
+
raise Errors::InvalidEntropyLengthError unless Constants::ENTROPY_LENGTHS.include?(entropy_bytes.bytesize)
|
|
100
|
+
|
|
101
|
+
checksum_length_bits = (entropy_bytes.bytesize * 8) / 32
|
|
102
|
+
entropy_bits = entropy_bytes.unpack1("B*")
|
|
103
|
+
checksum_bits = Digest::SHA256.digest(entropy_bytes).unpack1("B*")[0, checksum_length_bits]
|
|
104
|
+
bitstream = entropy_bits + checksum_bits
|
|
105
|
+
|
|
106
|
+
words = bitstream.scan(/.{11}/).map do |chunk|
|
|
107
|
+
wordlist.fetch(chunk.to_i(2))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
new(words.join(" "))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Coerces raw input into a validated {Bip39} instance.
|
|
114
|
+
#
|
|
115
|
+
# @param value [Bip39, String]
|
|
116
|
+
# @return [Bip39]
|
|
117
|
+
# @raise [Errors::InvalidMnemonicError] if the value cannot be imported
|
|
118
|
+
def import(value)
|
|
119
|
+
case
|
|
120
|
+
when value.is_a?(Bip39) then value
|
|
121
|
+
when value.is_a?(String) then new(value)
|
|
122
|
+
else
|
|
123
|
+
raise Errors::InvalidMnemonicError
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Loads the canonical English BIP39 wordlist.
|
|
128
|
+
#
|
|
129
|
+
# @return [Array<String>] frozen array of 2048 words
|
|
130
|
+
def wordlist
|
|
131
|
+
@wordlist ||= File.readlines(WORDLIST_PATH, chomp: true).freeze
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Builds a constant-time-ish lookup map from word to index.
|
|
135
|
+
#
|
|
136
|
+
# @return [Hash{String => Integer}]
|
|
137
|
+
def word_index
|
|
138
|
+
@word_index ||= wordlist.each_with_index.to_h.freeze
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def entropy_length_for_word_count(word_count)
|
|
144
|
+
raise Errors::InvalidMnemonicConfigurationError, "unsupported BIP39 word count: #{word_count}" unless Constants::MNEMONIC_WORD_COUNTS.include?(word_count)
|
|
145
|
+
|
|
146
|
+
(word_count * 11 * 32) / (33 * 8)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def normalize_entropy(entropy, expected_bytes: nil)
|
|
150
|
+
entropy_bytes =
|
|
151
|
+
if octet_array?(entropy)
|
|
152
|
+
octets_to_bytes(entropy)
|
|
153
|
+
elsif hex_string?(entropy)
|
|
154
|
+
hex_to_bytes(entropy)
|
|
155
|
+
elsif byte_string?(entropy)
|
|
156
|
+
entropy
|
|
157
|
+
else
|
|
158
|
+
raise Errors::InvalidEntropyLengthError
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
if expected_bytes && entropy_bytes.bytesize != expected_bytes
|
|
162
|
+
raise Errors::InvalidEntropyLengthError, "entropy must be #{expected_bytes} bytes for the requested mnemonic length"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
entropy_bytes
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def normalize_phrase(value)
|
|
172
|
+
normalized_utf8(value.to_s).strip.gsub(/\s+/, " ")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def normalized_utf8(value)
|
|
176
|
+
value.unicode_normalize(:nfkd).encode(Encoding::UTF_8)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def validate!
|
|
180
|
+
raise Errors::InvalidMnemonicError unless valid_word_count?(words)
|
|
181
|
+
raise Errors::InvalidMnemonicError unless valid_words?(words)
|
|
182
|
+
raise Errors::InvalidMnemonicError unless valid_checksum?(words)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def valid_word_count?(mnemonic_words)
|
|
186
|
+
Constants::MNEMONIC_WORD_COUNTS.include?(mnemonic_words.length)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def valid_words?(mnemonic_words)
|
|
190
|
+
mnemonic_words.all? { |word| self.class.word_index.key?(word) }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def valid_checksum?(mnemonic_words)
|
|
194
|
+
# BIP39 packs each word index into 11 bits, then splits the resulting
|
|
195
|
+
# bitstream into entropy bits plus checksum bits derived from SHA-256.
|
|
196
|
+
bitstream = mnemonic_words.map do |word|
|
|
197
|
+
self.class.word_index.fetch(word).to_s(2).rjust(11, "0")
|
|
198
|
+
end.join
|
|
199
|
+
|
|
200
|
+
entropy_length_bits = (mnemonic_words.length * 11 * 32) / 33
|
|
201
|
+
checksum_length_bits = bitstream.length - entropy_length_bits
|
|
202
|
+
entropy_bits = bitstream[0, entropy_length_bits]
|
|
203
|
+
checksum_bits = bitstream[entropy_length_bits, checksum_length_bits]
|
|
204
|
+
|
|
205
|
+
entropy_bytes = [entropy_bits].pack("B*")
|
|
206
|
+
expected_checksum_bits = sha256(entropy_bytes).unpack1("B*")[0, checksum_length_bits]
|
|
207
|
+
|
|
208
|
+
checksum_bits == expected_checksum_bits
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|