diff --git a/.yardopts b/.yardopts index e06e717..3c4c879 100644 --- a/.yardopts +++ b/.yardopts @@ -1 +1 @@ ---no-private lib/**/*.rb - README.md LICENSE.txt CODE_OF_CONDUCT.md +--plugin contracts -e lib/tss/custom_contracts.rb --no-private lib/**/*.rb - README.md LICENSE.txt CODE_OF_CONDUCT.md diff --git a/README.md b/README.md index 72c6de7..ae1c154 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,7 @@ tss~v1~546604c0b5e9138b~3~NTQ2NjA0YzBiNWU5MTM4YgIDAD8FqmJGRCiXokksUc3E1-gQNuNf2l You can use the CLI to enter shares in order to recover a secret. Of course you will need at least the number of shares necessary as determined by the threshold when your shares were created. The `threshold` is visible -as the third field in every `human` formatted share. +as the third field in every `HUMAN` formatted share. As with splitting a secret, there are also three methods of getting the shares into the CLI. `STDIN`, a path to a file containing shares, or interactively. @@ -419,12 +419,12 @@ and then combined with it prior to secret splitting. This means that the hash is protected the same way as the secret. The algorithm used is `secret || hash(secret)`. You can use one of `NONE`, `SHA1`, or `SHA256`. -The `format` arg takes a String Enum with either `'human'` (default) or -`'binary'` values. This instructs the output of a split to either provide an +The `format` arg takes an uppercase String Enum with either `'HUMAN'` (default) or +`'BINARY'` values. This instructs the output of a split to either provide an array of binary octet strings (a standard RTSS format for interoperability), or a human friendly URL Safe Base 64 encoded version of that same binary output. -The `human` format can be easily shared in a tweet, email, or even a URL. The -`human` format is prefixed with `tss~VERSION~IDENTIFIER~THRESHOLD~` to make it +The `HUMAN` format can be easily shared in a tweet, email, or even a URL. The +`HUMAN` format is prefixed with `tss~VERSION~IDENTIFIER~THRESHOLD~` to make it easier to visually compare shares and see if they have matching identifiers and if you have enough shares to reach the threshold. @@ -523,23 +523,23 @@ of those shares are selected for use in the operation. The method used to select the shares can be chosen with the `select_by:` argument which takes the following values as options: -`select_by: 'first'` : If X shares are required by the threshold and more than X +`select_by: 'FIRST'` : If X shares are required by the threshold and more than X shares are provided, then the first X shares in the Array of shares provided will be used. All others will be discarded and the operation will fail if those selected shares cannot recreate the secret. -`select_by: 'sample'` : If X shares are required by the threshold and more than X +`select_by: 'SAMPLE'` : If X shares are required by the threshold and more than X shares are provided, then X shares will be randomly selected from the Array of shares provided. All others will be discarded and the operation will fail if those selected shares cannot recreate the secret. -`select_by: 'combinations'` : If X shares are required, and more than X shares are +`select_by: 'COMBINATIONS'` : If X shares are required, and more than X shares are provided, then all possible combinations of the threshold number of shares will be tried to see if the secret can be recreated. **Warning** -This `combinations` flexibility comes with a cost. All combinations of +This `COMBINATIONS` flexibility comes with a cost. All combinations of `threshold` shares must be generated before processing. Due to the math associated with combinations it is possible that the system would try to generate a number of combinations that could never be generated or processed diff --git a/lib/tss/cli_split.rb b/lib/tss/cli_split.rb index 596796e..0bc1eda 100644 --- a/lib/tss/cli_split.rb +++ b/lib/tss/cli_split.rb @@ -10,7 +10,7 @@ class CLI < Thor method_option :num_shares, :aliases => '-n', :banner => 'num_shares', :type => :numeric, :desc => '# of shares total that will be generated' method_option :identifier, :aliases => '-i', :banner => 'identifier', :type => :string, :desc => 'A unique identifier string, 0-16 Bytes, [a-zA-Z0-9.-_]' method_option :hash_alg, :aliases => '-h', :banner => 'hash_alg', :type => :string, :desc => 'A hash type for verification, NONE, SHA1, SHA256' - method_option :format, :aliases => '-f', :banner => 'format', :type => :string, :default => 'human', :desc => 'Share output format, binary or human' + method_option :format, :aliases => '-f', :banner => 'format', :type => :string, :default => 'HUMAN', :desc => 'Share output format, BINARY or HUMAN' method_option :pad_blocksize, :aliases => '-p', :banner => 'pad_blocksize', :type => :numeric, :desc => 'Block size # secrets will be left-padded to, 0-255' method_option :input_file, :aliases => '-I', :banner => 'input_file', :type => :string, :desc => 'A filename to read the secret from' method_option :output_file, :aliases => '-O', :banner => 'output_file', :type => :string, :desc => 'A filename to write the shares to' @@ -57,7 +57,7 @@ class CLI < Thor Example w/ options: - $ tss split -t 3 -n 6 -i abc123 -h SHA256 -p 8 -f human + $ tss split -t 3 -n 6 -i abc123 -h SHA256 -p 8 -f HUMAN Enter your secret: diff --git a/lib/tss/combiner.rb b/lib/tss/combiner.rb index b213018..89ce08e 100644 --- a/lib/tss/combiner.rb +++ b/lib/tss/combiner.rb @@ -1,5 +1,8 @@ module TSS - # Combiner has responsibility for combining an Array of String shares back + # Warning, you probably don't want to use this directly. Instead + # see the TSS module. + # + # TSS::Combiner has responsibility for combining an Array of String shares back # into the original secret the shares were split from. It is also responsible # for doing extensive validation of user provided shares and ensuring # that any recovered secret matches the hash of the original secret. @@ -11,15 +14,17 @@ class Combiner attr_reader :shares, :select_by - Contract ({ :shares => C::ArrayOf[String], :select_by => C::Maybe[C::Enum['first', 'sample', 'combinations']] }) => C::Any + Contract ({ :shares => C::ArrayOfShares, :select_by => C::Maybe[C::SelectByArg] }) => C::Any def initialize(opts = {}) # clone the incoming shares so the object passed to this # function doesn't get modified. @shares = opts.fetch(:shares).clone - raise TSS::ArgumentError, 'Invalid number of shares. Must be between 1 and 255' unless @shares.size.between?(1,255) - @select_by = opts.fetch(:select_by, 'first') + @select_by = opts.fetch(:select_by, 'FIRST') end + # Warning, you probably don't want to use this directly. Instead + # see the TSS module. + # # To reconstruct a secret from a set of shares, the following # procedure, or any equivalent method, is used: # @@ -52,8 +57,11 @@ def initialize(opts = {}) # # The output string is returned (along with some metadata). # + # + # @return an Hash of combined secret attributes + # @raise [ParamContractError, TSS::ArgumentError] if the options Types or Values are invalid # rubocop:disable CyclomaticComplexity - Contract C::None => ({ :hash => C::Maybe[String], :hash_alg => C::Maybe[C::Enum['NONE', 'SHA1', 'SHA256']], :identifier => TSS::IdentifierArg, :process_time => C::Num, :secret => TSS::SecretArg, :threshold => TSS::ThresholdArg}) + Contract C::None => ({ :hash => C::Maybe[String], :hash_alg => C::HashAlgArg, :identifier => C::IdentifierArg, :process_time => C::Num, :secret => C::SecretArg, :threshold => C::ThresholdArg}) def combine # unwrap 'human' shares into binary shares if all_shares_appear_human?(shares) @@ -70,9 +78,9 @@ def combine # Select a subset of the shares provided using the chosen selection # method. If there are exactly the right amount of shares this is a no-op. - if select_by == 'first' + if select_by == 'FIRST' @shares = shares.shift(threshold) - elsif select_by == 'sample' + elsif select_by == 'SAMPLE' @shares = shares.sample(threshold) end @@ -85,7 +93,7 @@ def combine shares_bytes_have_valid_indexes!(shares_bytes) - if select_by == 'combinations' + if select_by == 'COMBINATIONS' share_combinations_mode_allowed!(hash_id) share_combinations_out_of_bounds!(shares, threshold) @@ -107,7 +115,7 @@ def combine # Return a Hash with the secret and metadata { hash: secret[:hash], - hash_alg: secret[:hash_alg].to_s, + hash_alg: secret[:hash_alg], identifier: identifier, process_time: ((Time.now - start_processing_time)*1000).round(2), secret: Util.bytes_to_utf8(secret[:secret]), @@ -122,12 +130,12 @@ def combine # and validate it against any one-way hash that was embedded in the shares # along with the secret. # - # @param hash_id [Integer] the ID of the one-way hash function to test with - # @param shares_bytes [Array] the shares as Byte Arrays to be evaluated - # @return [Array] returns the secret as an Array of Bytes if it was recovered from the shares and validated + # @param hash_id the ID of the one-way hash function to test with + # @param shares_bytes the shares as Byte Arrays to be evaluated + # @return returns the secret as an Array of Bytes if it was recovered from the shares and validated # @raise [TSS::NoSecretError] if the secret was not able to be recovered (with no hash) # @raise [TSS::InvalidSecretHashError] if the secret was able to be recovered but the hash test failed - Contract C::Int, C::ArrayOf[C::ArrayOf[C::Num]] => ({ :secret => C::ArrayOf[C::Num], :hash => C::Maybe[String], :hash_alg => C::Enum[:NONE, :SHA1, :SHA256] }) + Contract C::Int, C::ArrayOf[C::ArrayOf[C::Num]] => ({ :secret => C::ArrayOf[C::Num], :hash => C::Maybe[String], :hash_alg => C::HashAlgArg }) def extract_secret_from_shares!(hash_id, shares_bytes) secret = [] @@ -172,8 +180,9 @@ def extract_secret_from_shares!(hash_id, shares_bytes) # Strip off leading padding chars ("\u001F", decimal 31) # - # @param secret [Array] the secret to be stripped - # @return [Array,nil] returns the secret, stripped of the leading padding char + # @param secret the secret to be stripped + # @return returns the secret, stripped of the leading padding char + # @raise [ParamContractError] if secret appears invalid Contract C::ArrayOf[C::Num] => C::Maybe[Array] def strip_left_pad(secret) secret.shift while secret.first == 31 @@ -181,8 +190,9 @@ def strip_left_pad(secret) # Do all of the shares match the pattern expected of human style shares? # - # @param shares [Array] the shares to be evaluated - # @return [true,false] returns true if all shares match the patterns, false if not + # @param shares the shares to be evaluated + # @return returns true if all shares match the patterns, false if not + # @raise [ParamContractError] if shares appear invalid Contract C::ArrayOf[String] => C::Bool def all_shares_appear_human?(shares) shares.all? do |s| @@ -194,9 +204,9 @@ def all_shares_appear_human?(shares) # Convert an Array of human style shares to binary style # - # @param shares [Array] the shares to be converted - # @return [Array] returns an Array of String shares in binary octet String format - # @raise [TSS::ArgumentError] if shares appear invalid + # @param shares the shares to be converted + # @return returns an Array of String shares in binary octet String format + # @raise [ParamContractError, TSS::ArgumentError] if shares appear invalid Contract C::ArrayOf[String] => C::ArrayOf[String] def convert_shares_human_to_binary(shares) shares.map do |s| @@ -216,9 +226,9 @@ def convert_shares_human_to_binary(shares) # Do all shares have a common Byte size? They are invalid if not. # - # @param shares [Array] the shares to be evaluated - # @return [true] returns true if all shares have the same Byte size - # @raise [TSS::ArgumentError] if shares appear invalid + # @param shares the shares to be evaluated + # @return returns true if all shares have the same Byte size + # @raise [ParamContractError, TSS::ArgumentError] if shares appear invalid Contract C::ArrayOf[String] => C::Bool def shares_have_same_bytesize!(shares) shares.each do |s| @@ -231,9 +241,9 @@ def shares_have_same_bytesize!(shares) # Do all shares have a valid header and match each other? They are invalid if not. # - # @param shares [Array] the shares to be evaluated - # @return [true] returns true if all shares have the same header - # @raise [TSS::ArgumentError] if shares appear invalid + # @param shares the shares to be evaluated + # @return returns true if all shares have the same header + # @raise [ParamContractError, TSS::ArgumentError] if shares appear invalid Contract C::ArrayOf[String] => C::Bool def shares_have_valid_headers!(shares) fh = Util.extract_share_header(shares.first) @@ -253,9 +263,9 @@ def shares_have_valid_headers!(shares) # Do all shares have a the expected length? They are invalid if not. # - # @param shares [Array] the shares to be evaluated - # @return [true] returns true if all shares have the same header - # @raise [TSS::ArgumentError] if shares appear invalid + # @param shares the shares to be evaluated + # @return returns true if all shares have the same header + # @raise [ParamContractError, TSS::ArgumentError] if shares appear invalid Contract C::ArrayOf[String] => C::Bool def shares_have_expected_length!(shares) shares.each do |s| @@ -268,9 +278,9 @@ def shares_have_expected_length!(shares) # Were enough shares provided to meet the threshold? They are invalid if not. # - # @param shares [Array] the shares to be evaluated - # @return [true] returns true if there are enough shares - # @raise [TSS::ArgumentError] if shares appear invalid + # @param shares the shares to be evaluated + # @return returns true if there are enough shares + # @raise [ParamContractError, TSS::ArgumentError] if shares appear invalid Contract C::ArrayOf[String] => C::Bool def shares_meet_threshold_min!(shares) fh = Util.extract_share_header(shares.first) @@ -283,8 +293,9 @@ def shares_meet_threshold_min!(shares) # Were enough shares provided to meet the threshold? They are invalid if not. # - # @param shares [Array] the shares to be evaluated - # @return [true] returns true if all tests pass + # @param shares the shares to be evaluated + # @return returns true if all tests pass + # @raise [ParamContractError] if shares appear invalid Contract C::ArrayOf[String] => C::Bool def validate_all_shares(shares) if shares_have_valid_headers!(shares) && @@ -299,9 +310,9 @@ def validate_all_shares(shares) # Do all the shares have a valid first-byte index? They are invalid if not. # - # @param shares_bytes [Array] the shares as Byte Arrays to be evaluated - # @return [true] returns true if there are enough shares - # @raise [TSS::ArgumentError] if shares appear invalid + # @param shares_bytes the shares as Byte Arrays to be evaluated + # @return returns true if there are enough shares + # @raise [ParamContractError, TSS::ArgumentError] if shares bytes appear invalid Contract C::ArrayOf[C::ArrayOf[C::Num]] => C::Bool def shares_bytes_have_valid_indexes!(shares_bytes) u = shares_bytes.map do |s| @@ -320,9 +331,9 @@ def shares_bytes_have_valid_indexes!(shares_bytes) # Is it valid to use combinations mode? Only when there is an embedded non-zero # hash_id Integer to test the results against. Invalid if not. # - # @param hash_id [Integer] the shares as Byte Arrays to be evaluated - # @return [true] returns true if OK to use combinations mode - # @raise [TSS::ArgumentError] if hash_id represents a non hashing type + # @param hash_id the shares as Byte Arrays to be evaluated + # @return returns true if OK to use combinations mode + # @raise [ParamContractError, TSS::ArgumentError] if hash_id represents a non hashing type Contract C::Int => C::Bool def share_combinations_mode_allowed!(hash_id) unless Hasher.codes_without_none.include?(hash_id) @@ -340,11 +351,11 @@ def share_combinations_mode_allowed!(hash_id) # e.g. 255 total shares, with threshold of 128, results in # combinations of: # 2884329411724603169044874178931143443870105850987581016304218283632259375395 # - # @param shares [Array] the shares to be evaluated - # @param threshold [Integer] the threshold value set in the shares - # @param max_combinations [Integer] the max (1_000_000) number of combinations allowed - # @return [true] returns true if a reasonable number of combinations - # @raise [TSS::ArgumentError] if the number of possible combinations is unreasonably high + # @param shares the shares to be evaluated + # @param threshold the threshold value set in the shares + # @param max_combinations the max (1_000_000) number of combinations allowed + # @return returns true if a reasonable number of combinations + # @raise [ParamContractError, TSS::ArgumentError] if args are invalid or the number of possible combinations is unreasonably high Contract C::ArrayOf[String], C::Int, C::Int => C::Bool def share_combinations_out_of_bounds!(shares, threshold, max_combinations = 1_000_000) combinations = Util.calc_combinations(shares.size, threshold) diff --git a/lib/tss/custom_contracts.rb b/lib/tss/custom_contracts.rb index 64938d2..95666f8 100644 --- a/lib/tss/custom_contracts.rb +++ b/lib/tss/custom_contracts.rb @@ -1,10 +1,25 @@ -module TSS +module Contracts # Custom Contracts # See : https://egonschiele.github.io/contracts.ruby/ + + class ArrayOfShares + def self.valid? val + val.present? && + val.is_a?(Array) && + val.length.between?(1,255) && + Contracts::ArrayOf[String].valid?(val) + end + + def self.to_s + 'An Array of split secret shares' + end + end + class SecretArg def self.valid? val - val.present? && val.is_a?(String) && + val.present? && + val.is_a?(String) && val.length.between?(1,65502) && ['UTF-8', 'US-ASCII'].include?(val.encoding.name) && val.slice(0) != "\u001F" @@ -52,6 +67,36 @@ def self.to_s end end + class HashAlgArg + def self.valid? val + Contracts::Enum['NONE', 'SHA1', 'SHA256'].valid?(val) + end + + def self.to_s + 'must be a uppercase String specifying the hash algorithm to use [NONE, SHA1, SHA256].' + end + end + + class FormatArg + def self.valid? val + Contracts::Enum['BINARY', 'HUMAN'].valid?(val) + end + + def self.to_s + 'must be a uppercase String specifying the desired String share format [BINARY, HUMAN].' + end + end + + class SelectByArg + def self.valid? val + Contracts::Enum['FIRST', 'SAMPLE', 'COMBINATIONS'].valid?(val) + end + + def self.to_s + 'must be a uppercase String specifying the desired way to sample shares provided [FIRST, SAMPLE, COMBINATIONS].' + end + end + class PadBlocksizeArg def self.valid? val val.present? && diff --git a/lib/tss/hasher.rb b/lib/tss/hasher.rb index f364268..86daf29 100644 --- a/lib/tss/hasher.rb +++ b/lib/tss/hasher.rb @@ -5,15 +5,15 @@ class Hasher include Contracts::Core C = Contracts - HASHES = { NONE: { code: 0, bytesize: 0, hasher: nil }, - SHA1: { code: 1, bytesize: 20, hasher: Digest::SHA1 }, - SHA256: { code: 2, bytesize: 32, hasher: Digest::SHA256 }}.freeze + HASHES = { 'NONE' => { code: 0, bytesize: 0, hasher: nil }, + 'SHA1' => { code: 1, bytesize: 20, hasher: Digest::SHA1 }, + 'SHA256' => { code: 2, bytesize: 32, hasher: Digest::SHA256 }}.freeze # Lookup the Symbol key for a Hash with the code. # - # @param code [Integer] the hash code to convert to a Symbol key - # @return [Symbol,nil] the hash key Symbol or nil if not found - Contract C::Int => C::Maybe[Symbol] + # @param code the hash code to convert to a Symbol key + # @return the hash key String or nil if not found + Contract C::Int => C::Maybe[C::HashAlgArg] def self.key_from_code(code) return nil unless Hasher.codes.include?(code) HASHES.each do |k, v| @@ -23,16 +23,16 @@ def self.key_from_code(code) # Lookup the hash code for the hash matching hash_key. # - # @param hash_key [Symbol, String] the hash key to convert to an Integer code - # @return [Integer] the hash key code - Contract C::Maybe[C::Enum['NONE', 'SHA1', 'SHA256'], C::Enum[:NONE, :SHA1, :SHA256]] => C::Maybe[C::Int] + # @param hash_key the hash key to convert to an Integer code + # @return the hash key code + Contract C::HashAlgArg => C::Maybe[C::Int] def self.code(hash_key) - HASHES[hash_key.upcase.to_sym][:code] + HASHES[hash_key][:code] end # Lookup all valid hash codes, including NONE. # - # @return [Array] all hash codes including NONE + # @return all hash codes including NONE Contract C::None => C::ArrayOf[C::Int] def self.codes HASHES.map do |_k, v| @@ -42,7 +42,7 @@ def self.codes # All valid hash codes that actually do hashing, excluding NONE. # - # @return [Array] all hash codes excluding NONE + # @return all hash codes excluding NONE Contract C::None => C::ArrayOf[C::Int] def self.codes_without_none HASHES.map do |_k, v| @@ -52,49 +52,46 @@ def self.codes_without_none # Lookup the size in Bytes for a specific hash_key. # - # @param hash_key [Symbol, String] the hash key to lookup - # @return [Integer] the size in Bytes for a specific hash_key - Contract C::Or[C::Enum['NONE', 'SHA1', 'SHA256'], C::Enum[:NONE, :SHA1, :SHA256]] => C::Int + # @param hash_key the hash key to lookup + # @return the size in Bytes for a specific hash_key + Contract C::HashAlgArg => C::Int def self.bytesize(hash_key) - HASHES[hash_key.to_sym][:bytesize] + HASHES[hash_key][:bytesize] end # Return a hexdigest hash for a String using hash_key hash algorithm. - # Returns '' if hash_key == :NONE + # Returns '' if hash_key == 'NONE' # - # @param hash_key [Symbol, String] the hash key to use to hash a String - # @param str [String] the String to hash - # @return [String] the hex digest for str - Contract C::Or[C::Enum['NONE', 'SHA1', 'SHA256'], C::Enum[:NONE, :SHA1, :SHA256]], String => String + # @param hash_key the hash key to use to hash a String + # @param str the String to hash + # @return the hex digest for str + Contract C::HashAlgArg, String => String def self.hex_string(hash_key, str) - hash_key = hash_key.upcase.to_sym - return '' if hash_key == :NONE + return '' if hash_key == 'NONE' HASHES[hash_key][:hasher].send(:hexdigest, str) end # Return a Byte String hash for a String using hash_key hash algorithm. - # Returns '' if hash_key == :NONE + # Returns '' if hash_key == 'NONE' # - # @param hash_key [Symbol, String] the hash key to use to hash a String - # @param str [String] the String to hash - # @return [String] the Byte String digest for str - Contract C::Or[C::Enum['NONE', 'SHA1', 'SHA256'], C::Enum[:NONE, :SHA1, :SHA256]], String => String + # @param hash_key the hash key to use to hash a String + # @param str the String to hash + # @return the Byte String digest for str + Contract C::HashAlgArg, String => String def self.byte_string(hash_key, str) - hash_key = hash_key.upcase.to_sym - return '' if hash_key == :NONE + return '' if hash_key == 'NONE' HASHES[hash_key][:hasher].send(:digest, str) end # Return a Byte Array hash for a String using hash_key hash algorithm. - # Returns [] if hash_key == :NONE + # Returns [] if hash_key == 'NONE' # - # @param hash_key [Symbol, String] the hash key to use to hash a String - # @param str [String] the String to hash - # @return [Array] the Byte Array digest for str - Contract C::Or[C::Enum['NONE', 'SHA1', 'SHA256'], C::Enum[:NONE, :SHA1, :SHA256]], String => C::ArrayOf[C::Int] + # @param hash_key the hash key to use to hash a String + # @param str the String to hash + # @return the Byte Array digest for str + Contract C::HashAlgArg, String => C::ArrayOf[C::Int] def self.byte_array(hash_key, str) - hash_key = hash_key.upcase.to_sym - return [] if hash_key == :NONE + return [] if hash_key == 'NONE' HASHES[hash_key][:hasher].send(:digest, str).unpack('C*') end end diff --git a/lib/tss/splitter.rb b/lib/tss/splitter.rb index 052aea8..7271800 100644 --- a/lib/tss/splitter.rb +++ b/lib/tss/splitter.rb @@ -1,5 +1,8 @@ module TSS - # Splitter has responsibility for splitting a secret into an Array of String shares. + # Warning, you probably don't want to use this directly. Instead + # see the TSS module. + # + # TSS::Splitter has responsibility for splitting a secret into an Array of String shares. class Splitter include Contracts::Core include Util @@ -8,14 +11,14 @@ class Splitter attr_reader :secret, :threshold, :num_shares, :identifier, :hash_alg, :format, :pad_blocksize - Contract ({ :secret => TSS::SecretArg, :threshold => C::Maybe[TSS::ThresholdArg], :num_shares => C::Maybe[TSS::NumSharesArg], :identifier => C::Maybe[TSS::IdentifierArg], :hash_alg => C::Maybe[C::Enum['NONE', 'SHA1', 'SHA256']], :format => C::Maybe[C::Enum['binary', 'human']], :pad_blocksize => C::Maybe[TSS::PadBlocksizeArg] }) => C::Any + Contract ({ :secret => C::SecretArg, :threshold => C::Maybe[C::ThresholdArg], :num_shares => C::Maybe[C::NumSharesArg], :identifier => C::Maybe[C::IdentifierArg], :hash_alg => C::Maybe[C::HashAlgArg], :format => C::Maybe[C::FormatArg], :pad_blocksize => C::Maybe[C::PadBlocksizeArg] }) => C::Any def initialize(opts = {}) @secret = opts.fetch(:secret) @threshold = opts.fetch(:threshold, 3) @num_shares = opts.fetch(:num_shares, 5) @identifier = opts.fetch(:identifier, SecureRandom.hex(8)) @hash_alg = opts.fetch(:hash_alg, 'SHA256') - @format = opts.fetch(:format, 'human') + @format = opts.fetch(:format, 'HUMAN') @pad_blocksize = opts.fetch(:pad_blocksize, 0) end @@ -26,6 +29,9 @@ def initialize(opts = {}) 'n', :share_len ]) + # Warning, you probably don't want to use this directly. Instead + # see the TSS module. + # # To split a secret into a set of shares, the following # procedure, or any equivalent method, is used: # @@ -49,7 +55,9 @@ def initialize(opts = {}) # If the operation can not be completed successfully, then an error # code should be returned. # - Contract C::None => C::ArrayOf[String] + # @return an Array of formatted String shares + # @raise [ParamContractError, TSS::ArgumentError] if the options Types or Values are invalid + Contract C::None => C::ArrayOfShares def split num_shares_not_less_than_threshold!(threshold, num_shares) @@ -115,7 +123,7 @@ def split binary = (header + s.pack('C*')).force_encoding('ASCII-8BIT') # join with URL safe '~' human = ['tss', 'v1', identifier, threshold, Base64.urlsafe_encode64(binary)].join('~') - format == 'binary' ? binary : human + format == 'BINARY' ? binary : human end return shares @@ -125,11 +133,11 @@ def split # The num_shares must be greater than or equal to the threshold or it is invalid. # - # @param threshold [Integer] the threshold value - # @param num_shares [Integer] the num_shares value - # @return [true] returns true if num_shares is >= threshold - # @raise [TSS::ArgumentError] if invalid - Contract TSS::ThresholdArg, TSS::NumSharesArg => C::Bool + # @param threshold the threshold value + # @param num_shares the num_shares value + # @return returns true if num_shares is >= threshold + # @raise [ParamContractError, TSS::ArgumentError] if invalid + Contract C::ThresholdArg, C::NumSharesArg => C::Bool def num_shares_not_less_than_threshold!(threshold, num_shares) if num_shares < threshold raise TSS::ArgumentError, "invalid num_shares, must be >= threshold (#{threshold})" @@ -141,9 +149,9 @@ def num_shares_not_less_than_threshold!(threshold, num_shares) # The total Byte size of the secret, including padding and hash, must be # less than the max allowed Byte size or it is invalid. # - # @param secret_bytes [Array] the Byte Array containing the secret - # @return [true] returns true if num_shares is >= threshold - # @raise [TSS::ArgumentError] if invalid + # @param secret_bytes the Byte Array containing the secret + # @return returns true if num_shares is >= threshold + # @raise [ParamContractError, TSS::ArgumentError] if invalid Contract C::ArrayOf[C::Int] => C::Bool def secret_bytes_is_smaller_than_max_size!(secret_bytes) if secret_bytes.size >= 65_535 @@ -155,12 +163,13 @@ def secret_bytes_is_smaller_than_max_size!(secret_bytes) # Construct a binary share header from its constituent parts. # - # @param identifier [String] the unique identifier String - # @param hash_alg [String] the hash algorithm String - # @param threshold [Integer] the threshold value - # @param share_len [Integer] the length of the share in Bytes - # @return [String] returns an octet String of Bytes containing the binary header - Contract TSS::IdentifierArg, C::Maybe[C::Enum['NONE', 'SHA1', 'SHA256']], TSS::ThresholdArg, C::Int => String + # @param identifier the unique identifier String + # @param hash_alg the hash algorithm String + # @param threshold the threshold value + # @param share_len the length of the share in Bytes + # @return returns an octet String of Bytes containing the binary header + # @raise [ParamContractError] if invalid + Contract C::IdentifierArg, C::HashAlgArg, C::ThresholdArg, C::Int => String def share_header(identifier, hash_alg, threshold, share_len) SHARE_HEADER_STRUCT.encode(identifier: identifier, hash_id: Hasher.code(hash_alg), diff --git a/lib/tss/tss.rb b/lib/tss/tss.rb index 8c78266..c01781f 100644 --- a/lib/tss/tss.rb +++ b/lib/tss/tss.rb @@ -60,7 +60,7 @@ class InvalidSecretHashError < TSS::Error; end # to `SHA256`. The use of `NONE` is discouraged as it does not allow those # who are recombining the shares to verify if they have in fact recovered # the correct secret. - # @option opts [String] :format ('binary') the format of the String share output, 'binary' or 'human' + # @option opts [String] :format ('BINARY') the format of the String share output, 'BINARY' or 'HUMAN' # @option opts [Integer] :pad_blocksize (0) An integer representing the nearest multiple of Bytes # to left pad the secret to. Defaults to not adding any padding (0). Padding # is done with the "\u001F" character (decimal 31 in a Byte Array). @@ -75,9 +75,9 @@ class InvalidSecretHashError < TSS::Error; end # place. You would also need to manually remove the padding you added after # the share is recombined, or instruct recipients to ignore it. # - # @return [Array] an Array of String shares + # @return an Array of formatted String shares # @raise [ParamContractError, TSS::ArgumentError] if the options Types or Values are invalid - Contract ({ :secret => TSS::SecretArg, :threshold => C::Maybe[TSS::ThresholdArg], :num_shares => C::Maybe[TSS::NumSharesArg], :identifier => C::Maybe[TSS::IdentifierArg], :hash_alg => C::Maybe[C::Enum['NONE', 'SHA1', 'SHA256']], :format => C::Maybe[C::Enum['binary', 'human']], :pad_blocksize => C::Maybe[TSS::PadBlocksizeArg] }) => C::ArrayOf[String] + Contract ({ :secret => C::SecretArg, :threshold => C::Maybe[C::ThresholdArg], :num_shares => C::Maybe[C::NumSharesArg], :identifier => C::Maybe[C::IdentifierArg], :hash_alg => C::Maybe[C::HashAlgArg], :format => C::Maybe[C::FormatArg], :pad_blocksize => C::Maybe[C::PadBlocksizeArg] }) => C::ArrayOfShares def self.split(opts) TSS::Splitter.new(opts).split end @@ -88,26 +88,26 @@ def self.split(opts) # # @param [Hash] opts the options to create a message with. # @option opts [Array] :shares an Array of String shares to try to recombine into a secret - # @option opts [String] :select_by ('first') the method to use for selecting + # @option opts [String] :select_by ('FIRST') the method to use for selecting # shares from the Array if more then threshold shares are provided. Can be - # 'first', 'sample', or 'combinations'. + # upper case 'FIRST', 'SAMPLE', or 'COMBINATIONS'. # # If the number of shares provided as input to the secret # reconstruction operation is greater than the threshold M, then M # of those shares are selected for use in the operation. The method # used to select the shares can be chosen using the following values: # - # `first` : If X shares are required by the threshold and more than X + # `FIRST` : If X shares are required by the threshold and more than X # shares are provided, then the first X shares in the Array of shares provided # will be used. All others will be discarded and the operation will fail if # those selected shares cannot recreate the secret. # - # `sample` : If X shares are required by the threshold and more than X + # `SAMPLE` : If X shares are required by the threshold and more than X # shares are provided, then X shares will be randomly selected from the Array # of shares provided. All others will be discarded and the operation will # fail if those selected shares cannot recreate the secret. # - # `combinations` : If X shares are required, and more than X shares are + # `COMBINATIONS` : If X shares are required, and more than X shares are # provided, then all possible combinations of the threshold number of shares # will be tried to see if the secret can be recreated. # This flexibility comes with a cost. All combinations of `threshold` shares @@ -120,11 +120,11 @@ def self.split(opts) # of combinations will be too large then the an Exception will be raised before # processing has started. # - # @return [Hash] a Hash containing the ':secret' and other metadata + # @return a Hash containing the now combined secret and other metadata # @raise [TSS::NoSecretError] if the secret cannot be re-created from the shares provided # @raise [TSS::InvalidSecretHashError] if the embedded hash of the secret does not match the hash of the recreated secret # @raise [ParamContractError, TSS::ArgumentError] if the options Types or Values are invalid - Contract ({ :shares => C::ArrayOf[String], :select_by => C::Maybe[C::Enum['first', 'sample', 'combinations']] }) => ({ :hash => C::Maybe[String], :hash_alg => C::Enum['NONE', 'SHA1', 'SHA256'], :identifier => TSS::IdentifierArg, :process_time => C::Num, :secret => TSS::SecretArg, :threshold => TSS::ThresholdArg}) + Contract ({ :shares => C::ArrayOfShares, :select_by => C::Maybe[C::SelectByArg] }) => ({ :hash => C::Maybe[String], :hash_alg => C::HashAlgArg, :identifier => C::IdentifierArg, :process_time => C::Num, :secret => C::SecretArg, :threshold => C::ThresholdArg}) def self.combine(opts) TSS::Combiner.new(opts).combine end diff --git a/lib/tss/util.rb b/lib/tss/util.rb index a542958..3627d78 100644 --- a/lib/tss/util.rb +++ b/lib/tss/util.rb @@ -217,8 +217,8 @@ def self.lagrange_interpolation(u, v) # Convert a UTF-8 String to an Array of Bytes # - # @param str [String] a UTF-8 String to convert - # @return [Array] an Array of Integer Bytes + # @param str a UTF-8 String to convert + # @return an Array of Integer Bytes Contract String => C::ArrayOf[C::Int] def self.utf8_to_bytes(str) str.bytes.to_a @@ -226,8 +226,8 @@ def self.utf8_to_bytes(str) # Convert an Array of Bytes to a UTF-8 String # - # @param bytes [Array] an Array of Bytes to convert - # @return [String] a UTF-8 String + # @param bytes an Array of Bytes to convert + # @return a UTF-8 String Contract C::ArrayOf[C::Int] => String def self.bytes_to_utf8(bytes) bytes.pack('C*').force_encoding('utf-8') @@ -235,8 +235,8 @@ def self.bytes_to_utf8(bytes) # Convert an Array of Bytes to a hex String # - # @param bytes [Array] an Array of Bytes to convert - # @return [String] a hex String + # @param bytes an Array of Bytes to convert + # @return a hex String Contract C::ArrayOf[C::Int] => String def self.bytes_to_hex(bytes) hex = '' @@ -246,8 +246,8 @@ def self.bytes_to_hex(bytes) # Convert a hex String to an Array of Bytes # - # @param str [String] a hex String to convert - # @return [Array] an Array of Integer Bytes + # @param str a hex String to convert + # @return an Array of Integer Bytes Contract String => C::ArrayOf[C::Int] def self.hex_to_bytes(str) [str].pack('H*').unpack('C*') @@ -255,8 +255,8 @@ def self.hex_to_bytes(str) # Convert a hex String to a UTF-8 String # - # @param hex [String] a hex String to convert - # @return [String] a UTF-8 String + # @param hex a hex String to convert + # @return a UTF-8 String Contract String => String def self.hex_to_utf8(hex) bytes_to_utf8(hex_to_bytes(hex)) @@ -264,8 +264,8 @@ def self.hex_to_utf8(hex) # Convert a UTF-8 String to a hex String # - # @param str [String] a UTF-8 String to convert - # @return [String] a hex String + # @param str a UTF-8 String to convert + # @return a hex String Contract String => String def self.utf8_to_hex(str) bytes_to_hex(utf8_to_bytes(str)) @@ -273,10 +273,10 @@ def self.utf8_to_hex(str) # Left pad a String with pad_char in multiples of byte_multiple # - # @param byte_multiple [Integer] pad in blocks of this size - # @param input_string [String] the String to pad - # @param pad_char [String] the String to pad with - # @return [String] a padded String + # @param byte_multiple pad in blocks of this size + # @param input_string the String to pad + # @param pad_char the String to pad with + # @return a padded String Contract C::Int, String, String => String def self.left_pad(byte_multiple, input_string, pad_char = "\u001F") return input_string if byte_multiple == 0 @@ -295,9 +295,9 @@ def self.left_pad(byte_multiple, input_string, pad_char = "\u001F") # via timing attacks. The user provided value should always be passed # in as the second parameter so as not to leak info about the secret. # - # @param a [String] the private value - # @param b [String] the user provided value - # @return [true, false] whether the strings match or not + # @param a the private value + # @param b the user provided value + # @return whether the strings match or not Contract String, String => C::Bool def self.secure_compare(a, b) return false unless a.bytesize == b.bytesize @@ -312,8 +312,8 @@ def self.secure_compare(a, b) # Extract the header data from a binary share. # Extra "\x00" padding in the identifier will be removed. # - # @param share [String] a binary octet share - # @return [Hash] header attributes + # @param share a binary octet share + # @return header attributes Contract String => Hash def self.extract_share_header(share) h = Splitter::SHARE_HEADER_STRUCT.decode(share) @@ -323,8 +323,8 @@ def self.extract_share_header(share) # Calculate the factorial for an Integer. # - # @param n [Integer] the Integer to calculate for - # @return [Integer] the factorial of n + # @param n the Integer to calculate for + # @return the factorial of n Contract C::Int => C::Int def self.factorial(n) (1..n).reduce(:*) || 1 @@ -340,9 +340,9 @@ def self.factorial(n) # * http://chriscontinanza.com/2010/10/29/Array.html # * http://stackoverflow.com/questions/2434503/ruby-factorial-function # - # @param n [Integer] the total number of shares - # @param r [Integer] the threshold number of shares - # @return [Integer] the number of possible combinations + # @param n the total number of shares + # @param r the threshold number of shares + # @return the number of possible combinations Contract C::Int, C::Int => C::Int def self.calc_combinations(n, r) factorial(n) / (factorial(r) * factorial(n - r)) @@ -350,9 +350,9 @@ def self.calc_combinations(n, r) # Converts an Integer into a delimiter separated String. # - # @param n [Integer] an Integer to convert - # @param delimiter [String] the String to delimit n in three Integer groups - # @return [String] the object converted into a comma separated String. + # @param n an Integer to convert + # @param delimiter the String to delimit n in three Integer groups + # @return the object converted into a comma separated String. Contract C::Int, String => String def self.int_commas(n, delimiter = ',') n.to_s.reverse.gsub(%r{([0-9]{3}(?=([0-9])))}, "\\1#{delimiter}").reverse diff --git a/test/tss_burn_brute.rb b/test/tss_burn_brute.rb index 4119796..6beb875 100644 --- a/test/tss_burn_brute.rb +++ b/test/tss_burn_brute.rb @@ -4,18 +4,18 @@ describe 'end-to-end burn-in test' do it 'must split and combine the secret properly using many combinations of options' do [0, 32, 128, 255].each do |pb| - ['human', 'binary'].each do |f| + ['HUMAN', 'BINARY'].each do |f| ['NONE', 'SHA1', 'SHA256'].each do |h| (1..10).each do |m| (m..10).each do |n| id = SecureRandom.hex(rand(1..8)) s = SecureRandom.hex(rand(1..8)) shares = TSS.split(secret: s, identifier: id, threshold: m, num_shares: n, hash_alg: h, format: f, pad_blocksize: pb) - shares.first.encoding.name.must_equal f == 'human' ? 'UTF-8' : 'ASCII-8BIT' + shares.first.encoding.name.must_equal f == 'HUMAN' ? 'UTF-8' : 'ASCII-8BIT' - ['first', 'sample', 'combinations'].each do |sb| + ['FIRST', 'SAMPLE', 'COMBINATIONS'].each do |sb| # can't use combinations with NONE - sb = (h == 'NONE') ? 'first' : sb + sb = (h == 'NONE') ? 'FIRST' : sb sec = TSS.combine(shares: shares, select_by: sb) sec[:secret].must_equal s sec[:secret].encoding.name.must_equal 'UTF-8' diff --git a/test/tss_combiner_validation_test.rb b/test/tss_combiner_validation_test.rb index 83d6240..80f411a 100644 --- a/test/tss_combiner_validation_test.rb +++ b/test/tss_combiner_validation_test.rb @@ -27,13 +27,13 @@ end it 'must raise an error if an too small empty Array is passed' do - assert_raises(TSS::ArgumentError) { TSS::Combiner.new(shares: []).combine } + assert_raises(ParamContractError) { TSS::Combiner.new(shares: []).combine } end it 'must raise an error if a too large Array is passed' do arr = [] 256.times { arr << 'foo' } - assert_raises(TSS::ArgumentError) { TSS::Combiner.new(shares: arr).combine } + assert_raises(ParamContractError) { TSS::Combiner.new(shares: arr).combine } end it 'must raise an error if an invalid share is passed' do @@ -73,21 +73,21 @@ describe 'when share_selection arg is set to first' do it 'must return a secret' do - secret = TSS::Combiner.new(shares: @shares, select_by: 'first').combine + secret = TSS::Combiner.new(shares: @shares, select_by: 'FIRST').combine secret[:secret].must_equal @secret end end describe 'when share_selection arg is set to sample' do it 'must return a secret' do - secret = TSS::Combiner.new(shares: @shares, select_by: 'sample').combine + secret = TSS::Combiner.new(shares: @shares, select_by: 'SAMPLE').combine secret[:secret].must_equal @secret end end describe 'when share_selection arg is set to combinations' do it 'must return a secret' do - secret = TSS::Combiner.new(shares: @shares, select_by: 'combinations').combine + secret = TSS::Combiner.new(shares: @shares, select_by: 'COMBINATIONS').combine secret[:secret].must_equal @secret end end diff --git a/test/tss_hasher_test.rb b/test/tss_hasher_test.rb index 4a69c20..134d224 100644 --- a/test/tss_hasher_test.rb +++ b/test/tss_hasher_test.rb @@ -3,27 +3,27 @@ describe TSS::Hasher do describe 'HASHES' do it 'must return a correct result' do - TSS::Hasher::HASHES.must_equal({ NONE: { code: 0, bytesize: 0, hasher: nil }, - SHA1: { code: 1, bytesize: 20, hasher: Digest::SHA1 }, - SHA256: { code: 2, bytesize: 32, hasher: Digest::SHA256 } }) + TSS::Hasher::HASHES.must_equal({ 'NONE' => { code: 0, bytesize: 0, hasher: nil }, + 'SHA1' => { code: 1, bytesize: 20, hasher: Digest::SHA1 }, + 'SHA256' => { code: 2, bytesize: 32, hasher: Digest::SHA256 } }) end end describe 'key_from_code for 0' do - it 'must return :NONE' do - TSS::Hasher.key_from_code(0).must_equal :NONE + it 'must return NONE' do + TSS::Hasher.key_from_code(0).must_equal 'NONE' end end describe 'key_from_code for 1' do - it 'must return :SHA1' do - TSS::Hasher.key_from_code(1).must_equal :SHA1 + it 'must return SHA1' do + TSS::Hasher.key_from_code(1).must_equal 'SHA1' end end describe 'key_from_code for 2' do - it 'must return :SHA256' do - TSS::Hasher.key_from_code(2).must_equal :SHA256 + it 'must return SHA256' do + TSS::Hasher.key_from_code(2).must_equal 'SHA256' end end @@ -33,21 +33,21 @@ end end - describe 'code for :NONE' do + describe 'code for NONE' do it 'must return 0' do - TSS::Hasher.code(:NONE).must_equal 0 + TSS::Hasher.code('NONE').must_equal 0 end end - describe 'code for :SHA1' do + describe 'code for SHA1' do it 'must return 1' do - TSS::Hasher.code(:SHA1).must_equal 1 + TSS::Hasher.code('SHA1').must_equal 1 end end - describe 'code for :SHA256' do + describe 'code for SHA256' do it 'must return 2' do - TSS::Hasher.code(:SHA256).must_equal 2 + TSS::Hasher.code('SHA256').must_equal 2 end end diff --git a/test/tss_splitter_validation_test.rb b/test/tss_splitter_validation_test.rb index 9f53ff7..076386e 100644 --- a/test/tss_splitter_validation_test.rb +++ b/test/tss_splitter_validation_test.rb @@ -191,26 +191,26 @@ assert_raises(ParamContractError) { TSS::Splitter.new(secret: 'a', format: 'alien').split } end - it 'must default to human format output when no param is passed' do + it 'must default to HUMAN format output when no param is passed' do s = TSS::Splitter.new(secret: 'a').split s.first.must_match(/^tss~/) end - it 'must default to human format output when nil is passed' do + it 'must default to HUMAN format output when nil is passed' do s = TSS::Splitter.new(secret: 'a', format: nil).split s.first.must_match(/^tss~/) end - it 'must accept a human option' do - s = TSS::Splitter.new(secret: 'a', format: 'human').split + it 'must accept a HUMAN option' do + s = TSS::Splitter.new(secret: 'a', format: 'HUMAN').split s.first.encoding.to_s.must_equal 'UTF-8' s.first.must_match(/^tss~/) secret = TSS::Combiner.new(shares: s).combine secret[:secret].must_equal 'a' end - it 'must accept a binary option' do - s = TSS::Splitter.new(secret: 'a', format: 'binary').split + it 'must accept a BINARY option' do + s = TSS::Splitter.new(secret: 'a', format: 'BINARY').split s.first.encoding.to_s.must_equal 'ASCII-8BIT' secret = TSS::Combiner.new(shares: s).combine secret[:secret].must_equal 'a' @@ -228,13 +228,13 @@ describe 'when padding arg is set' do it 'must return a correctly sized share' do - share_0 = TSS::Splitter.new(secret: 'a', hash_alg: 'NONE', pad_blocksize: 0, format: 'binary').split + share_0 = TSS::Splitter.new(secret: 'a', hash_alg: 'NONE', pad_blocksize: 0, format: 'BINARY').split share_0.first.length.must_equal 22 - share_8 = TSS::Splitter.new(secret: 'a', hash_alg: 'NONE', pad_blocksize: 8, format: 'binary').split + share_8 = TSS::Splitter.new(secret: 'a', hash_alg: 'NONE', pad_blocksize: 8, format: 'BINARY').split share_8.first.length.must_equal 29 - share_16 = TSS::Splitter.new(secret: 'a', hash_alg: 'NONE', pad_blocksize: 16, format: 'binary').split + share_16 = TSS::Splitter.new(secret: 'a', hash_alg: 'NONE', pad_blocksize: 16, format: 'BINARY').split share_16.first.length.must_equal 37 end end diff --git a/test/tss_test.rb b/test/tss_test.rb index 756a08b..90f5b9b 100644 --- a/test/tss_test.rb +++ b/test/tss_test.rb @@ -21,11 +21,11 @@ describe 'with common args' do it 'must split and combine the secret properly' do [0, 8, 16].each do |pb| - ['human', 'binary'].each do |f| + ['HUMAN', 'BINARY'].each do |f| ['NONE', 'SHA1', 'SHA256'].each do |h| ['a', 'unicode ½ ♥ 💩', SecureRandom.hex(32).force_encoding('US-ASCII')].each do |s| shares = TSS.split(secret: s, hash_alg: h, format: f, pad_blocksize: pb) - shares.first.encoding.name.must_equal f == 'human' ? 'UTF-8' : 'ASCII-8BIT' + shares.first.encoding.name.must_equal f == 'HUMAN' ? 'UTF-8' : 'ASCII-8BIT' sec = TSS.combine(shares: shares) sec[:hash].must_equal h == 'NONE' ? nil : TSS::Hasher.hex_string(h, s) sec[:hash_alg].must_equal h diff --git a/tss.gemspec b/tss.gemspec index a159f70..6b4bc88 100644 --- a/tss.gemspec +++ b/tss.gemspec @@ -60,4 +60,5 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'coveralls', '~> 0.8' spec.add_development_dependency 'coco', '~> 0.14' spec.add_development_dependency 'wwtd', '~> 1.3' + spec.add_development_dependency 'yard-contracts', '~> 0.1' end