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,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkeletonKey
4
+ module Chains
5
+ module Ethereum
6
+ ##
7
+ # Ethereum-specific serialization and address helpers.
8
+ #
9
+ # This module owns the Ethereum-facing parts of account derivation:
10
+ # - extended key version bytes used by the current API surface
11
+ # - EIP-55 checksum address rendering
12
+ # - rendering of Ethereum child paths below an account node
13
+ #
14
+ # It intentionally does not own the underlying secp256k1 arithmetic.
15
+ module Support
16
+ extend Utils::Hashing
17
+ extend Utils::Encoding
18
+
19
+ XPUB_VERSION = 0x0488B21E
20
+ XPRV_VERSION = 0x0488ADE4
21
+
22
+ module_function
23
+
24
+ # Returns the four-byte version used for serialized extended private keys.
25
+ #
26
+ # @return [String] big-endian 4-byte version
27
+ def extended_private_version
28
+ [XPRV_VERSION].pack("N")
29
+ end
30
+
31
+ # Returns the four-byte version used for serialized extended public keys.
32
+ #
33
+ # @return [String] big-endian 4-byte version
34
+ def extended_public_version
35
+ [XPUB_VERSION].pack("N")
36
+ end
37
+
38
+ # Converts an uncompressed secp256k1 public key into a checksummed
39
+ # Ethereum address per EIP-55.
40
+ #
41
+ # @param pubkey_uncompressed [String] 65-byte uncompressed public key
42
+ # @return [String] checksummed `0x...` Ethereum address
43
+ def to_checksum_address(pubkey_uncompressed)
44
+ address_bytes = keccak256(pubkey_uncompressed.byteslice(1, 64)).byteslice(-20, 20)
45
+ address_hex = bytes_to_hex(address_bytes)
46
+ address_hash = bytes_to_hex(keccak256(address_hex))
47
+
48
+ checksummed = address_hex.chars.each_with_index.map do |char, idx|
49
+ next char if char.match?(/[0-9]/)
50
+
51
+ address_hash[idx].hex >= 8 ? char.upcase : char
52
+ end.join
53
+
54
+ "0x#{checksummed}"
55
+ end
56
+
57
+ # Derives a child node below the current account/root node and returns the
58
+ # Ethereum-facing representation of that child.
59
+ #
60
+ # @param change [Integer] branch index
61
+ # @param index [Integer] child index within the branch
62
+ # @param hardened_change [Boolean] whether the branch step is hardened
63
+ # @param hardened_index [Boolean] whether the address step is hardened
64
+ # @return [Hash] rendered path, private key, public keys, address, and chain code
65
+ def derive_address_from_node(change: 0, index: 0, hardened_change: false, hardened_index: false)
66
+ k_int, chain_code = derived[:k_int], derived[:c]
67
+ change_index = hardened_change ? change | Derivation::Path::HARDENED_FLAG : change
68
+ address_index = hardened_index ? index | Derivation::Path::HARDENED_FLAG : index
69
+
70
+ k_int, chain_code = ckd_priv(k_int, chain_code, change_index)
71
+ k_int, chain_code = ckd_priv(k_int, chain_code, address_index)
72
+
73
+ pubkey_compressed = privkey_to_pubkey_compressed(k_int)
74
+ pubkey_uncompressed = privkey_to_pubkey_uncompressed(k_int)
75
+ ethereum_public_key = pubkey_uncompressed.byteslice(1, 64)
76
+
77
+ {
78
+ path: build_derived_path(change: change, index: index, hardened_change: hardened_change, hardened_index: hardened_index),
79
+ private_key: ser256(k_int).unpack1("H*"),
80
+ public_key: ethereum_public_key.unpack1("H*"),
81
+ compressed_public_key: pubkey_compressed.unpack1("H*"),
82
+ address: to_checksum_address(pubkey_uncompressed),
83
+ chain_code: chain_code,
84
+ privkey: k_int,
85
+ pubkey: ethereum_public_key
86
+ }
87
+ end
88
+
89
+ # Derives and serializes a branch node directly beneath the current
90
+ # account/root prefix.
91
+ #
92
+ # @param change [Integer] branch index
93
+ # @param hardened_change [Boolean] whether the branch child is hardened
94
+ # @return [Hash] branch path with serialized xprv/xpub
95
+ def derive_branch_extended_keys(change: 0, hardened_change: false)
96
+ parent_key = derived[:k_int]
97
+ parent_chain_code = derived[:c]
98
+ parent_pubkey = privkey_to_pubkey_compressed(parent_key)
99
+ child_num = hardened_change ? change | Derivation::Path::HARDENED_FLAG : change
100
+ branch_key, branch_chain_code = ckd_priv(parent_key, parent_chain_code, child_num)
101
+ branch_pubkey = privkey_to_pubkey_compressed(branch_key)
102
+
103
+ {
104
+ path: branch_derived_path(change: change, hardened_change: hardened_change),
105
+ xprv: serialize_xprv(
106
+ branch_key,
107
+ branch_chain_code,
108
+ depth: legacy_root_branch? ? 1 : 4,
109
+ parent_fpr: fingerprint_from_pubkey(parent_pubkey),
110
+ child_num: child_num,
111
+ version: extended_private_version
112
+ ),
113
+ xpub: serialize_xpub(
114
+ branch_pubkey,
115
+ branch_chain_code,
116
+ depth: legacy_root_branch? ? 1 : 4,
117
+ parent_fpr: fingerprint_from_pubkey(parent_pubkey),
118
+ child_num: child_num,
119
+ version: extended_public_version
120
+ )
121
+ }
122
+ end
123
+
124
+ # Renders the derived child path in canonical BIP32 string form.
125
+ #
126
+ # @return [String]
127
+ def build_derived_path(change:, index:, hardened_change:, hardened_index:)
128
+ rendered_change = hardened_change ? "#{change}'" : change.to_s
129
+ rendered_index = hardened_index ? "#{index}'" : index.to_s
130
+ "#{path}/#{rendered_change}/#{rendered_index}"
131
+ end
132
+
133
+ # Renders the derived branch path in canonical BIP32 string form.
134
+ #
135
+ # @return [String]
136
+ def branch_derived_path(change:, hardened_change:)
137
+ rendered_change = hardened_change ? "#{change}'" : change.to_s
138
+ "#{path}/#{rendered_change}"
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkeletonKey
4
+ module Chains
5
+ module Solana
6
+ ##
7
+ # Solana account derivation using hardened SLIP-0010 Ed25519 paths.
8
+ #
9
+ # Solana in SkeletonKey is intentionally hardened-only. This class owns the
10
+ # Solana path convention and turns a shared seed into Ed25519 key material
11
+ # and Base58 addresses, while the underlying SLIP-0010 math remains in the
12
+ # derivation layer.
13
+ #
14
+ # @example Derive the default wallet-style Solana address
15
+ # account = SkeletonKey::Chains::Solana::Account.new(seed: seed.bytes)
16
+ # account.address(change: 0)
17
+ class Account
18
+ include Support
19
+ include Derivation::SLIP10
20
+
21
+ # @return [Integer] derivation purpose, fixed at 44
22
+ # @return [Integer] SLIP-0044 coin type, fixed at 501 for Solana
23
+ # @return [Integer] account index within the Solana namespace
24
+ # @return [Hash] cached account-level key seed and chain code
25
+ attr_reader :purpose, :coin_type, :account_index, :derived
26
+
27
+ DEFAULT_PURPOSE = 44
28
+ SOLANA_COIN = 501
29
+ LEGACY_CHANGELESS_PURPOSE = 44
30
+
31
+ # @param seed [String] canonical seed bytes
32
+ # @param purpose [Integer] derivation purpose, fixed at 44
33
+ # @param coin_type [Integer] SLIP-0044 coin type, fixed at 501
34
+ # @param account_index [Integer] hardened account index
35
+ # @raise [Errors::UnsupportedPurposeError] if non-Solana path parameters are requested
36
+ def initialize(seed:, purpose: DEFAULT_PURPOSE, coin_type: SOLANA_COIN, account_index: 0)
37
+ @purpose = purpose
38
+ @coin_type = coin_type
39
+ @account_index = account_index
40
+ @derived = derive_from_seed(seed, purpose: purpose, coin_type: coin_type, account: account_index)
41
+ end
42
+
43
+ # Returns the hardened account prefix for future child derivation.
44
+ #
45
+ # @return [String]
46
+ def path
47
+ derived[:path_prefix]
48
+ end
49
+
50
+ # Derives a hardened Solana child path below the account prefix.
51
+ #
52
+ # In practice this supports both the shorter account-root convention and
53
+ # the wallet-style `.../change'/index'` convention used by many tools.
54
+ #
55
+ # @param change [Integer, nil] hardened child directly beneath the account
56
+ # @param index [Integer, nil] hardened child beneath the change node
57
+ # @return [Hash] path, private key, public key, address, and chain code
58
+ def address(change: 0, index: nil)
59
+ key_seed, chain_code = derived[:key_seed], derived[:chain_code]
60
+ current_path = path
61
+
62
+ unless change.nil?
63
+ current_path = "#{current_path}/#{change}'"
64
+ key_seed, chain_code = ckd_priv(key_seed, chain_code, hardened(change))
65
+ end
66
+
67
+ unless index.nil?
68
+ current_path = "#{current_path}/#{index}'"
69
+ key_seed, chain_code = ckd_priv(key_seed, chain_code, hardened(index))
70
+ end
71
+
72
+ private_key, public_key = keypair_from_seed(key_seed)
73
+
74
+ {
75
+ path: current_path,
76
+ private_key: private_key.unpack1("H*"),
77
+ public_key: public_key.unpack1("H*"),
78
+ address: to_address(public_key),
79
+ chain_code: chain_code
80
+ }
81
+ end
82
+
83
+ private
84
+
85
+ def derive_from_seed(seed_bytes, purpose:, coin_type:, account:)
86
+ raise Errors::UnsupportedPurposeError.new(purpose) unless purpose == 44
87
+ raise Errors::UnsupportedPurposeError.new(coin_type) unless coin_type == SOLANA_COIN
88
+
89
+ key_seed, chain_code = master_from_seed(seed_bytes)
90
+ # Every Solana child step is hardened. The path prefix stored here is
91
+ # the account node from which wallet-style descendants are derived.
92
+ [
93
+ hardened(purpose),
94
+ hardened(coin_type),
95
+ hardened(account)
96
+ ].each do |index|
97
+ key_seed, chain_code = ckd_priv(key_seed, chain_code, index)
98
+ end
99
+
100
+ {
101
+ path_prefix: "m/#{purpose}'/#{coin_type}'/#{account}'",
102
+ key_seed: key_seed,
103
+ chain_code: chain_code
104
+ }
105
+ end
106
+
107
+ # Encodes a child index as a hardened SLIP-0010 index.
108
+ #
109
+ # @param index [Integer]
110
+ # @return [Integer]
111
+ def hardened(index)
112
+ index | Derivation::SLIP10::HARDENED_FLAG
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkeletonKey
4
+ module Chains
5
+ module Solana
6
+ ##
7
+ # Solana-specific address helpers.
8
+ #
9
+ # Solana addresses are the raw Ed25519 public key bytes encoded with the
10
+ # Bitcoin-style Base58 alphabet. No hashing or script construction is
11
+ # applied at the address layer.
12
+ module Support
13
+ extend Utils::Encoding
14
+
15
+ module_function
16
+
17
+ # Encodes a 32-byte Ed25519 public key as a Solana address.
18
+ #
19
+ # @param public_key_bytes [String] 32-byte Ed25519 public key
20
+ # @return [String] Base58-encoded Solana address
21
+ def to_address(public_key_bytes)
22
+ Codecs::Base58.encode(public_key_bytes)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkeletonKey
4
+ module Codecs
5
+ ##
6
+ # Raw Base58 codec using the Bitcoin alphabet.
7
+ #
8
+ # This module performs plain Base58 conversion only. It does not append or
9
+ # validate checksums; callers that need Base58Check should use
10
+ # {Base58Check}. The implementation preserves leading zero bytes as `"1"`
11
+ # characters to match Bitcoin-compatible tooling.
12
+ module Base58
13
+ ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".freeze
14
+ INDEXES = ALPHABET.chars.each_with_index.to_h.freeze
15
+
16
+ module_function
17
+
18
+ # Encodes raw bytes as a Base58 string.
19
+ #
20
+ # @param bytes [String] binary payload
21
+ # @return [String] Base58 string
22
+ def encode(bytes)
23
+ return "" if bytes.empty?
24
+
25
+ zero_prefixes = bytes.bytes.take_while(&:zero?).size
26
+ value = bytes.unpack1("H*").to_i(16)
27
+ encoded = +""
28
+
29
+ while value.positive?
30
+ value, remainder = value.divmod(58)
31
+ encoded.prepend(ALPHABET[remainder])
32
+ end
33
+
34
+ ("1" * zero_prefixes) + encoded
35
+ end
36
+
37
+ # Decodes a Base58 string into raw bytes.
38
+ #
39
+ # @param encoded [String] Base58 string
40
+ # @return [String] decoded binary payload
41
+ # @raise [Errors::InvalidBase58Error] if the string contains invalid characters
42
+ def decode(encoded)
43
+ raise Errors::InvalidBase58Error if encoded.nil?
44
+ raise Errors::InvalidBase58Error unless encoded.is_a?(String)
45
+ raise Errors::InvalidBase58Error if encoded.empty?
46
+
47
+ value = 0
48
+ encoded.each_char do |char|
49
+ digit = INDEXES[char]
50
+ raise Errors::InvalidBase58Error unless digit
51
+
52
+ value = (value * 58) + digit
53
+ end
54
+
55
+ hex = value.to_s(16)
56
+ hex = "0#{hex}" if hex.length.odd?
57
+ decoded = hex.empty? ? +"".b : [hex].pack("H*")
58
+
59
+ leading_zeroes = encoded.each_char.take_while { |char| char == "1" }.size
60
+ ("\x00".b * leading_zeroes) + decoded
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkeletonKey
4
+ module Codecs
5
+ ##
6
+ # Base58Check codec used by Bitcoin-facing serialization.
7
+ #
8
+ # Base58Check appends a four-byte double-SHA256 checksum to the payload
9
+ # before Base58 encoding. This module owns checksum construction and
10
+ # verification, while {Base58} handles the underlying alphabet conversion.
11
+ module Base58Check
12
+ extend Utils::Hashing
13
+
14
+ module_function
15
+
16
+ # Encodes a payload with a four-byte checksum.
17
+ #
18
+ # @param payload [String] raw bytes to encode
19
+ # @return [String] Base58Check string
20
+ def encode(payload)
21
+ Base58.encode(payload + checksum(payload))
22
+ end
23
+
24
+ # Decodes and verifies a Base58Check string.
25
+ #
26
+ # @param encoded [String] Base58Check string
27
+ # @return [String] decoded payload without checksum bytes
28
+ # @raise [Errors::InvalidBase58Error] if the string is malformed
29
+ # @raise [Errors::InvalidChecksumError] if the checksum does not match
30
+ def decode(encoded)
31
+ decoded = Base58.decode(encoded)
32
+ raise Errors::InvalidBase58Error if decoded.bytesize < 4
33
+
34
+ payload = decoded.byteslice(0, decoded.bytesize - 4)
35
+ observed_checksum = decoded.byteslice(-4, 4)
36
+ raise Errors::InvalidChecksumError unless checksum(payload) == observed_checksum
37
+
38
+ payload
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkeletonKey
4
+ module Codecs
5
+ ##
6
+ # Local Bech32 and Bech32m codec implementation.
7
+ #
8
+ # This module implements the exact checksum and charset rules needed for
9
+ # Bitcoin SegWit address encoding. It is intentionally generic at the codec
10
+ # boundary: HRP handling, checksum creation, and bit conversion live here,
11
+ # while script semantics remain in the Bitcoin layer.
12
+ module Bech32
13
+ CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l".freeze
14
+ CHARSET_INDEX = CHARSET.chars.each_with_index.to_h.freeze
15
+ GENERATOR = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3].freeze
16
+ SEPARATOR = "1"
17
+ MAX_LENGTH = 90
18
+
19
+ module Encoding
20
+ BECH32 = :bech32
21
+ BECH32M = :bech32m
22
+ end
23
+
24
+ module_function
25
+
26
+ # Encodes an HRP and 5-bit data words into a Bech32 or Bech32m string.
27
+ #
28
+ # @param hrp [String] human-readable prefix
29
+ # @param data [Array<Integer>] data words in the range 0..31
30
+ # @param encoding [Symbol] {Encoding::BECH32} or {Encoding::BECH32M}
31
+ # @return [String] encoded Bech32 string
32
+ def encode(hrp, data, encoding = Encoding::BECH32)
33
+ validate_hrp!(hrp)
34
+ validate_data!(data)
35
+
36
+ hrp = hrp.downcase
37
+ combined = data + create_checksum(hrp, data, encoding)
38
+ "#{hrp}#{SEPARATOR}#{combined.map { |value| CHARSET[value] }.join}"
39
+ end
40
+
41
+ # Decodes a Bech32/Bech32m string and validates its checksum.
42
+ #
43
+ # @param bech [String] encoded Bech32 string
44
+ # @return [Array(String, Array<Integer>, Symbol)] HRP, data words, encoding type
45
+ # @raise [Errors::InvalidBech32Error] if the string is malformed or the checksum fails
46
+ def decode(bech)
47
+ raise Errors::InvalidBech32Error unless bech.is_a?(String)
48
+ raise Errors::InvalidBech32Error if bech.empty? || bech.length > MAX_LENGTH
49
+
50
+ has_lower = bech.match?(/[a-z]/)
51
+ has_upper = bech.match?(/[A-Z]/)
52
+ raise Errors::InvalidBech32Error if has_lower && has_upper
53
+
54
+ normalized = bech.downcase
55
+ separator_index = normalized.rindex(SEPARATOR)
56
+ raise Errors::InvalidBech32Error if separator_index.nil?
57
+ raise Errors::InvalidBech32Error if separator_index < 1
58
+ raise Errors::InvalidBech32Error if separator_index + 7 > normalized.length
59
+
60
+ hrp = normalized[0...separator_index]
61
+ data_part = normalized[(separator_index + 1)..]
62
+ raise Errors::InvalidBech32Error if hrp.empty? || data_part.empty?
63
+
64
+ data = data_part.chars.map do |char|
65
+ value = CHARSET_INDEX[char]
66
+ raise Errors::InvalidBech32Error unless value
67
+
68
+ value
69
+ end
70
+
71
+ encoding = verify_checksum(hrp, data)
72
+ raise Errors::InvalidBech32Error unless encoding
73
+
74
+ [hrp, data[0...-6], encoding]
75
+ end
76
+
77
+ # Converts a stream of fixed-width integers into another width.
78
+ #
79
+ # This is the normalization step required when moving between raw bytes
80
+ # (8-bit values) and Bech32 witness program words (5-bit values).
81
+ #
82
+ # @param data [Array<Integer>] source values
83
+ # @param from_bits [Integer] bit width of each source value
84
+ # @param to_bits [Integer] desired bit width of each output value
85
+ # @param pad [Boolean] whether trailing zero padding is permitted
86
+ # @return [Array<Integer>]
87
+ # @raise [Errors::InvalidConvertBitsError] if the input cannot be losslessly converted
88
+ def convert_bits(data, from_bits, to_bits, pad)
89
+ acc = 0
90
+ bits = 0
91
+ result = []
92
+ maxv = (1 << to_bits) - 1
93
+ max_acc = (1 << (from_bits + to_bits - 1)) - 1
94
+
95
+ data.each do |value|
96
+ raise Errors::InvalidConvertBitsError if value.negative? || (value >> from_bits) != 0
97
+
98
+ acc = ((acc << from_bits) | value) & max_acc
99
+ bits += from_bits
100
+ while bits >= to_bits
101
+ bits -= to_bits
102
+ result << ((acc >> bits) & maxv)
103
+ end
104
+ end
105
+
106
+ if pad
107
+ result << ((acc << (to_bits - bits)) & maxv) if bits.positive?
108
+ elsif bits >= from_bits || ((acc << (to_bits - bits)) & maxv) != 0
109
+ raise Errors::InvalidConvertBitsError
110
+ end
111
+
112
+ result
113
+ end
114
+
115
+ # Creates the six checksum words for the given payload.
116
+ #
117
+ # @return [Array<Integer>]
118
+ def create_checksum(hrp, data, encoding)
119
+ constant = checksum_constant(encoding)
120
+ values = hrp_expand(hrp) + data
121
+ polymod = polymod(values + [0, 0, 0, 0, 0, 0]) ^ constant
122
+ 6.times.map { |idx| (polymod >> (5 * (5 - idx))) & 31 }
123
+ end
124
+
125
+ # Verifies the checksum and returns the detected Bech32 encoding family.
126
+ #
127
+ # @return [Symbol, nil]
128
+ def verify_checksum(hrp, data)
129
+ value = polymod(hrp_expand(hrp) + data)
130
+ return Encoding::BECH32 if value == 1
131
+ return Encoding::BECH32M if value == 0x2bc830a3
132
+
133
+ nil
134
+ end
135
+
136
+ # Returns the checksum constant for the selected encoding family.
137
+ #
138
+ # @return [Integer]
139
+ def checksum_constant(encoding)
140
+ case encoding
141
+ when Encoding::BECH32 then 1
142
+ when Encoding::BECH32M then 0x2bc830a3
143
+ else
144
+ raise Errors::InvalidBech32Error, "unsupported bech32 encoding"
145
+ end
146
+ end
147
+
148
+ # Computes the Bech32 polymod checksum accumulator.
149
+ #
150
+ # @return [Integer]
151
+ def polymod(values)
152
+ chk = 1
153
+ values.each do |value|
154
+ top = chk >> 25
155
+ chk = ((chk & 0x1ffffff) << 5) ^ value
156
+ 5.times do |idx|
157
+ chk ^= GENERATOR[idx] if ((top >> idx) & 1) == 1
158
+ end
159
+ end
160
+ chk
161
+ end
162
+
163
+ # Expands the HRP into the form required by the Bech32 checksum.
164
+ #
165
+ # @return [Array<Integer>]
166
+ def hrp_expand(hrp)
167
+ hrp.bytes.map { |byte| byte >> 5 } + [0] + hrp.bytes.map { |byte| byte & 31 }
168
+ end
169
+
170
+ def validate_hrp!(hrp)
171
+ raise Errors::InvalidBech32Error unless hrp.is_a?(String)
172
+ raise Errors::InvalidBech32Error if hrp.empty?
173
+ raise Errors::InvalidBech32Error unless hrp.bytes.all? { |byte| byte.between?(33, 126) }
174
+ end
175
+
176
+ def validate_data!(data)
177
+ raise Errors::InvalidBech32Error unless data.is_a?(Array)
178
+ raise Errors::InvalidBech32Error unless data.all? { |value| value.is_a?(Integer) && value.between?(0, 31) }
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkeletonKey
4
+ ##
5
+ # Core constants shared across the SkeletonKey library.
6
+ #
7
+ # These constants define valid seed and entropy lengths
8
+ # according to BIP-39 (mnemonics), SLIP-39 (Shamir mnemonics),
9
+ # and common cryptographic practice.
10
+ #
11
+ # @see https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki BIP-39
12
+ # @see https://github.com/satoshilabs/slips/blob/master/slip-0039.md SLIP-39
13
+ module Constants
14
+ ##
15
+ # Valid entropy lengths for mnemonic generation.
16
+ #
17
+ # BIP-39 specifies entropy sizes of 128–256 bits,
18
+ # in steps of 32 bits. These map to 12–24 words.
19
+ #
20
+ # @return [Array<Integer>] allowed entropy sizes in bytes
21
+ ENTROPY_LENGTHS = [16, 20, 24, 28, 32].freeze
22
+
23
+ ##
24
+ # Valid BIP39 mnemonic lengths in words.
25
+ #
26
+ # BIP39 supports 12, 15, 18, 21, and 24 word phrases.
27
+ #
28
+ # @return [Array<Integer>] allowed mnemonic word counts
29
+ MNEMONIC_WORD_COUNTS = [12, 15, 18, 21, 24].freeze
30
+
31
+ ##
32
+ # Valid master secret lengths for SLIP-39.
33
+ #
34
+ # SLIP-39 supports secrets of 128, 192, or 256 bits,
35
+ # which are represented as 16, 24, or 32 bytes.
36
+ #
37
+ # @return [Array<Integer>] allowed SLIP-39 master secret sizes
38
+ SLIP39_SECRET_LENGTHS = [16, 24, 32].freeze
39
+
40
+ ##
41
+ # Valid seed lengths for wallet derivation.
42
+ #
43
+ # - BIP-39 seeds are produced by PBKDF2 and are always 64 bytes (512 bits).
44
+ # - SLIP-39 master secrets are used directly and may be 16, 24, or 32 bytes.
45
+ #
46
+ # @return [Array<Integer>] allowed seed sizes in bytes
47
+ SEED_LENGTHS = (SLIP39_SECRET_LENGTHS + [64]).freeze
48
+
49
+ ##
50
+ # Standard length of a private key (secp256k1 or Ed25519).
51
+ #
52
+ # Both Bitcoin/Ethereum (secp256k1) and Solana (Ed25519)
53
+ # use 32-byte private keys.
54
+ #
55
+ # @return [Integer]
56
+ PRIVATE_KEY_LENGTH = 32
57
+
58
+ ##
59
+ # Standard length of a public key (compressed Ed25519 or secp256k1).
60
+ #
61
+ # - Ed25519 public keys are 32 bytes.
62
+ # - Compressed secp256k1 public keys are 33 bytes,
63
+ # but the underlying X coordinate is 32 bytes.
64
+ #
65
+ # @return [Integer]
66
+ PUBLIC_KEY_LENGTH = 32
67
+ end
68
+ end
@@ -0,0 +1,37 @@
1
+ module SkeletonKey
2
+ module Core
3
+ ##
4
+ # Cryptographically secure entropy generation.
5
+ #
6
+ # This module is the raw randomness boundary for SkeletonKey. It generates
7
+ # entropy suitable for seed creation and mnemonic generation, but it does
8
+ # not interpret that entropy as a chain-specific key or address.
9
+ module Entropy
10
+ # Generates cryptographically secure random entropy
11
+ #
12
+ # @param bytes [Integer] the number of random bytes to generate (default: 32)
13
+ # @param format [Symbol] the format of the output (:bytes, :octets, :hex)
14
+ # @return [String, Array<Integer>] the generated entropy in the specified format
15
+ # @raise [ArgumentError] if bytes is not a valid entropy length or format is invalid
16
+ def self.generate(bytes: 32, format: :bytes)
17
+ raise SkeletonKey::Errors::InvalidEntropyLengthError unless valid_entropy_lengths.include?(bytes)
18
+ raw = SecureRandom.random_bytes(bytes)
19
+
20
+ case format
21
+ when :bytes then raw.freeze # binary string (32 bytes)
22
+ when :octets then raw.bytes.freeze # array of byte values (32 integers)
23
+ when :hex then raw.unpack1("H*") # 64 hex chars for 32 bytes
24
+ else
25
+ raise SkeletonKey::Errors::InvalidEntropyFormatError
26
+ end
27
+ end
28
+
29
+ # Returns the valid byte sizes for entropy generation
30
+ #
31
+ # @return [Array<Integer>] valid byte sizes
32
+ def self.valid_entropy_lengths
33
+ SkeletonKey::Constants::ENTROPY_LENGTHS
34
+ end
35
+ end
36
+ end
37
+ end