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,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "digest"
5
+
6
+ module SkeletonKey
7
+ module Derivation
8
+ module BIP32
9
+ # Utility modules
10
+ include Utils::Hashing
11
+ include Utils::Encoding
12
+
13
+ # Constants
14
+ CURVE = OpenSSL::PKey::EC.new("secp256k1")
15
+ GROUP = CURVE.group
16
+ ORDER = GROUP.order
17
+
18
+ module_function
19
+
20
+ # Create master key from seed
21
+ #
22
+ # @param seed_bytes [String] seed byte string
23
+ # @return [Array(Integer, String)] [master_privkey_int, master_chain_code] Master key result
24
+ def master_from_seed(seed_bytes)
25
+ i = hmac_sha512("Bitcoin seed", seed_bytes)
26
+
27
+ il = i[0, 32]
28
+ ir = i[32, 32]
29
+ k_int = parse256(il)
30
+
31
+ raise Errors::InvalidMasterKeyError if k_int <= 0 || k_int >= ORDER
32
+
33
+ [k_int, ir]
34
+ end
35
+
36
+ # Convert private key integer to compressed public key bytes
37
+ #
38
+ # @param k_int [Integer] private key as integer
39
+ # @return [String] compressed public key (33 bytes)
40
+ def privkey_to_pubkey_compressed(k_int)
41
+ raise Errors::InvalidPrivateKeyError if k_int <= 0 || k_int >= ORDER
42
+
43
+ bn = OpenSSL::BN.new(k_int.to_s(16), 16)
44
+ point = GROUP.generator.mul(bn)
45
+ point_to_bytes_compressed(point)
46
+ end
47
+
48
+ def privkey_to_pubkey_uncompressed(k_int)
49
+ raise Errors::InvalidPrivateKeyError if k_int <= 0 || k_int >= ORDER
50
+
51
+ bn = OpenSSL::BN.new(k_int.to_s(16), 16)
52
+ point = GROUP.generator.mul(bn)
53
+ point.to_octet_string(:uncompressed)
54
+ end
55
+
56
+ # Convert OpenSSL::PKey::EC::Point to compressed public key bytes
57
+ #
58
+ # @param point [OpenSSL::PKey::EC::Point] EC point
59
+ # @return [String] compressed public key (33 bytes)
60
+ def point_to_bytes_compressed(point)
61
+ point.to_octet_string(:compressed)
62
+ end
63
+
64
+ # Convert compressed public key bytes to OpenSSL::PKey::EC::Point
65
+ #
66
+ # @param pub_compressed [String] compressed public key (33 bytes)
67
+ # @return [OpenSSL::PKey::EC::Point] EC point
68
+ def bytes_to_point(pub_compressed)
69
+ # OpenSSL can parse a full EC::Point from octet string if we use PKey
70
+ key = OpenSSL::PKey::EC.new("secp256k1")
71
+ key.public_key = OpenSSL::PKey::EC::Point.new(GROUP, OpenSSL::BN.new(pub_compressed, 2))
72
+ key.public_key
73
+ end
74
+
75
+ # Compute the parent fingerprint for BIP32 serialization.
76
+ #
77
+ # Per BIP32: identifier = hash160(compressed_pubkey).
78
+ # The fingerprint is the first 4 bytes of this identifier.
79
+ #
80
+ # @param pubkey_compressed [String] compressed secp256k1 pubkey (33 bytes)
81
+ # @return [String] 4-byte binary fingerprint
82
+ def fingerprint_from_pubkey(pubkey_compressed)
83
+ hash160(pubkey_compressed)[0, 4]
84
+ end
85
+
86
+ # Serializes an extended private key (xprv)
87
+ #
88
+ # @param k_int [Integer] private key as integer
89
+ # @param chain_code [String] 32-byte chain code
90
+ # @param depth [Integer] depth in the derivation path
91
+ # @param parent_fpr [String] 4-byte parent fingerprint
92
+ # @param child_num [Integer] child index
93
+ # @param version [Integer] version byte (optional)
94
+ # @return [String] base58check-encoded xprv
95
+ def serialize_xprv(k_int, chain_code, depth:, parent_fpr:, child_num:, version: nil)
96
+ priv_version = version || version_byte(network: @network, purpose: @purpose, private: true)
97
+
98
+ payload = [
99
+ priv_version,
100
+ [depth].pack("C"),
101
+ parent_fpr,
102
+ ser32(child_num),
103
+ chain_code,
104
+ "\x00", ser256(k_int)
105
+ ].map(&:b).join
106
+
107
+ base58check_encode(payload)
108
+ end
109
+
110
+ # Serializes an extended public key (xpub)
111
+ #
112
+ # @param pubkey_bytes [String] compressed public key (33 bytes)
113
+ # @param chain_code [String] 32-byte chain code
114
+ # @param depth [Integer] depth in the derivation path
115
+ # @param parent_fpr [String] 4-byte parent fingerprint
116
+ # @param child_num [Integer] child index
117
+ # @param version [Integer] version byte (optional)
118
+ # @return [String] base58check-encoded xpub
119
+ def serialize_xpub(pubkey_bytes, chain_code, depth:, parent_fpr:, child_num:, version: nil)
120
+ pub_version = version || version_byte(network: @network, purpose: @purpose)
121
+
122
+ payload = [
123
+ pub_version,
124
+ [depth].pack("C"),
125
+ parent_fpr,
126
+ ser32(child_num),
127
+ chain_code,
128
+ pubkey_bytes
129
+ ].map(&:b).join
130
+
131
+ base58check_encode(payload)
132
+ end
133
+
134
+ # CDKpriv (hardened and non-hardened)
135
+ #
136
+ # @param k_parent_int [Integer] parent private key as integer
137
+ # @param c_parent [String] 32-byte parent chain code
138
+ # @param index [Integer] child index
139
+ # @return [Array(Integer, String)] [child_privkey_int, child_chain_code] CKDpriv result
140
+ def ckd_priv(k_parent_int, c_parent, index)
141
+ data =
142
+ if index >= 0x8000_0000
143
+ # hardened: 0x00 || ser256(k_par) || ser32(i)
144
+ "\x00" + ser256(k_parent_int) + ser32(index)
145
+ else
146
+ # non-hardened: serP(point(k_par)) || ser32(i)
147
+ pub = privkey_to_pubkey_compressed(k_parent_int)
148
+ pub + ser32(index)
149
+ end
150
+
151
+ i = hmac_sha512(c_parent, data)
152
+ il = parse256(i[0, 32])
153
+ ir = i[32, 32]
154
+ raise Errors::DerivationValueOutOfRangeError if il >= ORDER
155
+
156
+ child = (il + k_parent_int) % ORDER
157
+ raise Errors::InvalidDerivedKeyError if child == 0
158
+ [child, ir]
159
+ end
160
+
161
+ # CKDpub (non-hardened only)
162
+ #
163
+ # @param pub_parent_bytes [String] compressed public key (33 bytes)
164
+ # @param c_parent [String] 32-byte chain code
165
+ # @param index [Integer] child index (non-hardened only)
166
+ # @return [Array(String, String)] [child_pubkey_bytes, child_chain_code] CKDpub result
167
+ def ckd_pub(pub_parent_bytes, c_parent, index)
168
+ raise Errors::HardenedPublicDerivationError if index >= 0x8000_0000
169
+
170
+ i = hmac_sha512(c_parent, pub_parent_bytes + ser32(index))
171
+ il = parse256(i[0, 32])
172
+ ir = i[32, 32]
173
+ raise Errors::DerivationValueOutOfRangeError if il >= ORDER
174
+
175
+ # child_point = G*IL + K_par
176
+ child_point = GROUP.generator.mul(OpenSSL::BN.new(il.to_s(16), 16)) +
177
+ bytes_to_point(pub_parent_bytes)
178
+ [point_to_bytes_compressed(child_point), ir]
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,112 @@
1
+ module SkeletonKey
2
+ module Derivation
3
+ # Represents a BIP32/44-style derivation path.
4
+ #
5
+ # Internally, each index is stored as a 32-bit integer.
6
+ # If the "hardened flag" (0x80000000) is set, the index is considered hardened.
7
+ # Logical values (purpose, coin_type, etc.) mask off this flag using INDEX_MASK (0x7fffffff).
8
+ #
9
+ # Example:
10
+ # path = Path.new("m/44'/0'/0'/0/5")
11
+ # path.purpose # => 44
12
+ # path.hardened?(0) # => true
13
+ # path.parts[0] # => 2147483692 (raw index with hardened bit set)
14
+ # path.to_s # => "m/44'/0'/0'/0/5"
15
+ #
16
+ class Path
17
+ # Set if an index is hardened (2^31)
18
+ HARDENED_FLAG = 0x80000000
19
+
20
+ # Mask for stripping hardened flag
21
+ INDEX_MASK = 0x7FFFFFFF
22
+
23
+ # Order of components in the path
24
+ ORDERED_KEYS = %i[purpose coin_type account_index change address_index].freeze
25
+
26
+ # The full path string (e.g. "m/44'/0'/0'/0/0")
27
+ # @return [String]
28
+ attr_reader :original_path
29
+
30
+ # Encoded path components as integers (with hardened flag set if applicable)
31
+ # @return [Array<Integer>]
32
+ attr_reader :parts
33
+
34
+ # @param path_str [String] BIP32 path string (e.g. "m/44'/0'/0'/0/0")
35
+ def initialize(path_str)
36
+ @original_path = path_str
37
+ @parts = parse(path_str)
38
+
39
+ # Parse and set individual components
40
+ @purpose, @coin_type, @account_index, @change, @address_index = parts
41
+ end
42
+
43
+ # Determines if a component at the given index is hardened.
44
+ #
45
+ # @param index [Integer] index in the path array (0-based)
46
+ # @return [Boolean] true if hardened
47
+ # @raise [Errors::IndexOutOfBoundsError] if index is out of bounds
48
+ def hardened?(idx)
49
+ raise Errors::IndexOutOfBoundsError if idx < 0 || idx >= parts.size
50
+
51
+ (parts[idx] & HARDENED_FLAG) != 0
52
+ end
53
+
54
+ # Converts the internal representation back to a canonical string
55
+ #
56
+ # @return [String] e.g. "m/44'/0'/0'/0/0"
57
+ def to_s
58
+ parts.each_with_index.reduce('m') do |acc, (part, idx)|
59
+ decoded = decode(part)
60
+ "#{acc}/#{hardened?(idx) ? "#{decoded}'" : decoded}"
61
+ end
62
+ end
63
+
64
+ ORDERED_KEYS.each do |attr|
65
+ # Accessor that returns the index without the hardened flag
66
+ #
67
+ # @return [Integer] the index without hardened flag
68
+ define_method(attr) do
69
+ instance_variable_get("@#{attr}") & INDEX_MASK
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ # Parse a BIP32 path string into an array of indices
76
+ # @param path_str [String] BIP32 path string (e.g. "m/44'/0'/0'/0/0")
77
+ # @return [Array<Integer>] Array of indices
78
+ def parse(path_str)
79
+ raise Errors::InvalidPathFormatError unless path_str.start_with?("m/")
80
+
81
+ path_str.split("/").drop(1).map { |part| encode_index(part) }
82
+ end
83
+
84
+ # Parses the path part and hardens it accordingly
85
+ #
86
+ # @param idx_str [String] index string (e.g. "44" or "0'")
87
+ # @return [Integer] the encoded index
88
+ def encode_index(idx_str)
89
+ index = idx_str.to_i
90
+ hardened = idx_str.end_with?("'")
91
+ encode(index, hardened: hardened)
92
+ end
93
+
94
+ # Adds the hardened flag to an index if specified
95
+ #
96
+ # @param idx [Integer] the index
97
+ # @param hardened [Boolean] whether to harden the index
98
+ # @return [Integer] the encoded index
99
+ def encode(idx, hardened: false)
100
+ hardened ? idx | HARDENED_FLAG : idx
101
+ end
102
+
103
+ # Masks out the hardened flag from an encoded index
104
+ #
105
+ # @param raw_idx [Integer] the encoded index
106
+ # @return [Integer] the index without hardened flag
107
+ def decode(raw_idx)
108
+ raw_idx & INDEX_MASK
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module SkeletonKey
6
+ module Derivation
7
+ ##
8
+ # Hardened-only SLIP-0010 derivation for Ed25519 keys.
9
+ #
10
+ # This module provides the shared derivation primitive used by Solana. It
11
+ # owns seed-to-master-key derivation, hardened child derivation, and
12
+ # deterministic Ed25519 keypair reconstruction from a 32-byte seed. It does
13
+ # not define any chain-specific path conventions or address formats.
14
+ module SLIP10
15
+ include Utils::Hashing
16
+ include Utils::Encoding
17
+
18
+ HARDENED_FLAG = 0x8000_0000
19
+ ED25519_PKCS8_PREFIX = "302e020100300506032b657004220420"
20
+ ED25519_PKCS8_PRIVATE_KEY_SIZE = 32
21
+ ED25519_SPKI_PUBLIC_KEY_SIZE = 32
22
+
23
+ module_function
24
+
25
+ # Derives the master key seed and chain code from a seed byte string.
26
+ #
27
+ # @param seed_bytes [String] canonical seed bytes
28
+ # @return [Array(String, String)] 32-byte key seed and 32-byte chain code
29
+ def master_from_seed(seed_bytes)
30
+ i = hmac_sha512("ed25519 seed", seed_bytes)
31
+ [i.byteslice(0, 32), i.byteslice(32, 32)]
32
+ end
33
+
34
+ # Derives a hardened SLIP-0010 child.
35
+ #
36
+ # Ed25519 SLIP-0010 does not support unhardened child derivation.
37
+ #
38
+ # @param parent_key [String] 32-byte parent key seed
39
+ # @param parent_chain_code [String] 32-byte parent chain code
40
+ # @param index [Integer] hardened child index
41
+ # @return [Array(String, String)] child key seed and child chain code
42
+ # @raise [Errors::UnsupportedDerivationIndexError] if a non-hardened index is requested
43
+ def ckd_priv(parent_key, parent_chain_code, index)
44
+ raise Errors::UnsupportedDerivationIndexError, "ed25519 SLIP-10 requires hardened indices" if index < HARDENED_FLAG
45
+
46
+ data = "\x00".b + parent_key + ser32(index)
47
+ i = hmac_sha512(parent_chain_code, data)
48
+ [i.byteslice(0, 32), i.byteslice(32, 32)]
49
+ end
50
+
51
+ # Reconstructs an Ed25519 keypair from a 32-byte seed.
52
+ #
53
+ # @param seed [String] 32-byte private seed
54
+ # @return [Array(String, String)] raw private key and raw public key
55
+ def keypair_from_seed(seed)
56
+ key = OpenSSL::PKey.read([ED25519_PKCS8_PREFIX + seed.unpack1("H*")].pack("H*"))
57
+ [raw_private_key(key), raw_public_key(key)]
58
+ end
59
+
60
+ # Extracts the 32-byte private seed from an Ed25519 key object.
61
+ #
62
+ # Some Ruby/OpenSSL builds expose `raw_private_key`; older builds only
63
+ # expose PKCS#8 DER serialization. The trailing 32 bytes of the PKCS#8
64
+ # structure are the original seed.
65
+ #
66
+ # @param key [OpenSSL::PKey::PKey] Ed25519 key object
67
+ # @return [String] 32-byte private seed
68
+ def raw_private_key(key)
69
+ return key.raw_private_key if key.respond_to?(:raw_private_key)
70
+
71
+ key.private_to_der.byteslice(-ED25519_PKCS8_PRIVATE_KEY_SIZE, ED25519_PKCS8_PRIVATE_KEY_SIZE)
72
+ end
73
+
74
+ # Extracts the 32-byte public key from an Ed25519 key object.
75
+ #
76
+ # Some Ruby/OpenSSL builds expose `raw_public_key`; older builds only
77
+ # expose SubjectPublicKeyInfo DER. The trailing 32 bytes of the SPKI
78
+ # structure are the Ed25519 public key bytes.
79
+ #
80
+ # @param key [OpenSSL::PKey::PKey] Ed25519 key object
81
+ # @return [String] 32-byte public key
82
+ def raw_public_key(key)
83
+ return key.raw_public_key if key.respond_to?(:raw_public_key)
84
+
85
+ key.public_to_der.byteslice(-ED25519_SPKI_PUBLIC_KEY_SIZE, ED25519_SPKI_PUBLIC_KEY_SIZE)
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,158 @@
1
+ module SkeletonKey
2
+ module Errors
3
+ ##
4
+ # Base class for typed SkeletonKey failures.
5
+ #
6
+ # Typed errors are part of the repository contract. They allow callers and
7
+ # tests to distinguish invalid recovery material, unsupported derivation
8
+ # paths, and codec failures without pattern-matching free-form strings.
9
+ class SkeletonKeyError < StandardError; end
10
+
11
+ # Raised when seed material cannot be normalized to an allowed byte length.
12
+ class InvalidSeedError < SkeletonKeyError
13
+ def initialize(msg = "must be a byte string of length #{SkeletonKey::Constants::SEED_LENGTHS.inspect}")
14
+ super(msg)
15
+ end
16
+ end
17
+
18
+ # Raised when entropy length is outside the supported range.
19
+ class InvalidEntropyLengthError < SkeletonKeyError
20
+ def initialize(msg = "must be a byte string of length #{SkeletonKey::Constants::ENTROPY_LENGTHS.inspect}")
21
+ super(msg)
22
+ end
23
+ end
24
+
25
+ # Raised when a requested entropy output format is unsupported.
26
+ class InvalidEntropyFormatError < SkeletonKeyError
27
+ def initialize(msg = "must be one of :bytes, :hex, or :base64")
28
+ super(msg)
29
+ end
30
+ end
31
+
32
+ # Raised when a BIP39 mnemonic fails word-count, wordlist, or checksum validation.
33
+ class InvalidMnemonicError < SkeletonKeyError
34
+ def initialize(msg = "must be a BIP39 mnemonic with #{SkeletonKey::Constants::MNEMONIC_WORD_COUNTS.inspect} words")
35
+ super(msg)
36
+ end
37
+ end
38
+
39
+ # Raised when BIP39 generation parameters are invalid.
40
+ class InvalidMnemonicConfigurationError < SkeletonKeyError
41
+ def initialize(msg = "invalid BIP39 generation parameters")
42
+ super(msg)
43
+ end
44
+ end
45
+
46
+ # Raised when a SLIP-0039 share or share set fails validation or threshold checks.
47
+ class InvalidSlip39ShareError < SkeletonKeyError
48
+ def initialize(msg = "invalid SLIP-0039 share set")
49
+ super(msg)
50
+ end
51
+ end
52
+
53
+ # Raised when SLIP-0039 generation parameters are invalid.
54
+ class InvalidSlip39ConfigurationError < SkeletonKeyError
55
+ def initialize(msg = "invalid SLIP-0039 generation parameters")
56
+ super(msg)
57
+ end
58
+ end
59
+
60
+ # Raised when a derivation path string cannot be parsed.
61
+ class InvalidPathFormatError < SkeletonKeyError
62
+ def initialize(msg = "invalid path format")
63
+ super(msg)
64
+ end
65
+ end
66
+
67
+ # Raised when a caller requests a path component that does not exist.
68
+ class IndexOutOfBoundsError < SkeletonKeyError
69
+ def initialize(msg = "index out of bounds")
70
+ super(msg)
71
+ end
72
+ end
73
+
74
+ # Raised when seed material cannot produce a valid master key.
75
+ class InvalidMasterKeyError < SkeletonKeyError
76
+ def initialize(msg = "invalid master key")
77
+ super(msg)
78
+ end
79
+ end
80
+
81
+ # Raised when a private key falls outside the valid curve range.
82
+ class InvalidPrivateKeyError < SkeletonKeyError
83
+ def initialize(msg = "invalid private key")
84
+ super(msg)
85
+ end
86
+ end
87
+
88
+ # Raised when HMAC output yields an out-of-range derivation scalar.
89
+ class DerivationValueOutOfRangeError < SkeletonKeyError
90
+ def initialize(msg = "derived IL out of range")
91
+ super(msg)
92
+ end
93
+ end
94
+
95
+ # Raised when child key derivation lands on an invalid point or scalar.
96
+ class InvalidDerivedKeyError < SkeletonKeyError
97
+ def initialize(msg = "derived invalid key")
98
+ super(msg)
99
+ end
100
+ end
101
+
102
+ # Raised when hardened derivation is requested from public key material.
103
+ class HardenedPublicDerivationError < SkeletonKeyError
104
+ def initialize(msg = "cannot derive hardened child from public key")
105
+ super(msg)
106
+ end
107
+ end
108
+
109
+ # Raised when a chain account class does not support the requested purpose.
110
+ class UnsupportedPurposeError < SkeletonKeyError
111
+ def initialize(purpose)
112
+ super("unsupported purpose: #{purpose}")
113
+ end
114
+ end
115
+
116
+ # Raised when a purpose/network combination is not defined by the chain layer.
117
+ class UnsupportedPurposeNetworkError < SkeletonKeyError
118
+ def initialize(purpose:, network:)
119
+ super("unsupported purpose/network combination: purpose=#{purpose}, network=#{network}")
120
+ end
121
+ end
122
+
123
+ # Raised when Base58 input contains invalid characters or structure.
124
+ class InvalidBase58Error < SkeletonKeyError
125
+ def initialize(msg = "invalid base58 string")
126
+ super(msg)
127
+ end
128
+ end
129
+
130
+ # Raised when a serialized checksum does not match the payload.
131
+ class InvalidChecksumError < SkeletonKeyError
132
+ def initialize(msg = "invalid checksum")
133
+ super(msg)
134
+ end
135
+ end
136
+
137
+ # Raised when Bech32 or Bech32m input fails format or checksum validation.
138
+ class InvalidBech32Error < SkeletonKeyError
139
+ def initialize(msg = "invalid bech32 string")
140
+ super(msg)
141
+ end
142
+ end
143
+
144
+ # Raised when 5-bit/8-bit conversion cannot be performed losslessly.
145
+ class InvalidConvertBitsError < SkeletonKeyError
146
+ def initialize(msg = "invalid convert_bits input")
147
+ super(msg)
148
+ end
149
+ end
150
+
151
+ # Raised when a derivation family rejects the requested child index.
152
+ class UnsupportedDerivationIndexError < SkeletonKeyError
153
+ def initialize(msg = "unsupported derivation index")
154
+ super(msg)
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,63 @@
1
+
2
+ module SkeletonKey
3
+ ##
4
+ # SkeletonKey::Keyring
5
+ #
6
+ # The {Keyring} class is the entry point for working with chain-agnostic key
7
+ # material. It encapsulates a master {Seed} and exposes convenience accessors
8
+ # for deriving accounts on supported blockchains (e.g. Bitcoin, Ethereum,
9
+ # Solana).
10
+ #
11
+ # A {Keyring} can be initialized with:
12
+ # - a raw byte string (32–64 bytes),
13
+ # - a hex-encoded seed string,
14
+ # - an array of octets (Array<Integer>),
15
+ # - another {Seed} instance,
16
+ # - or nothing (in which case a new random seed will be generated).
17
+ #
18
+ # Each plugin (e.g. {SkeletonKey::Chains::Bitcoin::Account}) receives the canonical
19
+ # {Seed} and is responsible for deriving its own keys according to the
20
+ # chain’s standard derivation path (BIP44, BIP84, SLIP-10, etc.).
21
+ #
22
+ # @example Generate a new keyring with a random seed
23
+ # keyring = SkeletonKey::Keyring.new
24
+ # account = keyring.bitcoin
25
+ #
26
+ # @example Initialize from an existing hex seed
27
+ # keyring = SkeletonKey::Keyring.new(seed: "2df1184bbb5ee0e4303d6db3b4013284")
28
+ # account = keyring.bitcoin(purpose: 84, coin_type: 0, account: 0)
29
+ #
30
+ # @see SkeletonKey::Seed
31
+ # @see SkeletonKey::Chains::Bitcoin::Account
32
+ class Keyring
33
+ # Initializes a new Keyring with an optional seed
34
+ #
35
+ # @param seed [String, Seed, Array<Integer>, nil] the seed to initialize the Keyring with (optional)
36
+ # @return [Keyring] the initialized Keyring
37
+ def initialize(seed: nil)
38
+ @seed = Seed.import(seed)
39
+ end
40
+
41
+ # Access the Bitcoin account derived from the seed
42
+ #
43
+ # @param kwargs [Hash] options to pass to Chains::Bitcoin::Account
44
+ # @return [Chains::Bitcoin::Account] the derived Bitcoin account
45
+ def bitcoin(**)
46
+ SkeletonKey::Chains::Bitcoin::Account.new(seed: seed.bytes, **)
47
+ end
48
+
49
+ def ethereum(**)
50
+ SkeletonKey::Chains::Ethereum::Account.new(seed: seed.bytes, **)
51
+ end
52
+
53
+ def solana(**)
54
+ SkeletonKey::Chains::Solana::Account.new(seed: seed.bytes, **)
55
+ end
56
+
57
+ private
58
+
59
+ # Reader for the seed
60
+ # @return [Seed] the seed
61
+ attr_reader :seed
62
+ end
63
+ end