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,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "securerandom"
5
+
6
+ module SkeletonKey
7
+ module Recovery
8
+ ##
9
+ # SLIP-0039 Shamir-share recovery for master secrets.
10
+ #
11
+ # This class validates SLIP-0039 mnemonic shares, enforces group and member
12
+ # thresholds, can generate new share sets from a master secret, reconstructs
13
+ # the encrypted master secret, and decrypts it into a {Seed}. It belongs to
14
+ # the recovery layer and intentionally stops before any downstream chain
15
+ # derivation begins.
16
+ #
17
+ # Caller input is a **flat array of share strings**. The caller does not
18
+ # supply nested groups. Instead, each share encodes its own group metadata,
19
+ # and {Slip39} reconstructs the group structure internally during recovery.
20
+ #
21
+ # In other words, the protocol is multi-group, but the Ruby interface is:
22
+ # - `Array<String>` of shares
23
+ # - optional ASCII passphrase
24
+ # - return a recovered {Seed}
25
+ #
26
+ # @example Recover a seed from a single-group threshold set
27
+ # seed = SkeletonKey::Recovery::Slip39.recover(shares, passphrase: "")
28
+ #
29
+ # @example Recover a seed from a multi-group threshold set
30
+ # shares = [
31
+ # group_0_share_0,
32
+ # group_0_share_1,
33
+ # group_2_share_0,
34
+ # group_2_share_1
35
+ # ]
36
+ #
37
+ # # Group membership is inferred from the encoded SLIP-0039 share data.
38
+ # seed = SkeletonKey::Recovery::Slip39.recover(shares, passphrase: "PASS8")
39
+ class Slip39
40
+ extend Utils::Encoding
41
+
42
+ WORDLIST_PATH = File.expand_path("slip39_wordlist.txt", __dir__)
43
+ include Slip39Support::Protocol
44
+
45
+ class << self
46
+ # Generates SLIP-0039 mnemonic shares from a master secret.
47
+ #
48
+ # For single-group sharing, provide `member_threshold` and
49
+ # `member_count`. For advanced multi-group sharing, provide `groups:`
50
+ # and `group_threshold:`.
51
+ #
52
+ # @param master_secret [String, Array<Integer>, Seed] 16, 24, or 32-byte master secret
53
+ # @param member_threshold [Integer, nil] single-group member threshold
54
+ # @param member_count [Integer, nil] single-group member count
55
+ # @param groups [Array<Hash>, nil] multi-group member thresholds/counts
56
+ # @param group_threshold [Integer] number of groups required for recovery
57
+ # @param passphrase [String] optional SLIP-0039 passphrase
58
+ # @param extendable [Boolean]
59
+ # @param iteration_exponent [Integer]
60
+ # @param random_bytes [#call, nil] optional deterministic random-byte source
61
+ # @return [Slip39Support::GeneratedSet]
62
+ def generate(
63
+ master_secret:,
64
+ member_threshold: nil,
65
+ member_count: nil,
66
+ groups: nil,
67
+ group_threshold: 1,
68
+ passphrase: "",
69
+ extendable: true,
70
+ iteration_exponent: 1,
71
+ random_bytes: nil
72
+ )
73
+ group_config = normalize_groups(member_threshold:, member_count:, groups:)
74
+ generator = Slip39Support::Generator.new(
75
+ wordlist: wordlist,
76
+ customization_string: method(:customization_string_for),
77
+ cipher: Slip39Support::Cipher.new(
78
+ customization_string_orig: Slip39Support::Protocol::CUSTOMIZATION_STRING_ORIG
79
+ ),
80
+ random_bytes: random_bytes || SecureRandom.method(:random_bytes)
81
+ )
82
+
83
+ generator.generate(
84
+ master_secret: normalize_master_secret(master_secret),
85
+ group_threshold: group_threshold,
86
+ groups: group_config,
87
+ passphrase: passphrase,
88
+ extendable: extendable,
89
+ iteration_exponent: iteration_exponent
90
+ )
91
+ end
92
+
93
+ # Recovers a master secret from a set of SLIP-0039 mnemonics.
94
+ #
95
+ # The input is a flat list of share strings. Grouping is inferred from
96
+ # the metadata encoded inside each share rather than from caller-supplied
97
+ # nesting.
98
+ #
99
+ # @param mnemonics [Array<String>] flat threshold set of SLIP-0039 shares
100
+ # @param passphrase [String] optional SLIP-0039 passphrase
101
+ # @return [Seed]
102
+ def recover(mnemonics, passphrase: "")
103
+ new(mnemonics).recover(passphrase: passphrase)
104
+ end
105
+
106
+ # Loads the canonical SLIP-0039 wordlist.
107
+ #
108
+ # @return [Array<String>] frozen list of 1024 words
109
+ # @raise [Errors::InvalidSlip39ShareError] if the vendored wordlist is malformed
110
+ def wordlist
111
+ @wordlist ||= begin
112
+ words = File.readlines(WORDLIST_PATH, chomp: true)
113
+ raise Errors::InvalidSlip39ShareError, "invalid SLIP-0039 wordlist length" unless words.length == Slip39Support::Protocol::RADIX
114
+
115
+ words.freeze
116
+ end
117
+ end
118
+
119
+ # Maps each word to its 10-bit index.
120
+ #
121
+ # @return [Hash{String => Integer}]
122
+ def word_index
123
+ @word_index ||= wordlist.each_with_index.to_h.freeze
124
+ end
125
+
126
+ def customization_string_for(extendable)
127
+ if extendable
128
+ Slip39Support::Protocol::CUSTOMIZATION_STRING_EXTENDABLE
129
+ else
130
+ Slip39Support::Protocol::CUSTOMIZATION_STRING_ORIG
131
+ end
132
+ end
133
+
134
+ private
135
+
136
+ def normalize_groups(member_threshold:, member_count:, groups:)
137
+ if groups
138
+ raise Errors::InvalidSlip39ConfigurationError, "pass either groups or member_threshold/member_count, not both" unless member_threshold.nil? && member_count.nil?
139
+
140
+ return groups.map do |group|
141
+ {
142
+ member_threshold: group.fetch(:member_threshold),
143
+ member_count: group.fetch(:member_count)
144
+ }
145
+ end
146
+ end
147
+
148
+ if member_threshold.nil? || member_count.nil?
149
+ raise Errors::InvalidSlip39ConfigurationError,
150
+ "single-group generation requires member_threshold and member_count"
151
+ end
152
+
153
+ [{ member_threshold: member_threshold, member_count: member_count }]
154
+ end
155
+
156
+ def normalize_master_secret(master_secret)
157
+ if master_secret.is_a?(Seed)
158
+ return master_secret.bytes
159
+ end
160
+
161
+ return octets_to_bytes(master_secret) if octet_array?(master_secret)
162
+ return hex_to_bytes(master_secret) if hex_string?(master_secret)
163
+ return master_secret if byte_string?(master_secret)
164
+
165
+ raise Errors::InvalidSlip39ConfigurationError, "master_secret must be bytes, hex, octets, or Seed"
166
+ end
167
+ end
168
+
169
+ def initialize(mnemonics)
170
+ @mnemonics = Array(mnemonics)
171
+ @decoder = Slip39Support::Decoder.new(
172
+ word_index: self.class.word_index,
173
+ customization_string: method(:customization_string)
174
+ )
175
+ @secret_recovery = Slip39Support::SecretRecovery.new
176
+ @cipher = Slip39Support::Cipher.new(
177
+ customization_string_orig: CUSTOMIZATION_STRING_ORIG
178
+ )
179
+ end
180
+
181
+ # Validates the share set and recovers the decrypted master secret.
182
+ #
183
+ # Shares may span one or more protocol groups, but callers still pass a
184
+ # single flat array. This method decodes the embedded group metadata and
185
+ # reconstructs the required group/member threshold structure internally.
186
+ #
187
+ # @param passphrase [String] optional SLIP-0039 passphrase
188
+ # @return [Seed]
189
+ # @raise [Errors::InvalidSlip39ShareError] if the share set is invalid or insufficient
190
+ def recover(passphrase: "")
191
+ raise Errors::InvalidSlip39ShareError, "the list of SLIP-0039 shares is empty" if @mnemonics.empty?
192
+
193
+ groups = @decoder.decode(@mnemonics)
194
+ encrypted_master_secret = @secret_recovery.recover_encrypted_master_secret(groups)
195
+ validate_passphrase!(passphrase)
196
+ Seed.import_from_bytes(
197
+ @cipher.decrypt(
198
+ encrypted_master_secret.fetch(:ciphertext),
199
+ passphrase.b,
200
+ encrypted_master_secret.fetch(:iteration_exponent),
201
+ encrypted_master_secret.fetch(:identifier),
202
+ encrypted_master_secret.fetch(:extendable)
203
+ )
204
+ )
205
+ end
206
+
207
+ private
208
+
209
+ def validate_passphrase!(passphrase)
210
+ return if passphrase.bytes.all? { |byte| byte.between?(32, 126) }
211
+
212
+ raise Errors::InvalidSlip39ShareError, "SLIP-0039 passphrase must contain only printable ASCII characters"
213
+ end
214
+
215
+ def customization_string(extendable)
216
+ extendable ? CUSTOMIZATION_STRING_EXTENDABLE : CUSTOMIZATION_STRING_ORIG
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkeletonKey
4
+ module Recovery
5
+ module Slip39Support
6
+ ##
7
+ # Integer and byte packing helpers used by SLIP-0039 parsing and recovery.
8
+ module BitPacking
9
+ module_function
10
+
11
+ def int_from_indices(indices, radix)
12
+ indices.reduce(0) { |value, index| (value * radix) + index }
13
+ end
14
+
15
+ def int_to_indices(value, length, radix_bits)
16
+ mask = (1 << radix_bits) - 1
17
+ (0...length).map do |offset|
18
+ shift = (length - offset - 1) * radix_bits
19
+ (value >> shift) & mask
20
+ end
21
+ end
22
+
23
+ def bits_to_bytes(bit_count)
24
+ (bit_count + 7) / 8
25
+ end
26
+
27
+ def bits_to_words(bit_count, radix_bits)
28
+ (bit_count + radix_bits - 1) / radix_bits
29
+ end
30
+
31
+ def xor_bytes(left, right)
32
+ left.bytes.zip(right.bytes).map { |a, b| a ^ b }.pack("C*")
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkeletonKey
4
+ module Recovery
5
+ module Slip39Support
6
+ ##
7
+ # RS1024 checksum implementation for SLIP-0039 mnemonics.
8
+ module Checksum
9
+ GENERATORS = [
10
+ 0xE0E040,
11
+ 0x1C1C080,
12
+ 0x3838100,
13
+ 0x7070200,
14
+ 0xE0E0009,
15
+ 0x1C0C2412,
16
+ 0x38086C24,
17
+ 0x3090FC48,
18
+ 0x21B1F890,
19
+ 0x3F3F120
20
+ ].freeze
21
+
22
+ module_function
23
+
24
+ def create(data, customization)
25
+ values = customization.bytes + data + [0] * Protocol::CHECKSUM_LENGTH_WORDS
26
+ checksum = polymod(values) ^ 1
27
+ Protocol::CHECKSUM_LENGTH_WORDS.times.map do |offset|
28
+ shift = 10 * (Protocol::CHECKSUM_LENGTH_WORDS - offset - 1)
29
+ (checksum >> shift) & 1023
30
+ end
31
+ end
32
+
33
+ def verify(data, customization)
34
+ polymod(customization.bytes + data) == 1
35
+ end
36
+
37
+ def polymod(values)
38
+ checksum = 1
39
+
40
+ values.each do |value|
41
+ top = checksum >> 20
42
+ checksum = ((checksum & 0xFFFFF) << 10) ^ value
43
+ 10.times do |index|
44
+ checksum ^= GENERATORS[index] if ((top >> index) & 1) == 1
45
+ end
46
+ end
47
+
48
+ checksum
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module SkeletonKey
6
+ module Recovery
7
+ module Slip39Support
8
+ ##
9
+ # Feistel cipher used to encrypt and decrypt SLIP-0039 master secrets.
10
+ class Cipher
11
+ include Protocol
12
+
13
+ def initialize(customization_string_orig:)
14
+ @customization_string_orig = customization_string_orig
15
+ end
16
+
17
+ # Encrypts a master secret into an encrypted master secret payload.
18
+ #
19
+ # @param master_secret [String] even-length byte string
20
+ # @param passphrase [String] printable ASCII passphrase bytes
21
+ # @param iteration_exponent [Integer]
22
+ # @param identifier [Integer]
23
+ # @param extendable [Boolean]
24
+ # @return [String]
25
+ def encrypt(master_secret, passphrase, iteration_exponent, identifier, extendable)
26
+ raise Errors::InvalidSlip39ConfigurationError, "SLIP-0039 master secret must have even byte length" if master_secret.bytesize.odd?
27
+
28
+ left = master_secret.byteslice(0, master_secret.bytesize / 2)
29
+ right = master_secret.byteslice(master_secret.bytesize / 2, master_secret.bytesize / 2)
30
+ salt = slip39_salt(identifier, extendable)
31
+
32
+ ROUND_COUNT.times do |round|
33
+ feistel = OpenSSL::PKCS5.pbkdf2_hmac(
34
+ [round].pack("C") + passphrase,
35
+ salt + right,
36
+ (BASE_ITERATION_COUNT << iteration_exponent) / ROUND_COUNT,
37
+ left.bytesize,
38
+ "sha256"
39
+ )
40
+ left, right = right, BitPacking.xor_bytes(left, feistel)
41
+ end
42
+
43
+ right + left
44
+ end
45
+
46
+ # Decrypts an encrypted master secret into the original master secret.
47
+ #
48
+ # @param encrypted_master_secret [String] even-length byte string
49
+ # @return [String]
50
+ def decrypt(encrypted_master_secret, passphrase, iteration_exponent, identifier, extendable)
51
+ raise Errors::InvalidSlip39ShareError, "SLIP-0039 master secret must have even byte length" if encrypted_master_secret.bytesize.odd?
52
+
53
+ left = encrypted_master_secret.byteslice(0, encrypted_master_secret.bytesize / 2)
54
+ right = encrypted_master_secret.byteslice(encrypted_master_secret.bytesize / 2, encrypted_master_secret.bytesize / 2)
55
+ salt = slip39_salt(identifier, extendable)
56
+
57
+ (ROUND_COUNT - 1).downto(0) do |round|
58
+ feistel = OpenSSL::PKCS5.pbkdf2_hmac(
59
+ [round].pack("C") + passphrase,
60
+ salt + right,
61
+ (BASE_ITERATION_COUNT << iteration_exponent) / ROUND_COUNT,
62
+ left.bytesize,
63
+ "sha256"
64
+ )
65
+ left, right = right, BitPacking.xor_bytes(left, feistel)
66
+ end
67
+
68
+ right + left
69
+ end
70
+
71
+ private
72
+
73
+ def slip39_salt(identifier, extendable)
74
+ return "".b if extendable
75
+
76
+ @customization_string_orig + [identifier].pack("n")
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module SkeletonKey
6
+ module Recovery
7
+ module Slip39Support
8
+ ##
9
+ # Decodes flat SLIP-0039 share strings into validated grouped share objects.
10
+ class Decoder
11
+ include Protocol
12
+
13
+ def initialize(word_index:, customization_string:)
14
+ @word_index = word_index
15
+ @customization_string = customization_string
16
+ end
17
+
18
+ def decode(mnemonics)
19
+ common_params = Set.new
20
+ groups = Hash.new { |hash, key| hash[key] = [] }
21
+
22
+ mnemonics.each do |mnemonic|
23
+ share = share_from_mnemonic(mnemonic)
24
+ common_params << share.common_parameters
25
+ groups[share.group_index] << share
26
+ end
27
+
28
+ if common_params.length != 1
29
+ raise Errors::InvalidSlip39ShareError,
30
+ "all SLIP-0039 shares must have matching identifier, group threshold, and group count"
31
+ end
32
+
33
+ groups.transform_values { |shares| dedupe_and_validate_group(shares) }
34
+ end
35
+
36
+ private
37
+
38
+ def dedupe_and_validate_group(shares)
39
+ unique = shares.uniq { |share| share.index }
40
+ if unique.length != shares.length
41
+ raise Errors::InvalidSlip39ShareError, "SLIP-0039 share indices must be unique within a group"
42
+ end
43
+
44
+ group_parameters = unique.first.group_parameters
45
+ unless unique.all? { |share| share.group_parameters == group_parameters }
46
+ raise Errors::InvalidSlip39ShareError, "SLIP-0039 group parameters do not match"
47
+ end
48
+
49
+ unique
50
+ end
51
+
52
+ def share_from_mnemonic(mnemonic)
53
+ indices = mnemonic_to_indices(mnemonic)
54
+ if indices.length < MIN_MNEMONIC_LENGTH_WORDS
55
+ raise Errors::InvalidSlip39ShareError,
56
+ "invalid SLIP-0039 mnemonic length: must be at least #{MIN_MNEMONIC_LENGTH_WORDS} words"
57
+ end
58
+
59
+ padding_length = (RADIX_BITS * (indices.length - METADATA_LENGTH_WORDS)) % 16
60
+ raise Errors::InvalidSlip39ShareError, "invalid SLIP-0039 mnemonic length" if padding_length > 8
61
+
62
+ id_exp_int = BitPacking.int_from_indices(indices.first(ID_EXP_LENGTH_WORDS), RADIX)
63
+ identifier = id_exp_int >> (EXTENDABLE_FLAG_LENGTH_BITS + ITERATION_EXP_LENGTH_BITS)
64
+ extendable = ((id_exp_int >> ITERATION_EXP_LENGTH_BITS) & 1) == 1
65
+ iteration_exponent = id_exp_int & ((1 << ITERATION_EXP_LENGTH_BITS) - 1)
66
+
67
+ unless Checksum.verify(indices, @customization_string.call(extendable))
68
+ raise Errors::InvalidSlip39ShareError, "invalid SLIP-0039 checksum"
69
+ end
70
+
71
+ share_params_int = BitPacking.int_from_indices(indices[ID_EXP_LENGTH_WORDS, 2], RADIX)
72
+ group_index, group_threshold_minus_one, group_count_minus_one, index, member_threshold_minus_one =
73
+ BitPacking.int_to_indices(share_params_int, 5, 4)
74
+
75
+ if group_count_minus_one < group_threshold_minus_one
76
+ raise Errors::InvalidSlip39ShareError, "SLIP-0039 group threshold cannot exceed group count"
77
+ end
78
+
79
+ value_indices = indices[(ID_EXP_LENGTH_WORDS + 2)...-CHECKSUM_LENGTH_WORDS]
80
+ value_byte_count = BitPacking.bits_to_bytes(RADIX_BITS * value_indices.length - padding_length)
81
+ value_int = BitPacking.int_from_indices(value_indices, RADIX)
82
+ value = [value_int.to_s(16).rjust(value_byte_count * 2, "0")].pack("H*")
83
+
84
+ Share.new(
85
+ identifier: identifier,
86
+ extendable: extendable,
87
+ iteration_exponent: iteration_exponent,
88
+ group_index: group_index,
89
+ group_threshold: group_threshold_minus_one + 1,
90
+ group_count: group_count_minus_one + 1,
91
+ index: index,
92
+ member_threshold: member_threshold_minus_one + 1,
93
+ value: value
94
+ )
95
+ rescue RangeError
96
+ raise Errors::InvalidSlip39ShareError, "invalid SLIP-0039 mnemonic padding"
97
+ end
98
+
99
+ def mnemonic_to_indices(mnemonic)
100
+ mnemonic.split.map do |word|
101
+ @word_index.fetch(word.downcase)
102
+ rescue KeyError
103
+ raise Errors::InvalidSlip39ShareError, "invalid SLIP-0039 mnemonic word: #{word}"
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkeletonKey
4
+ module Recovery
5
+ module Slip39Support
6
+ ##
7
+ # Encodes structured SLIP-0039 shares back into mnemonic word strings.
8
+ class Encoder
9
+ include Protocol
10
+
11
+ def initialize(wordlist:, customization_string:)
12
+ @wordlist = wordlist
13
+ @customization_string = customization_string
14
+ end
15
+
16
+ def mnemonic_for_share(share)
17
+ share_data = encode_id_exp(share) + encode_share_params(share) + encode_value(share.value)
18
+ checksum = Checksum.create(share_data, @customization_string.call(share.extendable))
19
+ (share_data + checksum).map { |index| @wordlist.fetch(index) }.join(" ")
20
+ end
21
+
22
+ private
23
+
24
+ def encode_id_exp(share)
25
+ id_exp_int = share.identifier << (ITERATION_EXP_LENGTH_BITS + EXTENDABLE_FLAG_LENGTH_BITS)
26
+ id_exp_int += (share.extendable ? 1 : 0) << ITERATION_EXP_LENGTH_BITS
27
+ id_exp_int += share.iteration_exponent
28
+ BitPacking.int_to_indices(id_exp_int, ID_EXP_LENGTH_WORDS, RADIX_BITS)
29
+ end
30
+
31
+ def encode_share_params(share)
32
+ value = share.group_index
33
+ value = (value << 4) + (share.group_threshold - 1)
34
+ value = (value << 4) + (share.group_count - 1)
35
+ value = (value << 4) + share.index
36
+ value = (value << 4) + (share.member_threshold - 1)
37
+ BitPacking.int_to_indices(value, 2, 10)
38
+ end
39
+
40
+ def encode_value(bytes)
41
+ word_count = BitPacking.bits_to_words(bytes.bytesize * 8, RADIX_BITS)
42
+ value_int = bytes.unpack1("H*").to_i(16)
43
+ BitPacking.int_to_indices(value_int, word_count, RADIX_BITS)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkeletonKey
4
+ module Recovery
5
+ module Slip39Support
6
+ ##
7
+ # Return object for generated SLIP-0039 shares.
8
+ class GeneratedSet
9
+ attr_reader :identifier, :extendable, :iteration_exponent, :group_threshold, :groups, :mnemonic_groups
10
+
11
+ def initialize(identifier:, extendable:, iteration_exponent:, group_threshold:, groups:, mnemonic_groups:)
12
+ @identifier = identifier
13
+ @extendable = extendable
14
+ @iteration_exponent = iteration_exponent
15
+ @group_threshold = group_threshold
16
+ @groups = groups
17
+ @mnemonic_groups = mnemonic_groups
18
+ end
19
+
20
+ # Returns every generated share in a single flat array.
21
+ #
22
+ # @return [Array<String>]
23
+ def all_shares
24
+ mnemonic_groups.flatten
25
+ end
26
+
27
+ # Returns a simple threshold-satisfying recovery subset by taking the
28
+ # first required members from the first required groups.
29
+ #
30
+ # @return [Array<String>]
31
+ def recovery_set
32
+ mnemonic_groups.first(group_threshold).each_with_index.flat_map do |shares, index|
33
+ shares.first(groups.fetch(index).fetch(:member_threshold))
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end