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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +542 -0
  3. data/bin/console +8 -0
  4. data/bin/lint +10 -0
  5. data/bin/setup +21 -0
  6. data/lib/skeleton_key/chains/bitcoin/account.rb +101 -0
  7. data/lib/skeleton_key/chains/bitcoin/account_derivation.rb +127 -0
  8. data/lib/skeleton_key/chains/bitcoin/support/outputs.rb +77 -0
  9. data/lib/skeleton_key/chains/bitcoin/support/paths.rb +34 -0
  10. data/lib/skeleton_key/chains/bitcoin/support/versioning.rb +87 -0
  11. data/lib/skeleton_key/chains/bitcoin/support.rb +48 -0
  12. data/lib/skeleton_key/chains/ethereum/account.rb +191 -0
  13. data/lib/skeleton_key/chains/ethereum/support.rb +143 -0
  14. data/lib/skeleton_key/chains/solana/account.rb +117 -0
  15. data/lib/skeleton_key/chains/solana/support.rb +27 -0
  16. data/lib/skeleton_key/codecs/base58.rb +64 -0
  17. data/lib/skeleton_key/codecs/base58_check.rb +42 -0
  18. data/lib/skeleton_key/codecs/bech32.rb +182 -0
  19. data/lib/skeleton_key/constants.rb +68 -0
  20. data/lib/skeleton_key/core/entropy.rb +37 -0
  21. data/lib/skeleton_key/derivation/bip32.rb +182 -0
  22. data/lib/skeleton_key/derivation/path.rb +112 -0
  23. data/lib/skeleton_key/derivation/slip10.rb +89 -0
  24. data/lib/skeleton_key/errors.rb +158 -0
  25. data/lib/skeleton_key/keyring.rb +63 -0
  26. data/lib/skeleton_key/recovery/bip39.rb +212 -0
  27. data/lib/skeleton_key/recovery/bip39_english.txt +2048 -0
  28. data/lib/skeleton_key/recovery/slip39.rb +220 -0
  29. data/lib/skeleton_key/recovery/slip39_support/bit_packing.rb +37 -0
  30. data/lib/skeleton_key/recovery/slip39_support/checksum.rb +53 -0
  31. data/lib/skeleton_key/recovery/slip39_support/cipher.rb +81 -0
  32. data/lib/skeleton_key/recovery/slip39_support/decoder.rb +109 -0
  33. data/lib/skeleton_key/recovery/slip39_support/encoder.rb +48 -0
  34. data/lib/skeleton_key/recovery/slip39_support/generated_set.rb +39 -0
  35. data/lib/skeleton_key/recovery/slip39_support/generator.rb +156 -0
  36. data/lib/skeleton_key/recovery/slip39_support/interpolation.rb +71 -0
  37. data/lib/skeleton_key/recovery/slip39_support/protocol.rb +34 -0
  38. data/lib/skeleton_key/recovery/slip39_support/secret_recovery.rb +74 -0
  39. data/lib/skeleton_key/recovery/slip39_support/share.rb +50 -0
  40. data/lib/skeleton_key/recovery/slip39_wordlist.txt +1024 -0
  41. data/lib/skeleton_key/seed.rb +127 -0
  42. data/lib/skeleton_key/skeleton_key.code-workspace +11 -0
  43. data/lib/skeleton_key/utils/encoding.rb +134 -0
  44. data/lib/skeleton_key/utils/hashing.rb +238 -0
  45. data/lib/skeleton_key/version.rb +8 -0
  46. data/lib/skeleton_key.rb +66 -0
  47. 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