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,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
|