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