codex32 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e303c201093a87d6b4c458ccd892a8f217c0fdef841e6d3e94360e1f607b22e2
4
- data.tar.gz: f2c8aa7553fb789a7e3b743cd8860a4e36c3e815574b8ac32dfa62fd7535f143
3
+ metadata.gz: 93df6005aec8470948875ab612155a1c3380bf79774b77f0d6d1a8059e9afa82
4
+ data.tar.gz: c4229ff6918b00717dcd3db5bf1a93f7464b9dabdd7ec7c6d9ebff71a6321d30
5
5
  SHA512:
6
- metadata.gz: b42a64b0bc476c46d8c2bf40515d3ada761721cffc5332624384492e3755c64ba0e06480debc96cee011ee498b9531f51fd8f2bbc01b3c6955f88750939c754a
7
- data.tar.gz: 9e1c7ad5976a72dc5dfcc043b979a71faf7f025b415d53030cd907591592c736a4e15a60f2f22f4ba962bbc8b01817c3916876071b4e5a9957a004d50f4b10d5
6
+ metadata.gz: 4dc15829c70fcc68c250063312c2d86d0deb30628286c2d72157dc07c44fab1beb2c124f116423bc3b99d79629f34171023e2e30eff7f80a29b7a66e310476df
7
+ data.tar.gz: 1661ba5642dc6c28394b7be554d59bcde96d6bc72103273adf5c04acce56d13234226a647912dbe5cb1d7826541c49f5d9c8ca12c3aa91e1b42b8dcd1b36a796
data/.rubocop.yml CHANGED
@@ -4,20 +4,16 @@ require:
4
4
  inherit_gem:
5
5
  prettier: rubocop.yml
6
6
  AllCops:
7
- TargetRubyVersion: 2.7
7
+ TargetRubyVersion: 3.0
8
8
  RSpec/ExampleLength:
9
9
  Enabled: false
10
10
  Metrics:
11
11
  Enabled: false
12
- RSpec/MultipleExpectations:
13
- Max: 5
14
12
  Style/IfUnlessModifier:
15
13
  Enabled: false
16
14
  Style/WhileUntilModifier:
17
15
  Enabled: false
18
16
  Naming/MethodParameterName:
19
17
  Enabled: false
20
- RSpec/FilePath:
21
- Enabled: false
22
18
  RSpec/MultipleExpectations:
23
19
  Enabled: false
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- ruby-3.2.0
1
+ ruby-4.0.0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ### Added
4
+
5
+ - `Codex32.split` splits a master seed into shares using `SecureRandom` and
6
+ verifies that the generated shares recover the seed. Creating shares from
7
+ anything but cryptographically secure randomness leaks the master seed, and
8
+ the library previously offered no way to do it.
9
+ - A "Security considerations" section in the README, covering the lack of
10
+ constant time operations and of memory wiping.
11
+
12
+ ### Removed
13
+
14
+ - `Codex32::Errors::SeparatorNotFound`. It was never raised: a string which
15
+ starts with `ms` but has no separator is reported as
16
+ `Codex32::Errors::InvalidHRP`, the same as the invalid test vectors of BIP-93
17
+ which mix a missing prefix and a missing separator.
18
+
19
+ ### Security
20
+
21
+ - `Codex32.generate_share` no longer returns an all-zero share when the requested
22
+ share index collides with the index of one of the given shares. The check was
23
+ comparing a bech32 character with its integer value, so it never matched and
24
+ the Lagrange interpolation silently collapsed to zero. It now raises
25
+ `Codex32::Errors::DuplicateShareIndex`.
26
+ - `Codex32.from` validates the master seed. A non hexadecimal, odd length or out
27
+ of range (128 to 512 bits) seed used to be silently converted into a different
28
+ value by `Array#pack`. It now raises `Codex32::Errors::InvalidSeed`.
29
+ - `Codex32.parse` treats the last `1` as the separator, as BIP-93 requires.
30
+ Appending `1` and arbitrary data to a valid codex32 string is no longer
31
+ accepted, and the length is validated against the data part rather than the
32
+ whole string.
33
+
34
+ ### Fixed
35
+
36
+ - `Codex32.generate_share` compares the number of shares with the threshold of
37
+ the shares themselves instead of the threshold of the first share, raises
38
+ `Codex32::Errors::PayloadLengthMismatch` for shares with different payload
39
+ lengths, and raises `Codex32::Errors::IdentifierMismatch` (previously a
40
+ `NameError` was raised because of a missing namespace).
41
+ - `Codex32::Share#initialize` accepts an upper case `S` as the secret index.
42
+ - `Codex32.convert_bits` raises `ArgumentError` for a value which does not fit
43
+ in the source bit width instead of returning `nil`, which the callers did not
44
+ check for.
45
+
3
46
  ## [0.1.0] - 2023-03-12
4
47
 
5
48
  - Initial release
data/Gemfile CHANGED
@@ -9,7 +9,7 @@ gem "rake", "~> 13.0"
9
9
 
10
10
  gem "rspec", "~> 3.0"
11
11
 
12
- gem 'prettier'
12
+ gem 'prettier', "4.0.3"
13
13
 
14
14
  gem 'rubocop-rake'
15
15
  gem 'rubocop-rspec'
data/README.md CHANGED
@@ -29,6 +29,16 @@ share = Codex32.parse("ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw")
29
29
  # Get share data.
30
30
  share.data
31
31
 
32
+ # Split a master seed into 5 shares, any 3 of which recover the seed.
33
+ # The shares are derived from random values obtained from SecureRandom.
34
+ shares = Codex32.split(
35
+ seed: "ffeeddccbbaa99887766554433221100",
36
+ id: "test",
37
+ threshold: 3,
38
+ share_indexes: %w[a c d e f]
39
+ )
40
+ shares.map(&:to_s)
41
+
32
42
  # Recovery master seed using shares.
33
43
  share1 = Codex32.parse("MS12NAMEA320ZYXWVUTSRQPNMLKJHGFEDCAXRPP870HKKQRM")
34
44
  share2 = Codex32.parse("MS12NAMECACDEFGHJKLMNPQRSTUVWXYZ023FTR2GDZMPY6PN")
@@ -41,4 +51,23 @@ secret.data
41
51
  share3 = Codex32.generate_share([share1, share2], "d")
42
52
  # Obtain bech32 string.
43
53
  share3.to_s
44
- ```
54
+ ```
55
+
56
+ ## Security considerations
57
+
58
+ Codex32 strings and master seeds are secret material. Keep the following
59
+ limitations in mind.
60
+
61
+ - **Use `Codex32.split` to create shares.** Shares built from anything but
62
+ cryptographically secure randomness leak the master seed. `Codex32.split`
63
+ uses `SecureRandom`.
64
+ - **Verify a backup before relying on it.** Recover the master seed from a
65
+ threshold of the shares and compare it with the original. `Codex32.split`
66
+ performs this check once for the shares it returns.
67
+ - **This library is not constant time.** Character lookups, the field
68
+ arithmetic and the validation errors all depend on the data being processed.
69
+ Run it on a machine you trust, preferably offline, and do not feed it input
70
+ from untrusted parties on a shared host.
71
+ - **Secrets are not erased from memory.** Ruby gives a library no reliable way
72
+ to wipe a string, so master seeds and shares stay in the heap until they are
73
+ garbage collected, and may be copied by the GC or swapped to disk.
data/codex32.gemspec CHANGED
@@ -9,14 +9,14 @@ Gem::Specification.new do |spec|
9
9
  spec.email = ["techmedia.think@gmail.com"]
10
10
 
11
11
  spec.summary = "Ruby implementation of codex32"
12
- spec.description = spec.description
12
+ spec.description = spec.summary
13
13
  spec.homepage = "https://github.com/azuchi/codex32rb"
14
14
  spec.license = "MIT"
15
- spec.required_ruby_version = ">= 2.7.0"
15
+ spec.required_ruby_version = ">= 3.0.0"
16
16
 
17
17
  spec.metadata["homepage_uri"] = spec.homepage
18
- spec.metadata["source_code_uri"] = spec.homepage
19
- spec.metadata["changelog_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = spec.homepage
19
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md"
20
20
 
21
21
  # Specify which files should be added to the gem when it is released.
22
22
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
@@ -22,9 +22,6 @@ module Codex32
22
22
  class InvalidHRP < Error
23
23
  end
24
24
 
25
- class SeparatorNotFound < Error
26
- end
27
-
28
25
  class InvalidIdentifier < Error
29
26
  end
30
27
 
@@ -45,5 +42,11 @@ module Codex32
45
42
 
46
43
  class InvalidCase < Error
47
44
  end
45
+
46
+ class InvalidSeed < Error
47
+ end
48
+
49
+ class PayloadLengthMismatch < Error
50
+ end
48
51
  end
49
52
  end
data/lib/codex32/share.rb CHANGED
@@ -22,7 +22,7 @@ module Codex32
22
22
  @index = index.downcase
23
23
  @threshold = threshold
24
24
 
25
- if threshold.zero? && index != SECRET_INDEX
25
+ if threshold.zero? && @index != SECRET_INDEX
26
26
  raise Codex32::Errors::InvalidShareIndex
27
27
  end
28
28
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Codex32
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/codex32.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  require_relative "codex32/version"
4
6
  require_relative "codex32/errors"
5
7
  require_relative "codex32/share"
@@ -22,6 +24,16 @@ module Codex32
22
24
 
23
25
  SECRET_INDEX = "s"
24
26
 
27
+ # Minimum/maximum length of the data part (the part after the separator).
28
+ # 45 = threshold(1) + id(4) + index(1) + payload(26) + checksum(13)
29
+ # 124 = 127 (maximum length of a codex32 string) - "ms1"
30
+ MIN_DATA_LENGTH = 45
31
+ MAX_DATA_LENGTH = 124
32
+
33
+ # Minimum/maximum byte length of a master seed (128 bits to 512 bits).
34
+ MIN_SEED_LENGTH = 16
35
+ MAX_SEED_LENGTH = 64
36
+
25
37
  module_function
26
38
 
27
39
  # Parse codex32 string.
@@ -31,10 +43,14 @@ module Codex32
31
43
  if codex32.downcase != codex32 && codex32.upcase != codex32
32
44
  raise Errors::InvalidCase
33
45
  end
34
- hrp, remain = codex32.downcase.split(SEPARATOR)
35
- raise Errors::InvalidHRP unless hrp.downcase == HRP
36
- raise Errors::InvalidLength if codex32.length < 48 || codex32.length > 127
37
- raise Errors::SeparatorNotFound if remain.nil?
46
+ lower = codex32.downcase
47
+ # The separator is the *last* occurrence of SEPARATOR, everything before it is the HRP.
48
+ pos = lower.rindex(SEPARATOR)
49
+ raise Errors::InvalidHRP unless pos && lower[0...pos] == HRP
50
+ remain = lower[(pos + 1)..]
51
+ if remain.length < MIN_DATA_LENGTH || remain.length > MAX_DATA_LENGTH
52
+ raise Errors::InvalidLength
53
+ end
38
54
  unless valid_checksum?(bech32_to_array(remain))
39
55
  raise Errors::InvalidChecksum
40
56
  end
@@ -60,7 +76,13 @@ module Codex32
60
76
  def from(seed:, id:, share_index:, threshold: 0)
61
77
  raise Errors::InvalidThreshold unless threshold.is_a?(Integer)
62
78
  raise Errors::InvalidIdentifier unless id.length == 4
63
- raise Errors::InvalidBech32Character if CHARSET.index(share_index).nil?
79
+ unless id.downcase.each_char.all? { |c| CHARSET.include?(c) }
80
+ raise Errors::InvalidBech32Character
81
+ end
82
+ if CHARSET.index(share_index.downcase).nil?
83
+ raise Errors::InvalidBech32Character
84
+ end
85
+ validate_seed!(seed)
64
86
  payload =
65
87
  array_to_bech32(
66
88
  convert_bits([seed].pack("H*").unpack("C*"), 8, 5, padding: true)
@@ -68,6 +90,87 @@ module Codex32
68
90
  Share.new(id, threshold, share_index, payload)
69
91
  end
70
92
 
93
+ # Split +seed+ into +share_indexes.length+ shares, any +threshold+ of which
94
+ # recover the seed. The shares are derived from random values obtained from
95
+ # +SecureRandom+.
96
+ # @param [String] seed Secret with hex format.
97
+ # @param [String] id Identifier.
98
+ # @param [Integer] threshold Threshold value. Must be 2 to 9.
99
+ # @param [Array(String)] share_indexes Indexes of the shares to be created.
100
+ # @return [Array(Codex32::Share)] Shares in the order of +share_indexes+.
101
+ def split(seed:, id:, threshold:, share_indexes:)
102
+ unless threshold.is_a?(Integer) && threshold > 1 && threshold < 10
103
+ raise Errors::InvalidThreshold
104
+ end
105
+ unless share_indexes.is_a?(Array)
106
+ raise ArgumentError, "share_indexes must be array."
107
+ end
108
+ indexes = share_indexes.map(&:downcase)
109
+ if indexes.any? { |i| CHARSET.index(i).nil? }
110
+ raise Errors::InvalidBech32Character
111
+ end
112
+ unless indexes.uniq.length == indexes.length
113
+ raise ArgumentError, "Share index duplicate."
114
+ end
115
+ raise Errors::InvalidShareIndex if indexes.include?(SECRET_INDEX)
116
+ raise Errors::InsufficientShares if indexes.length < threshold
117
+
118
+ secret =
119
+ from(seed: seed, id: id, share_index: SECRET_INDEX, threshold: threshold)
120
+ # Any +threshold+ shares define the polynomial, so the secret share and
121
+ # threshold - 1 random shares are enough to derive the remaining ones.
122
+ random_shares =
123
+ indexes
124
+ .take(threshold - 1)
125
+ .map do |i|
126
+ Share.new(id, threshold, i, random_payload(seed.length / 2))
127
+ end
128
+ known = [secret] + random_shares
129
+ shares =
130
+ indexes.map do |i|
131
+ random_shares.find { |s| s.index == i } || generate_share(known, i)
132
+ end
133
+ verify_shares!(shares.take(threshold), secret)
134
+ shares
135
+ end
136
+
137
+ # Generate a random payload which encodes +byte_length+ bytes.
138
+ # @param [Integer] byte_length Byte length of the payload.
139
+ # @return [String] bech32 string.
140
+ def random_payload(byte_length)
141
+ array_to_bech32(
142
+ convert_bits(
143
+ SecureRandom.random_bytes(byte_length).unpack("C*"),
144
+ 8,
145
+ 5,
146
+ padding: true
147
+ )
148
+ )
149
+ end
150
+
151
+ # Check that +shares+ actually recover +secret+.
152
+ # @param [Array(Codex32::Share)] shares Array of share.
153
+ # @param [Codex32::Share] secret Expected secret.
154
+ # @raise [Codex32::Errors::Error]
155
+ def verify_shares!(shares, secret)
156
+ return if generate_share(shares, SECRET_INDEX).to_s == secret.to_s
157
+ raise Errors::Error, "Failed to verify the generated shares."
158
+ end
159
+
160
+ # Validate that +seed+ is a hex string which encodes a master seed of
161
+ # 128 to 512 bits.
162
+ # @param [String] seed Secret with hex format.
163
+ # @raise [Codex32::Errors::InvalidSeed]
164
+ def validate_seed!(seed)
165
+ unless seed.is_a?(String) && seed.match?(/\A\h*\z/) && seed.length.even?
166
+ raise Errors::InvalidSeed, "seed must be an even-length hex string."
167
+ end
168
+ byte_length = seed.length / 2
169
+ return if byte_length.between?(MIN_SEED_LENGTH, MAX_SEED_LENGTH)
170
+ raise Errors::InvalidSeed,
171
+ "seed must be #{MIN_SEED_LENGTH} to #{MAX_SEED_LENGTH} bytes."
172
+ end
173
+
71
174
  # Convert bech32 string to array.
72
175
  # @param [String] bech32_str bech32 string.
73
176
  # @return [Array(Integer)] array of bech32 data.
@@ -85,7 +188,8 @@ module Codex32
85
188
  # @return [Codex32::Share] Recovery secret.
86
189
  def generate_share(shares, share_index)
87
190
  raise ArgumentError, "shares must be array." unless shares.is_a?(Array)
88
- raise IdentifierMismatch unless shares.map(&:id).uniq.length == 1
191
+ raise ArgumentError, "shares must not be empty." if shares.empty?
192
+ raise Errors::IdentifierMismatch unless shares.map(&:id).uniq.length == 1
89
193
  threshold = shares.map(&:threshold).uniq
90
194
  threshold.delete(0)
91
195
  raise Errors::ThresholdMismatch unless threshold.length == 1
@@ -95,8 +199,15 @@ module Codex32
95
199
  unless indices.length == shares.length
96
200
  raise ArgumentError, "Share index duplicate."
97
201
  end
98
- raise Errors::DuplicateShareIndex if indices.first == index
99
- raise Errors::InsufficientShares if shares.length < shares[0].threshold
202
+ # The interpolation collapses to all zeros if +index+ collides with any
203
+ # existing share index, so every index must be checked, not just the first.
204
+ if indices.any? { |i| CHARSET.index(i) == index }
205
+ raise Errors::DuplicateShareIndex
206
+ end
207
+ raise Errors::InsufficientShares if shares.length < threshold.first
208
+ unless shares.map { |s| s.payload.length }.uniq.length == 1
209
+ raise Errors::PayloadLengthMismatch
210
+ end
100
211
 
101
212
  data =
102
213
  shares.map do |share|
@@ -171,7 +282,9 @@ module Codex32
171
282
  maxv = (1 << to) - 1
172
283
  max_acc = (1 << (from + to - 1)) - 1
173
284
  data.each do |v|
174
- return nil if v.negative? || (v >> from) != 0
285
+ if v.negative? || (v >> from) != 0
286
+ raise ArgumentError, "#{v} does not fit in #{from} bits."
287
+ end
175
288
  acc = ((acc << from) | v) & max_acc
176
289
  bits += from
177
290
  while bits >= to
metadata CHANGED
@@ -1,16 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: codex32
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - azuchi
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2023-03-14 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies: []
13
- description: ''
12
+ description: Ruby implementation of codex32
14
13
  email:
15
14
  - techmedia.think@gmail.com
16
15
  executables: []
@@ -42,8 +41,7 @@ licenses:
42
41
  metadata:
43
42
  homepage_uri: https://github.com/azuchi/codex32rb
44
43
  source_code_uri: https://github.com/azuchi/codex32rb
45
- changelog_uri: https://github.com/azuchi/codex32rb
46
- post_install_message:
44
+ changelog_uri: https://github.com/azuchi/codex32rb/blob/master/CHANGELOG.md
47
45
  rdoc_options: []
48
46
  require_paths:
49
47
  - lib
@@ -51,15 +49,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
51
49
  requirements:
52
50
  - - ">="
53
51
  - !ruby/object:Gem::Version
54
- version: 2.7.0
52
+ version: 3.0.0
55
53
  required_rubygems_version: !ruby/object:Gem::Requirement
56
54
  requirements:
57
55
  - - ">="
58
56
  - !ruby/object:Gem::Version
59
57
  version: '0'
60
58
  requirements: []
61
- rubygems_version: 3.4.1
62
- signing_key:
59
+ rubygems_version: 4.0.3
63
60
  specification_version: 4
64
61
  summary: Ruby implementation of codex32
65
62
  test_files: []