tss 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,14 @@
1
+ require 'digest'
2
+ require 'base64'
3
+ require 'securerandom'
4
+ require 'binary_struct'
5
+ require 'dry-types'
6
+ require 'tss/tss'
7
+ require 'tss/blank'
8
+ require 'tss/version'
9
+ require 'tss/types'
10
+ require 'tss/errors'
11
+ require 'tss/util'
12
+ require 'tss/hasher'
13
+ require 'tss/splitter'
14
+ require 'tss/combiner'
@@ -0,0 +1,142 @@
1
+ # Extracted from activesupport gem.
2
+ # https://github.com/rails/rails/blob/52ce6ece8c8f74064bb64e0a0b1ddd83092718e1/activesupport/lib/active_support/core_ext/object/blank.rb
3
+ class Object
4
+ # An object is blank if it's false, empty, or a whitespace string.
5
+ # For example, +false+, '', ' ', +nil+, [], and {} are all blank.
6
+ #
7
+ # This simplifies
8
+ #
9
+ # !address || address.empty?
10
+ #
11
+ # to
12
+ #
13
+ # address.blank?
14
+ #
15
+ # @return [true, false]
16
+ def blank?
17
+ respond_to?(:empty?) ? !!empty? : !self
18
+ end
19
+
20
+ # An object is present if it's not blank.
21
+ #
22
+ # @return [true, false]
23
+ def present?
24
+ !blank?
25
+ end
26
+
27
+ # Returns the receiver if it's present otherwise returns +nil+.
28
+ # <tt>object.presence</tt> is equivalent to
29
+ #
30
+ # object.present? ? object : nil
31
+ #
32
+ # For example, something like
33
+ #
34
+ # state = params[:state] if params[:state].present?
35
+ # country = params[:country] if params[:country].present?
36
+ # region = state || country || 'US'
37
+ #
38
+ # becomes
39
+ #
40
+ # region = params[:state].presence || params[:country].presence || 'US'
41
+ #
42
+ # @return [Object]
43
+ def presence
44
+ self if present?
45
+ end
46
+ end
47
+
48
+ class NilClass
49
+ # +nil+ is blank:
50
+ #
51
+ # nil.blank? # => true
52
+ #
53
+ # @return [true]
54
+ def blank?
55
+ true
56
+ end
57
+ end
58
+
59
+ class FalseClass
60
+ # +false+ is blank:
61
+ #
62
+ # false.blank? # => true
63
+ #
64
+ # @return [true]
65
+ def blank?
66
+ true
67
+ end
68
+ end
69
+
70
+ class TrueClass
71
+ # +true+ is not blank:
72
+ #
73
+ # true.blank? # => false
74
+ #
75
+ # @return [false]
76
+ def blank?
77
+ false
78
+ end
79
+ end
80
+
81
+ class Array
82
+ # An array is blank if it's empty:
83
+ #
84
+ # [].blank? # => true
85
+ # [1,2,3].blank? # => false
86
+ #
87
+ # @return [true, false]
88
+ alias_method :blank?, :empty?
89
+ end
90
+
91
+ class Hash
92
+ # A hash is blank if it's empty:
93
+ #
94
+ # {}.blank? # => true
95
+ # { key: 'value' }.blank? # => false
96
+ #
97
+ # @return [true, false]
98
+ alias_method :blank?, :empty?
99
+ end
100
+
101
+ class String
102
+ BLANK_RE = /\A[[:space:]]*\z/
103
+
104
+ # A string is blank if it's empty or contains whitespaces only:
105
+ #
106
+ # ''.blank? # => true
107
+ # ' '.blank? # => true
108
+ # "\t\n\r".blank? # => true
109
+ # ' blah '.blank? # => false
110
+ #
111
+ # Unicode whitespace is supported:
112
+ #
113
+ # "\u00a0".blank? # => true
114
+ #
115
+ # @return [true, false]
116
+ def blank?
117
+ BLANK_RE === self
118
+ end
119
+ end
120
+
121
+ class Numeric #:nodoc:
122
+ # No number is blank:
123
+ #
124
+ # 1.blank? # => false
125
+ # 0.blank? # => false
126
+ #
127
+ # @return [false]
128
+ def blank?
129
+ false
130
+ end
131
+ end
132
+
133
+ class Time #:nodoc:
134
+ # No Time is blank:
135
+ #
136
+ # Time.now.blank? # => false
137
+ #
138
+ # @return [false]
139
+ def blank?
140
+ false
141
+ end
142
+ end
@@ -0,0 +1,107 @@
1
+ require 'thor'
2
+
3
+ # Command Line Interface (CLI)
4
+ # See also, `bin/tss` executable.
5
+ module TSS
6
+ class CLI < Thor
7
+ include Thor::Actions
8
+
9
+ method_option :threshold, :aliases => '-t', :banner => 'threshold', :type => :numeric, :desc => '# of shares, of total, required to reconstruct a secret'
10
+ method_option :num_shares, :aliases => '-n', :banner => 'num_shares', :type => :numeric, :desc => '# of shares total that will be generated'
11
+ method_option :identifier, :aliases => '-i', :banner => 'identifier', :type => :string, :desc => 'A unique identifier string, 0-16 Bytes, [a-zA-Z0-9.-_]'
12
+ method_option :hash_alg, :aliases => '-h', :banner => 'hash_alg', :type => :string, :desc => 'A hash type for verification, NONE, SHA1, SHA256'
13
+ method_option :format, :aliases => '-f', :banner => 'format', :type => :string, :default => 'human', :desc => 'Share output format, binary or human'
14
+ method_option :pad_blocksize, :aliases => '-p', :banner => 'pad_blocksize', :type => :numeric, :desc => 'Block size # secrets will be left-padded to, 0-255'
15
+ desc "split SECRET", "split a SECRET String into shares"
16
+ long_desc <<-LONGDESC
17
+ `tss split` will generate a set of Threshold Secret
18
+ Sharing shares from the SECRET provided. To protect
19
+ your secret from being saved in your shell history
20
+ you will be prompted for the single-line secret.
21
+
22
+ Optional Params:
23
+
24
+ num_shares :
25
+ The number of total shares that will be generated.
26
+
27
+ threshold :
28
+ The threshold is the number of shares required to
29
+ recreate a secret. This is always a subset of the total
30
+ shares.
31
+
32
+ identifier :
33
+ A unique identifier string that will be attached
34
+ to each share. It can be 0-16 Bytes long and use the
35
+ characters [a-zA-Z0-9.-_]
36
+
37
+ hash_alg :
38
+ One of NONE, SHA1, SHA256. The algorithm to use for a one-way hash of the secret that will be split along with the secret.
39
+
40
+ pad_blocksize :
41
+ An Integer, 0-255, that represents a multiple to which the secret will be padded. For example if pad_blocksize is set to 8, the secret 'abc' would be left-padded to '00000abc' (the padding char is not zero, that is just for illustration).
42
+
43
+ format :
44
+ Whether to output the shares as a binary octet string (RTSS), or the same encoded as more human friendly Base 64 text with some metadata prefixed.
45
+
46
+ Example using all options:
47
+
48
+ $ tss split -t 3 -n 6 -i abc123 -h SHA256 -p 8 -f human
49
+
50
+ Enter your secret:
51
+
52
+ secret > my secret
53
+
54
+ tss~v1~abc123~3~YWJjMTIzAAAAAAAAAAAAAAIDADEBQ-AQG3PuU4oT4qHOh2oJmu-vQwGE6O5hsGRBNtdAYauTIi7VoIdi5imWSrswDdRy
55
+ tss~v1~abc123~3~YWJjMTIzAAAAAAAAAAAAAAIDADECM0OK5TSamH3nubH3FJ2EGZ4Yux4eQC-mvcYY85oOe6ae3kpvVXjuRUDU1m6sX20X
56
+ tss~v1~abc123~3~YWJjMTIzAAAAAAAAAAAAAAIDADEDb7yF4Vhr1JqNe2Nc8IXo98hmKAxsqC3c_Mn3r3t60NxQMC22ate51StDOM-BImch
57
+ tss~v1~abc123~3~YWJjMTIzAAAAAAAAAAAAAAIDADEEIXU0FajldnRtEQMLK-ZYMO2MRa0NmkBFfNAOx7olbgXLkVbP9txXMDsdokblVwke
58
+ tss~v1~abc123~3~YWJjMTIzAAAAAAAAAAAAAAIDADEFfYo7EcQUOpMH09Ggz_403rvy1r9_ckI_Pd_hm1tRxX8FfzEWyXMAoFCKTOfIKgMo
59
+ tss~v1~abc123~3~YWJjMTIzAAAAAAAAAAAAAAIDADEGDSmh74Ng8WTziMGZXAm5XcpFLqDl2oP4MH24XhYf33IIg1WsPIyMAznI0DJUeLpN
60
+ LONGDESC
61
+ def split
62
+ args = {}
63
+
64
+ say('Enter your secret:')
65
+ args[:secret] = ask('secret > ')
66
+ args[:threshold] = options[:threshold] if options[:threshold]
67
+ args[:num_shares] = options[:num_shares] if options[:num_shares]
68
+ args[:identifier] = options[:identifier] if options[:identifier]
69
+ args[:hash_alg] = options[:hash_alg] if options[:hash_alg]
70
+ args[:pad_blocksize] = options[:pad_blocksize] if options[:pad_blocksize]
71
+ args[:format] = options[:format] if options[:format]
72
+
73
+ begin
74
+ shares = TSS.split(args)
75
+ shares.each {|s| say(s) }
76
+ rescue => e
77
+ say("TSS ERROR : " + e.message)
78
+ end
79
+ end
80
+
81
+ desc "combine SHARES", "Enter min threshold # of SHARES, one at a time, to reconstruct a split secret"
82
+ def combine
83
+ shares = []
84
+ last_ans = nil
85
+
86
+ say('Enter shares, one per line, blank line or dot (.) to finish:')
87
+ until last_ans == '.' || last_ans == ''
88
+ last_ans = ask('share> ')
89
+ shares << last_ans unless last_ans.blank? || last_ans == '.'
90
+ end
91
+
92
+ begin
93
+ sec = TSS.combine(shares: shares)
94
+
95
+ say('')
96
+ say('Secret Recovered and Verified!')
97
+ say('')
98
+ say("identifier : " + sec[:identifier]) if sec[:identifier].present?
99
+ say("threshold : " + sec[:threshold].to_s) if sec[:threshold].present?
100
+ say("processing time (ms) : " + sec[:processing_time_ms].to_s) if sec[:processing_time_ms].present?
101
+ say("secret :\n" + '*'*50 + "\n" + sec[:secret] + "\n" + '*'*50 + "\n") if sec[:secret].present?
102
+ rescue => e
103
+ say("TSS ERROR : " + e.message)
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,296 @@
1
+ module TSS
2
+ class Combiner < Dry::Types::Struct
3
+ include Util
4
+
5
+ # dry-types
6
+ constructor_type(:schema)
7
+
8
+ attribute :shares, Types::Strict::Array
9
+ .constrained(min_size: 1)
10
+ .constrained(max_size: 255)
11
+ .member(Types::Strict::String)
12
+
13
+ attribute :select_by, Types::Strict::String
14
+ .enum('first', 'sample', 'combinations')
15
+ .default('first')
16
+
17
+ # The reconstruction, or combining, operation reconstructs the secret from a
18
+ # set of valid shares where the number of shares is >= the threshold when the
19
+ # secret was initially split. All arguments are provided in a single Hash:
20
+ #
21
+ # `shares` : The shares parameter is an Array of String shares.
22
+ #
23
+ # If the number of shares provided as input to the secret
24
+ # reconstruction operation is greater than the threshold M, then M
25
+ # of those shares are selected for use in the operation. The method
26
+ # used to select the shares can be chosen with the `select_by:` argument
27
+ # which takes the following values:
28
+ #
29
+ # `first` : If X shares are required by the threshold and more than X
30
+ # shares are provided, then the first X shares in the Array of shares provided
31
+ # will be used. All others will be discarded and the operation will fail if
32
+ # those selected shares cannot recreate the secret.
33
+ #
34
+ # `sample` : If X shares are required by the threshold and more than X
35
+ # shares are provided, then X shares will be randomly selected from the Array
36
+ # of shares provided. All others will be discarded and the operation will
37
+ # fail if those selected shares cannot recreate the secret.
38
+ #
39
+ # `combinations` : If X shares are required, and more than X shares are
40
+ # provided, then all possible combinations of the threshold number of shares
41
+ # will be tried to see if the secret can be recreated.
42
+ # This flexibility comes with a cost. All combinations of `threshold` shares
43
+ # must be generated. Due to the math associated with combinations it is possible
44
+ # that the system would try to generate a number of combinations that could never
45
+ # be generated or processed in many times the life of the Universe. This option
46
+ # can only be used if the possible combinations for the number of shares and the
47
+ # threshold needed to reconstruct a secret result in a number of combinations
48
+ # that is small enough to have a chance at being processed. If the number
49
+ # of combinations will be too large then the an Exception will be raised before
50
+ # processing has started.
51
+ #
52
+ # If the combine operation does not result in a secret being successfully
53
+ # extracted, then a `TSS::Error` exception will be raised.
54
+ #
55
+ #
56
+ # How it works:
57
+ #
58
+ # To reconstruct a secret from a set of shares, the following
59
+ # procedure, or any equivalent method, is used:
60
+ #
61
+ # If the number of shares provided as input to the secret
62
+ # reconstruction operation is greater than the threshold M, then M
63
+ # of those shares are selected for use in the operation. The method
64
+ # used to select the shares can be arbitrary.
65
+ #
66
+ # If the shares are not equal length, then the input is
67
+ # inconsistent. An error should be reported, and processing must
68
+ # halt.
69
+ #
70
+ # The output string is initialized to the empty (zero-length) octet
71
+ # string.
72
+ #
73
+ # The octet array U is formed by setting U[i] equal to the first
74
+ # octet of the ith share. (Note that the ordering of the shares is
75
+ # arbitrary, but must be consistent throughout this algorithm.)
76
+ #
77
+ # The initial octet is stripped from each share.
78
+ #
79
+ # If any two elements of the array U have the same value, then an
80
+ # error condition has occurred; this fact should be reported, then
81
+ # the procedure must halt.
82
+ #
83
+ # For each octet of the shares, the following steps are performed.
84
+ # An array V of M octets is created, in which the array element V[i]
85
+ # contains the octet from the ith share. The value of I(U, V) is
86
+ # computed, then appended to the output string.
87
+ #
88
+ # The output string is returned.
89
+ #
90
+ def combine
91
+ # unwrap 'human' share format
92
+ if shares.first.start_with?('tss~')
93
+ shares.collect! do |s|
94
+ matcher = /^tss~v1~*[a-zA-Z0-9\.\-\_]{0,16}~[0-9]{1,3}~([a-zA-Z0-9\-\_]+\={0,2})$/
95
+ s_b64 = s.match(matcher)
96
+ if s_b64.present?
97
+ # puts s_b64.to_a[1].inspect
98
+ Base64.urlsafe_decode64(s_b64.to_a[1])
99
+ else
100
+ raise TSS::ArgumentError, 'invalid shares, human format shares do not match expected pattern'
101
+ end
102
+ end
103
+ end
104
+
105
+ validate_all_shares(shares)
106
+ orig_shares_size = shares.size
107
+ start_processing_time = Time.now
108
+
109
+ h = Util.extract_share_header(shares.sample)
110
+ threshold = h[:threshold]
111
+ identifier = h[:identifier]
112
+ hash_id = h[:hash_id]
113
+
114
+ # If there are more shares than the threshold would require
115
+ # then choose a subset of the shares based on preference.
116
+ if shares.size > threshold
117
+ case select_by
118
+ when 'first'
119
+ @shares = shares.shift(threshold)
120
+ when 'sample'
121
+ @shares = shares.sample(threshold)
122
+ when 'combinations'
123
+ share_combinations_mode_allowed?(hash_id)
124
+ share_combinations_out_of_bounds?(shares, threshold)
125
+ end
126
+ end
127
+
128
+ # slice out the data after the header bytes in each share
129
+ # and unpack the byte string into an Array of Byte Arrays
130
+ shares_bytes = shares.collect do |s|
131
+ bytestring = s.byteslice(Splitter::SHARE_HEADER_STRUCT.size..s.bytesize)
132
+ bytestring.unpack('C*') unless bytestring.nil?
133
+ end.compact
134
+
135
+ shares_bytes_have_valid_indexes?(shares_bytes)
136
+
137
+ if select_by == 'combinations'
138
+ # Build an Array of all possible `threshold` size combinations.
139
+ share_combos = shares_bytes.combination(threshold).to_a
140
+
141
+ secret = nil
142
+ while secret.nil? && share_combos.present?
143
+ # Check a combination and shift it off the Array
144
+ result = extract_secret_from_shares(hash_id, share_combos.shift)
145
+ next if result.nil?
146
+ secret = result
147
+ end
148
+ else
149
+ secret = extract_secret_from_shares(hash_id, shares_bytes)
150
+ end
151
+
152
+ if secret.present?
153
+ {
154
+ hash_alg: Hasher.key_from_code(hash_id).to_s,
155
+ identifier: identifier,
156
+ num_shares_provided: orig_shares_size,
157
+ num_shares_used: share_combos.present? ? share_combos.first.size : shares.size,
158
+ processing_started_at: start_processing_time.utc.iso8601,
159
+ processing_finished_at: Time.now.utc.iso8601,
160
+ processing_time_ms: ((Time.now - start_processing_time)*1000).round(2),
161
+ secret: Util.bytes_to_utf8(secret),
162
+ shares_select_by: select_by,
163
+ combinations: share_combos.present? ? share_combos.size : nil,
164
+ threshold: threshold
165
+ }
166
+ else
167
+ raise TSS::Error, 'unable to recombine shares into a verifiable secret'
168
+ end
169
+ end
170
+
171
+ private
172
+
173
+ def extract_secret_from_shares(hash_id, shares_bytes)
174
+ secret = []
175
+
176
+ # build up an Array of index values from each share
177
+ # u[i] equal to the first octet of the ith share
178
+ u = shares_bytes.collect { |s| s[0] }
179
+
180
+ # loop through each byte in all the shares
181
+ # start at Array index 1 in each share's Byte Array to skip the index
182
+ (1..(shares_bytes.first.length - 1)).each do |i|
183
+ v = shares_bytes.collect { |share| share[i] }
184
+ secret << Util.lagrange_interpolation(u, v)
185
+ end
186
+
187
+ strip_left_pad(secret)
188
+
189
+ hash_alg = Hasher.key_from_code(hash_id)
190
+
191
+ # Run the hash digest checks if the shares were created with a digest
192
+ if Hasher.codes_without_none.include?(hash_id)
193
+ # RTSS : pop off the hash digest bytes from the tail of the secret. This
194
+ # leaves `secret` with only the secret bytes remaining.
195
+ orig_hash_bytes = secret.pop(Hasher.bytesize(hash_alg))
196
+
197
+ # RTSS : verify that the recombined secret computes the same hash
198
+ # digest now as when it was originally created.
199
+ new_hash_bytes = Hasher.byte_array(hash_alg, Util.bytes_to_utf8(secret))
200
+
201
+ # return the secret only if the hash test passed
202
+ new_hash_bytes == orig_hash_bytes ? secret : nil
203
+ else
204
+ secret
205
+ end
206
+ end
207
+
208
+ # strip off leading padding chars ("\u001F", decimal 31)
209
+ def strip_left_pad(secret)
210
+ secret.shift while secret.first == 31
211
+ end
212
+
213
+ def valid_header?(header)
214
+ header.is_a?(Hash) &&
215
+ header.key?(:identifier) &&
216
+ header[:identifier].is_a?(String) &&
217
+ header.key?(:hash_id) &&
218
+ header[:hash_id].is_a?(Integer) &&
219
+ header.key?(:threshold) &&
220
+ header[:threshold].is_a?(Integer) &&
221
+ header.key?(:share_len) &&
222
+ header[:share_len].is_a?(Integer)
223
+ end
224
+
225
+ def shares_have_same_bytesize?(shares)
226
+ shares.each do |s|
227
+ unless s.bytesize == shares.first.bytesize
228
+ raise TSS::ArgumentError, 'invalid shares, different byte lengths'
229
+ end
230
+ end
231
+ end
232
+
233
+ def shares_have_valid_headers?(shares)
234
+ fh = Util.extract_share_header(shares.first)
235
+ shares.each do |s|
236
+ h = Util.extract_share_header(s)
237
+ unless valid_header?(h) && h == fh
238
+ raise TSS::ArgumentError, 'invalid shares, bad headers'
239
+ end
240
+ end
241
+ end
242
+
243
+ def shares_have_expected_length?(shares)
244
+ shares.each do |s|
245
+ unless s.bytesize > Splitter::SHARE_HEADER_STRUCT.size + 1
246
+ raise TSS::ArgumentError, 'invalid shares, too short'
247
+ end
248
+ end
249
+ end
250
+
251
+ def shares_meet_threshold_min?(shares)
252
+ fh = Util.extract_share_header(shares.first)
253
+ unless shares.size >= fh[:threshold]
254
+ raise TSS::ArgumentError, 'invalid shares, fewer than threshold'
255
+ end
256
+ end
257
+
258
+ def validate_all_shares(shares)
259
+ shares_have_valid_headers?(shares)
260
+ shares_have_same_bytesize?(shares)
261
+ shares_have_expected_length?(shares)
262
+ shares_meet_threshold_min?(shares)
263
+ end
264
+
265
+ def shares_bytes_have_valid_indexes?(shares_bytes)
266
+ u = shares_bytes.collect do |s|
267
+ raise TSS::ArgumentError, 'invalid shares, no index' if s[0].blank?
268
+ raise TSS::ArgumentError, 'invalid shares, zero index' if s[0] == 0
269
+ s[0]
270
+ end
271
+
272
+ unless u.uniq.size == shares_bytes.size
273
+ raise TSS::ArgumentError, 'invalid shares, duplicate indexes'
274
+ end
275
+ end
276
+
277
+ def share_combinations_mode_allowed?(hash_id)
278
+ unless Hasher.codes_without_none.include?(hash_id)
279
+ raise TSS::ArgumentError, 'invalid options, combinations mode can only be used with hashed shares.'
280
+ end
281
+ end
282
+
283
+ def share_combinations_out_of_bounds?(shares, threshold, max_combinations = 1_000_000)
284
+ # Raise if the number of combinations is too high.
285
+ # If this is not checked, the number of combinations can quickly grow into
286
+ # numbers that cannot be calculated before the end of the universe.
287
+ # e.g. 255 total shares, with threshold of 128, results in # combinations of:
288
+ # 2884329411724603169044874178931143443870105850987581016304218283632259375395
289
+ #
290
+ combinations = Util.calc_combinations(shares.size, threshold)
291
+ if combinations > max_combinations
292
+ raise TSS::ArgumentError, "invalid options, too many combinations (#{Util.int_commas(combinations)})"
293
+ end
294
+ end
295
+ end
296
+ end