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