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,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module SkeletonKey
|
|
7
|
+
module Recovery
|
|
8
|
+
module Slip39Support
|
|
9
|
+
##
|
|
10
|
+
# Generates SLIP-0039 share mnemonics from a master secret.
|
|
11
|
+
class Generator
|
|
12
|
+
include Protocol
|
|
13
|
+
|
|
14
|
+
def initialize(wordlist:, customization_string:, cipher:, random_bytes: SecureRandom.method(:random_bytes))
|
|
15
|
+
@wordlist = wordlist
|
|
16
|
+
@customization_string = customization_string
|
|
17
|
+
@cipher = cipher
|
|
18
|
+
@random_bytes = random_bytes
|
|
19
|
+
@encoder = Encoder.new(wordlist: wordlist, customization_string: customization_string)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def generate(master_secret:, group_threshold:, groups:, passphrase:, extendable:, iteration_exponent:)
|
|
23
|
+
validate_passphrase!(passphrase)
|
|
24
|
+
validate_master_secret!(master_secret)
|
|
25
|
+
validate_groups!(group_threshold, groups)
|
|
26
|
+
|
|
27
|
+
identifier = random_identifier
|
|
28
|
+
ciphertext = @cipher.encrypt(master_secret, passphrase.b, iteration_exponent, identifier, extendable)
|
|
29
|
+
group_shares = split_secret(group_threshold, groups.length, ciphertext)
|
|
30
|
+
|
|
31
|
+
structured_groups = groups.map.with_index do |group_config, group_index|
|
|
32
|
+
member_threshold = group_config.fetch(:member_threshold)
|
|
33
|
+
member_count = group_config.fetch(:member_count)
|
|
34
|
+
member_shares = split_secret(member_threshold, member_count, group_shares.fetch(group_index).data)
|
|
35
|
+
|
|
36
|
+
share_objects = member_shares.map.with_index do |share, member_index|
|
|
37
|
+
Share.new(
|
|
38
|
+
identifier: identifier,
|
|
39
|
+
extendable: extendable,
|
|
40
|
+
iteration_exponent: iteration_exponent,
|
|
41
|
+
group_index: group_index,
|
|
42
|
+
group_threshold: group_threshold,
|
|
43
|
+
group_count: groups.length,
|
|
44
|
+
index: member_index,
|
|
45
|
+
member_threshold: member_threshold,
|
|
46
|
+
value: share.data
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
{
|
|
51
|
+
member_threshold: member_threshold,
|
|
52
|
+
member_count: member_count,
|
|
53
|
+
shares: share_objects.map { |share| @encoder.mnemonic_for_share(share) }
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
GeneratedSet.new(
|
|
58
|
+
identifier: identifier,
|
|
59
|
+
extendable: extendable,
|
|
60
|
+
iteration_exponent: iteration_exponent,
|
|
61
|
+
group_threshold: group_threshold,
|
|
62
|
+
groups: groups,
|
|
63
|
+
mnemonic_groups: structured_groups.map { |group| group.fetch(:shares) }
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def validate_passphrase!(passphrase)
|
|
70
|
+
return if passphrase.bytes.all? { |byte| byte.between?(32, 126) }
|
|
71
|
+
|
|
72
|
+
raise Errors::InvalidSlip39ConfigurationError,
|
|
73
|
+
"SLIP-0039 passphrase must contain only printable ASCII characters"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def validate_master_secret!(master_secret)
|
|
77
|
+
unless Constants::SLIP39_SECRET_LENGTHS.include?(master_secret.bytesize)
|
|
78
|
+
raise Errors::InvalidSlip39ConfigurationError,
|
|
79
|
+
"SLIP-0039 master secret must be #{Constants::SLIP39_SECRET_LENGTHS.inspect} bytes"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def validate_groups!(group_threshold, groups)
|
|
84
|
+
raise Errors::InvalidSlip39ConfigurationError, "SLIP-0039 groups must not be empty" if groups.empty?
|
|
85
|
+
raise Errors::InvalidSlip39ConfigurationError, "SLIP-0039 group threshold must be positive" if group_threshold < 1
|
|
86
|
+
if group_threshold > groups.length
|
|
87
|
+
raise Errors::InvalidSlip39ConfigurationError,
|
|
88
|
+
"SLIP-0039 group threshold must not exceed the number of groups"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
groups.each do |group|
|
|
92
|
+
member_threshold = group.fetch(:member_threshold)
|
|
93
|
+
member_count = group.fetch(:member_count)
|
|
94
|
+
|
|
95
|
+
if member_threshold < 1
|
|
96
|
+
raise Errors::InvalidSlip39ConfigurationError,
|
|
97
|
+
"SLIP-0039 member threshold must be positive"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
if member_threshold > member_count
|
|
101
|
+
raise Errors::InvalidSlip39ConfigurationError,
|
|
102
|
+
"SLIP-0039 member threshold must not exceed member count"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
if member_count > MAX_SHARE_COUNT
|
|
106
|
+
raise Errors::InvalidSlip39ConfigurationError,
|
|
107
|
+
"SLIP-0039 member count must not exceed #{MAX_SHARE_COUNT}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
if member_threshold == 1 && member_count > 1
|
|
111
|
+
raise Errors::InvalidSlip39ConfigurationError,
|
|
112
|
+
"SLIP-0039 does not allow member threshold 1 with multiple shares"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def random_identifier
|
|
118
|
+
identifier_bytes = @random_bytes.call(BitPacking.bits_to_bytes(ID_LENGTH_BITS))
|
|
119
|
+
identifier_bytes.unpack1("H*").to_i(16) & ((1 << ID_LENGTH_BITS) - 1)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def split_secret(threshold, share_count, shared_secret)
|
|
123
|
+
raise Errors::InvalidSlip39ConfigurationError, "SLIP-0039 threshold must be positive" if threshold < 1
|
|
124
|
+
if threshold > share_count
|
|
125
|
+
raise Errors::InvalidSlip39ConfigurationError,
|
|
126
|
+
"SLIP-0039 threshold must not exceed share count"
|
|
127
|
+
end
|
|
128
|
+
if share_count > MAX_SHARE_COUNT
|
|
129
|
+
raise Errors::InvalidSlip39ConfigurationError,
|
|
130
|
+
"SLIP-0039 share count must not exceed #{MAX_SHARE_COUNT}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
return (0...share_count).map { |index| RawShare.new(x: index, data: shared_secret) } if threshold == 1
|
|
134
|
+
|
|
135
|
+
random_share_count = threshold - 2
|
|
136
|
+
shares = (0...random_share_count).map do |index|
|
|
137
|
+
RawShare.new(x: index, data: @random_bytes.call(shared_secret.bytesize))
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
random_part = @random_bytes.call(shared_secret.bytesize - DIGEST_LENGTH_BYTES)
|
|
141
|
+
digest = OpenSSL::HMAC.digest("SHA256", random_part, shared_secret).byteslice(0, DIGEST_LENGTH_BYTES)
|
|
142
|
+
base_shares = shares + [
|
|
143
|
+
RawShare.new(x: DIGEST_INDEX, data: digest + random_part),
|
|
144
|
+
RawShare.new(x: SECRET_INDEX, data: shared_secret)
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
(random_share_count...share_count).each do |index|
|
|
148
|
+
shares << RawShare.new(x: index, data: Interpolation.interpolate(base_shares, index))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
shares
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkeletonKey
|
|
4
|
+
module Recovery
|
|
5
|
+
module Slip39Support
|
|
6
|
+
##
|
|
7
|
+
# GF(256) interpolation used to reconstruct SLIP-0039 secrets.
|
|
8
|
+
module Interpolation
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def exp_table
|
|
12
|
+
@exp_table ||= begin
|
|
13
|
+
exp = Array.new(255, 0)
|
|
14
|
+
log = Array.new(256, 0)
|
|
15
|
+
poly = 1
|
|
16
|
+
|
|
17
|
+
255.times do |i|
|
|
18
|
+
exp[i] = poly
|
|
19
|
+
log[poly] = i
|
|
20
|
+
poly = (poly << 1) ^ poly
|
|
21
|
+
poly ^= 0x11B if (poly & 0x100) != 0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@log_table = log.freeze
|
|
25
|
+
exp.freeze
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def log_table
|
|
30
|
+
exp_table
|
|
31
|
+
@log_table
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def interpolate(shares, x_coordinate)
|
|
35
|
+
x_values = shares.map(&:x)
|
|
36
|
+
raise Errors::InvalidSlip39ShareError, "SLIP-0039 share indices must be unique" unless x_values.uniq.length == x_values.length
|
|
37
|
+
|
|
38
|
+
share_lengths = shares.map { |share| share.data.bytesize }.uniq
|
|
39
|
+
raise Errors::InvalidSlip39ShareError, "all SLIP-0039 share values must have the same length" unless share_lengths.length == 1
|
|
40
|
+
|
|
41
|
+
if (direct_hit = shares.find { |share| share.x == x_coordinate })
|
|
42
|
+
return direct_hit.data
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
log_prod = shares.sum { |share| log_table[share.x ^ x_coordinate] }
|
|
46
|
+
result = "\x00".b * share_lengths.first
|
|
47
|
+
|
|
48
|
+
shares.each do |share|
|
|
49
|
+
log_basis_eval = (
|
|
50
|
+
log_prod -
|
|
51
|
+
log_table[share.x ^ x_coordinate] -
|
|
52
|
+
shares.sum { |other| other == share ? 0 : log_table[share.x ^ other.x] }
|
|
53
|
+
) % 255
|
|
54
|
+
|
|
55
|
+
result = result.bytes.zip(share.data.bytes).map do |intermediate, share_byte|
|
|
56
|
+
term =
|
|
57
|
+
if share_byte.zero?
|
|
58
|
+
0
|
|
59
|
+
else
|
|
60
|
+
exp_table[(log_table[share_byte] + log_basis_eval) % 255]
|
|
61
|
+
end
|
|
62
|
+
intermediate ^ term
|
|
63
|
+
end.pack("C*")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
result
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkeletonKey
|
|
4
|
+
module Recovery
|
|
5
|
+
module Slip39Support
|
|
6
|
+
##
|
|
7
|
+
# Protocol constants shared by the SLIP-0039 recovery helpers.
|
|
8
|
+
#
|
|
9
|
+
# These values are scoped to the SLIP-0039 implementation and should not
|
|
10
|
+
# leak into unrelated recovery or derivation code.
|
|
11
|
+
module Protocol
|
|
12
|
+
RADIX_BITS = 10
|
|
13
|
+
RADIX = 1 << RADIX_BITS
|
|
14
|
+
ID_LENGTH_BITS = 15
|
|
15
|
+
EXTENDABLE_FLAG_LENGTH_BITS = 1
|
|
16
|
+
ITERATION_EXP_LENGTH_BITS = 4
|
|
17
|
+
ID_EXP_LENGTH_WORDS = ((ID_LENGTH_BITS + EXTENDABLE_FLAG_LENGTH_BITS + ITERATION_EXP_LENGTH_BITS) + RADIX_BITS - 1) / RADIX_BITS
|
|
18
|
+
CHECKSUM_LENGTH_WORDS = 3
|
|
19
|
+
DIGEST_LENGTH_BYTES = 4
|
|
20
|
+
CUSTOMIZATION_STRING_ORIG = "shamir".b
|
|
21
|
+
CUSTOMIZATION_STRING_EXTENDABLE = "shamir_extendable".b
|
|
22
|
+
GROUP_PREFIX_LENGTH_WORDS = ID_EXP_LENGTH_WORDS + 1
|
|
23
|
+
METADATA_LENGTH_WORDS = ID_EXP_LENGTH_WORDS + 2 + CHECKSUM_LENGTH_WORDS
|
|
24
|
+
MIN_STRENGTH_BITS = 128
|
|
25
|
+
MIN_MNEMONIC_LENGTH_WORDS = METADATA_LENGTH_WORDS + ((MIN_STRENGTH_BITS + RADIX_BITS - 1) / RADIX_BITS)
|
|
26
|
+
BASE_ITERATION_COUNT = 10_000
|
|
27
|
+
ROUND_COUNT = 4
|
|
28
|
+
SECRET_INDEX = 255
|
|
29
|
+
DIGEST_INDEX = 254
|
|
30
|
+
MAX_SHARE_COUNT = 16
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module SkeletonKey
|
|
6
|
+
module Recovery
|
|
7
|
+
module Slip39Support
|
|
8
|
+
##
|
|
9
|
+
# Reconstructs SLIP-0039 group fragments and encrypted master secrets.
|
|
10
|
+
class SecretRecovery
|
|
11
|
+
include Protocol
|
|
12
|
+
|
|
13
|
+
def recover_encrypted_master_secret(groups)
|
|
14
|
+
raise Errors::InvalidSlip39ShareError, "the set of SLIP-0039 shares is empty" if groups.empty?
|
|
15
|
+
|
|
16
|
+
params = groups.values.first.first
|
|
17
|
+
if groups.length < params.group_threshold
|
|
18
|
+
raise Errors::InvalidSlip39ShareError,
|
|
19
|
+
"insufficient number of mnemonic groups: requires #{params.group_threshold}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
if groups.length != params.group_threshold
|
|
23
|
+
raise Errors::InvalidSlip39ShareError,
|
|
24
|
+
"wrong number of mnemonic groups: expected #{params.group_threshold}, got #{groups.length}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
group_shares = groups.map do |group_index, shares|
|
|
28
|
+
if shares.length != shares.first.member_threshold
|
|
29
|
+
raise Errors::InvalidSlip39ShareError,
|
|
30
|
+
"wrong number of mnemonics for group #{group_index}: expected #{shares.first.member_threshold}, got #{shares.length}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
RawShare.new(
|
|
34
|
+
x: group_index,
|
|
35
|
+
data: recover_secret(
|
|
36
|
+
shares.first.member_threshold,
|
|
37
|
+
shares.map { |share| RawShare.new(x: share.index, data: share.value) }
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
ciphertext = recover_secret(params.group_threshold, group_shares)
|
|
43
|
+
{
|
|
44
|
+
identifier: params.identifier,
|
|
45
|
+
extendable: params.extendable,
|
|
46
|
+
iteration_exponent: params.iteration_exponent,
|
|
47
|
+
ciphertext: ciphertext
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def recover_secret(threshold, shares)
|
|
54
|
+
return shares.first.data if threshold == 1
|
|
55
|
+
|
|
56
|
+
shared_secret = Interpolation.interpolate(shares, SECRET_INDEX)
|
|
57
|
+
digest_share = Interpolation.interpolate(shares, DIGEST_INDEX)
|
|
58
|
+
digest = digest_share.byteslice(0, DIGEST_LENGTH_BYTES)
|
|
59
|
+
random_part = digest_share.byteslice(DIGEST_LENGTH_BYTES, digest_share.bytesize - DIGEST_LENGTH_BYTES)
|
|
60
|
+
|
|
61
|
+
unless digest == create_digest(random_part, shared_secret)
|
|
62
|
+
raise Errors::InvalidSlip39ShareError, "invalid digest of the shared secret"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
shared_secret
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def create_digest(random_data, shared_secret)
|
|
69
|
+
OpenSSL::HMAC.digest("SHA256", random_data, shared_secret).byteslice(0, DIGEST_LENGTH_BYTES)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkeletonKey
|
|
4
|
+
module Recovery
|
|
5
|
+
module Slip39Support
|
|
6
|
+
# Minimal `(x, data)` tuple used during interpolation.
|
|
7
|
+
RawShare = Struct.new(:x, :data, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
##
|
|
10
|
+
# Parsed SLIP-0039 share metadata and value payload.
|
|
11
|
+
#
|
|
12
|
+
# Each share is self-describing. Recovery groups are reconstructed from
|
|
13
|
+
# these fields rather than from caller-supplied nested input.
|
|
14
|
+
Share = Struct.new(
|
|
15
|
+
:identifier,
|
|
16
|
+
:extendable,
|
|
17
|
+
:iteration_exponent,
|
|
18
|
+
:group_index,
|
|
19
|
+
:group_threshold,
|
|
20
|
+
:group_count,
|
|
21
|
+
:index,
|
|
22
|
+
:member_threshold,
|
|
23
|
+
:value,
|
|
24
|
+
keyword_init: true
|
|
25
|
+
) do
|
|
26
|
+
# Parameters that must match across every share in a recovery set.
|
|
27
|
+
#
|
|
28
|
+
# @return [Array<Integer, Boolean>]
|
|
29
|
+
def common_parameters
|
|
30
|
+
[identifier, extendable, iteration_exponent, group_threshold, group_count]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Parameters that must match within a single mnemonic group.
|
|
34
|
+
#
|
|
35
|
+
# @return [Array<Integer, Boolean>]
|
|
36
|
+
def group_parameters
|
|
37
|
+
[
|
|
38
|
+
identifier,
|
|
39
|
+
extendable,
|
|
40
|
+
iteration_exponent,
|
|
41
|
+
group_index,
|
|
42
|
+
group_threshold,
|
|
43
|
+
group_count,
|
|
44
|
+
member_threshold
|
|
45
|
+
]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|