skeleton_key 0.1.0 → 0.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2f7a0cbc245df26ea9b06a09ea21cc92336a5539853e235b33df9b49e65ab738
4
- data.tar.gz: 055e8323ee4457a6704d422f110d6a7140b4541e5be1a951591c7d78e74b7134
3
+ metadata.gz: fb63b4370d3a11f016dee71efc6df9516df5fcd436987dfeed4b07c348dd7277
4
+ data.tar.gz: e22d95c59f1b24ab1f8b2532bd55c9cb2407c555cd0e62324e8cd536945caec1
5
5
  SHA512:
6
- metadata.gz: 6baf89deae1dce76f2552a1133d3d42638e2604783f782c46f83357f49cad785742a859affdfdeaa393f721ffa9502fd413fe53937288d9deaf6a23d39a3f848
7
- data.tar.gz: cb22f9883391cbc02a3589101ff8830bcd8beed1ea78f83b4094cb9ab002310414e06571da5ada1b5151d0e730708bc3ba888eccc399ad816e0627d6a6c2e080
6
+ metadata.gz: 12b8bb96fd60252d45e46859ed505181f150c3c589cde6f8e5ae0120f610a3c204ae4893bd9534af6aafc2ddd3ac37e477d84d59235ce37de1f455e40263315c
7
+ data.tar.gz: bde4855fada160f437f17da16a04eaf6195a543be73fd10d1991ed2e831850f926a99060c23753d8ffaaa54633501327b603aab784eb63f27bb5aa953fd8eadc
data/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.1] - 2026-03-22
11
+
12
+ ### Added
13
+
14
+ - Added explicit BIP39 passphrase support to `SkeletonKey::Seed.import` and `SkeletonKey::Keyring.new` when recovering from mnemonic input.
15
+ - Added opt-in Solana `derivation_path: nil` support to match the default no-path behavior of `solana-keygen new`.
16
+ - Added Solana CLI-backed golden vectors and integration coverage for the no-path derivation mode.
17
+ - Added this changelog and included it in the packaged gem files.
18
+
19
+ ### Changed
20
+
21
+ - Documented that BIP39 passphrases are separate from mnemonic words.
22
+ - Documented the Solana default path mode and the opt-in Solana CLI compatibility mode.
23
+
24
+ [Unreleased]: https://github.com/sebscholl/skeleton-key/compare/v0.1.1...HEAD
25
+ [0.1.1]: https://github.com/sebscholl/skeleton-key/releases/tag/v0.1.1
data/README.md CHANGED
@@ -30,12 +30,13 @@
30
30
  ██████
31
31
  ```
32
32
 
33
- [![Gem Version](https://badge.fury.io/rb/skeleton_key.svg )](https://badge.fury.io/rb/skeleton_key )
33
+ [![Gem Version](https://badge.fury.io/rb/skeleton_key.svg?icon=si%3Arubygems&icon_color=%23f00000)](https://badge.fury.io/rb/skeleton_key)
34
+ [![Release](https://github.com/sebscholl/skeleton-key/actions/workflows/release.yml/badge.svg)](https://github.com/sebscholl/skeleton-key/actions/workflows/release.yml)
34
35
  [![License: MIT](https://img.shields.io/badge/License-MIT-black.svg )](LICENSE)
35
36
  [![Ruby](https://img.shields.io/badge/Ruby-%3E%3D%203.2-black )](https://www.ruby-lang.org )
36
37
  [![Safety-Critical](https://img.shields.io/badge/⚠%20Safety--Critical-black )](SECURITY.md)
37
38
 
38
- SkeletonKey is a Ruby library for deterministic wallet recovery and key derivation across Bitcoin, Ethereum, and Solana. It is designed around a strict boundary:
39
+ SkeletonKey is a zero-dependency Ruby library for deterministic wallet recovery and key derivation across Bitcoin, Ethereum, and Solana. It is designed around a strict boundary:
39
40
 
40
41
  - recovery formats in the recovery layer
41
42
  - shared seed and derivation primitives in the shared layer
@@ -91,6 +92,15 @@ Generate a random keyring:
91
92
  keyring = SkeletonKey::Keyring.new
92
93
  ```
93
94
 
95
+ Initialize from a mnemonic with a BIP39 passphrase:
96
+
97
+ ```ruby
98
+ keyring = SkeletonKey::Keyring.new(
99
+ seed: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
100
+ passphrase: "TREZOR"
101
+ )
102
+ ```
103
+
94
104
  Initialize from an existing hex seed:
95
105
 
96
106
  ```ruby
@@ -151,14 +161,18 @@ You can also pass a mnemonic directly to `Seed.import` or `Keyring.new`:
151
161
 
152
162
  ```ruby
153
163
  seed = SkeletonKey::Seed.import(
154
- "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
164
+ "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
165
+ passphrase: "TREZOR"
155
166
  )
156
167
 
157
168
  keyring = SkeletonKey::Keyring.new(
158
- seed: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
169
+ seed: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
170
+ passphrase: "TREZOR"
159
171
  )
160
172
  ```
161
173
 
174
+ The BIP39 passphrase is separate from the mnemonic. It is not an extra mnemonic word.
175
+
162
176
  What BIP39 validation currently enforces:
163
177
 
164
178
  - official BIP39 word counts only: `12`, `15`, `18`, `21`, `24`
@@ -436,6 +450,18 @@ Derive deeper hardened children:
436
450
  node = account.address(change: 0, index: 15)
437
451
  ```
438
452
 
453
+ Match the default no-path behavior of `solana-keygen new`:
454
+
455
+ ```ruby
456
+ account = keyring.solana(derivation_path: nil)
457
+ node = account.address
458
+
459
+ node[:path] # nil
460
+ node[:private_key] # first 32 bytes of the canonical seed, hex
461
+ node[:public_key] # 32-byte Ed25519 public key, hex
462
+ node[:address] # Base58-encoded Solana address
463
+ ```
464
+
439
465
  Solana in SkeletonKey is hardened-only. There is no supported unhardened child derivation path.
440
466
 
441
467
  ## Architecture
@@ -26,18 +26,33 @@ module SkeletonKey
26
26
 
27
27
  DEFAULT_PURPOSE = 44
28
28
  SOLANA_COIN = 501
29
- LEGACY_CHANGELESS_PURPOSE = 44
29
+ DEFAULT_DERIVATION_PATH = Object.new.freeze
30
+ UNSPECIFIED_CHANGE = Object.new.freeze
30
31
 
31
32
  # @param seed [String] canonical seed bytes
32
33
  # @param purpose [Integer] derivation purpose, fixed at 44
33
34
  # @param coin_type [Integer] SLIP-0044 coin type, fixed at 501
34
35
  # @param account_index [Integer] hardened account index
36
+ # @param derivation_path [nil] set to `nil` to match Solana CLI no-path mode
35
37
  # @raise [Errors::UnsupportedPurposeError] if non-Solana path parameters are requested
36
- def initialize(seed:, purpose: DEFAULT_PURPOSE, coin_type: SOLANA_COIN, account_index: 0)
38
+ def initialize(
39
+ seed:,
40
+ purpose: DEFAULT_PURPOSE,
41
+ coin_type: SOLANA_COIN,
42
+ account_index: 0,
43
+ derivation_path:
44
+ DEFAULT_DERIVATION_PATH
45
+ )
37
46
  @purpose = purpose
38
47
  @coin_type = coin_type
39
48
  @account_index = account_index
40
- @derived = derive_from_seed(seed, purpose: purpose, coin_type: coin_type, account: account_index)
49
+ @derived = derive(
50
+ seed,
51
+ purpose: purpose,
52
+ coin_type: coin_type,
53
+ account: account_index,
54
+ derivation_path: derivation_path
55
+ )
41
56
  end
42
57
 
43
58
  # Returns the hardened account prefix for future child derivation.
@@ -55,9 +70,12 @@ module SkeletonKey
55
70
  # @param change [Integer, nil] hardened child directly beneath the account
56
71
  # @param index [Integer, nil] hardened child beneath the change node
57
72
  # @return [Hash] path, private key, public key, address, and chain code
58
- def address(change: 0, index: nil)
73
+ def address(change: UNSPECIFIED_CHANGE, index: nil)
74
+ return no_path_address(change: change, index: index) if path.nil?
75
+
59
76
  key_seed, chain_code = derived[:key_seed], derived[:chain_code]
60
77
  current_path = path
78
+ change = 0 if change.equal?(UNSPECIFIED_CHANGE)
61
79
 
62
80
  unless change.nil?
63
81
  current_path = "#{current_path}/#{change}'"
@@ -73,15 +91,25 @@ module SkeletonKey
73
91
 
74
92
  {
75
93
  path: current_path,
76
- private_key: private_key.unpack1("H*"),
77
- public_key: public_key.unpack1("H*"),
94
+ chain_code: chain_code,
78
95
  address: to_address(public_key),
79
- chain_code: chain_code
96
+ public_key: public_key.unpack1("H*"),
97
+ private_key: private_key.unpack1("H*"),
80
98
  }
81
99
  end
82
100
 
83
101
  private
84
102
 
103
+ def derive(seed_bytes, purpose:, coin_type:, account:, derivation_path:)
104
+ return derive_without_path(seed_bytes, purpose: purpose, coin_type: coin_type, account: account) if derivation_path.nil?
105
+
106
+ unless derivation_path.equal?(DEFAULT_DERIVATION_PATH)
107
+ raise Errors::InvalidPathFormatError, "unsupported Solana derivation_path override"
108
+ end
109
+
110
+ derive_from_seed(seed_bytes, purpose: purpose, coin_type: coin_type, account: account)
111
+ end
112
+
85
113
  def derive_from_seed(seed_bytes, purpose:, coin_type:, account:)
86
114
  raise Errors::UnsupportedPurposeError.new(purpose) unless purpose == 44
87
115
  raise Errors::UnsupportedPurposeError.new(coin_type) unless coin_type == SOLANA_COIN
@@ -104,6 +132,39 @@ module SkeletonKey
104
132
  }
105
133
  end
106
134
 
135
+ def derive_without_path(seed_bytes, purpose:, coin_type:, account:)
136
+ unless purpose == DEFAULT_PURPOSE && coin_type == SOLANA_COIN && account.zero?
137
+ raise Errors::InvalidPathFormatError,
138
+ "solana derivation_path=nil is incompatible with purpose, coin_type, or account_index overrides"
139
+ end
140
+
141
+ if seed_bytes.bytesize < Constants::PRIVATE_KEY_LENGTH
142
+ raise Errors::InvalidSeedError, "solana derivation_path=nil requires at least 32 seed bytes"
143
+ end
144
+
145
+ {
146
+ path_prefix: nil,
147
+ key_seed: seed_bytes.byteslice(0, Constants::PRIVATE_KEY_LENGTH),
148
+ chain_code: nil
149
+ }
150
+ end
151
+
152
+ def no_path_address(change:, index:)
153
+ unless (change.equal?(UNSPECIFIED_CHANGE) || change.nil?) && index.nil?
154
+ raise Errors::InvalidPathFormatError, "solana derivation_path=nil does not support child derivation"
155
+ end
156
+
157
+ private_key, public_key = keypair_from_seed(derived[:key_seed])
158
+
159
+ {
160
+ path: nil,
161
+ private_key: private_key.unpack1("H*"),
162
+ public_key: public_key.unpack1("H*"),
163
+ address: to_address(public_key),
164
+ chain_code: nil
165
+ }
166
+ end
167
+
107
168
  # Encodes a child index as a hardened SLIP-0010 index.
108
169
  #
109
170
  # @param index [Integer]
@@ -33,9 +33,10 @@ module SkeletonKey
33
33
  # Initializes a new Keyring with an optional seed
34
34
  #
35
35
  # @param seed [String, Seed, Array<Integer>, nil] the seed to initialize the Keyring with (optional)
36
+ # @param passphrase [String] optional BIP39 passphrase when `seed` is a mnemonic
36
37
  # @return [Keyring] the initialized Keyring
37
- def initialize(seed: nil)
38
- @seed = Seed.import(seed)
38
+ def initialize(seed: nil, passphrase: "")
39
+ @seed = Seed.import(seed, passphrase: passphrase)
39
40
  end
40
41
 
41
42
  # Access the Bitcoin account derived from the seed
@@ -52,15 +52,16 @@ module SkeletonKey
52
52
  # - array of octets
53
53
  #
54
54
  # @param value [String, Seed, Recovery::Bip39, Array<Integer>, nil]
55
+ # @param passphrase [String] optional BIP39 passphrase for mnemonic input
55
56
  # @return [Seed]
56
57
  # @raise [Errors::InvalidSeedError] if the value cannot be normalized
57
- def import(value)
58
+ def import(value, passphrase: "")
58
59
  case
59
60
  when value.nil? then generate
60
61
  when value.is_a?(Seed) then import_from_seed(value)
61
- when value.is_a?(Recovery::Bip39) then import_from_mnemonic(value)
62
+ when value.is_a?(Recovery::Bip39) then import_from_mnemonic(value, passphrase: passphrase)
62
63
  when hex_string?(value) then import_from_hex(value)
63
- when mnemonic_string?(value) then import_from_mnemonic(value)
64
+ when mnemonic_string?(value) then import_from_mnemonic(value, passphrase: passphrase)
64
65
  when byte_string?(value) then import_from_bytes(value)
65
66
  when octet_array?(value) then import_from_octets(value)
66
67
  else
@@ -110,9 +111,10 @@ module SkeletonKey
110
111
  # Recovers a seed from a BIP39 mnemonic phrase.
111
112
  #
112
113
  # @param mnemonic [Recovery::Bip39, String]
114
+ # @param passphrase [String] optional BIP39 passphrase
113
115
  # @return [Seed]
114
- def import_from_mnemonic(mnemonic)
115
- Recovery::Bip39.import(mnemonic).seed
116
+ def import_from_mnemonic(mnemonic, passphrase: "")
117
+ Recovery::Bip39.import(mnemonic).seed(passphrase: passphrase)
116
118
  end
117
119
 
118
120
  private
@@ -4,5 +4,5 @@ module SkeletonKey
4
4
  # Current gem version.
5
5
  #
6
6
  # @return [String]
7
- VERSION = "0.1.0"
7
+ VERSION = "0.1.1"
8
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: skeleton_key
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Scholl
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '3.12'
18
+ version: '3.0'
19
19
  type: :development
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '3.12'
25
+ version: '3.0'
26
26
  description: SkeletonKey provides deterministic wallet recovery, seed normalization,
27
27
  and key derivation for Bitcoin, Ethereum, and Solana.
28
28
  email:
@@ -34,6 +34,7 @@ executables:
34
34
  extensions: []
35
35
  extra_rdoc_files: []
36
36
  files:
37
+ - CHANGELOG.md
37
38
  - README.md
38
39
  - bin/console
39
40
  - bin/lint