diff --git a/README.md b/README.md index b0a7257..5329aef 100644 --- a/README.md +++ b/README.md @@ -72,14 +72,34 @@ param = WOTS::Param::SHA512 param = WOTS::Param::SHAKE256 ``` +## Input format + +All key material, seeds and messages are **even-length hexadecimal strings**. Raw binary strings and +odd-length hex strings are rejected with an `ArgumentError`; convert them with `unpack1('H*')` first. + +```ruby +message = Digest::SHA256.hexdigest("some payload") # 64 hex chars = 32 bytes +signature = private_key.sign(pub_seed, message) +``` + ## Security Considerations -**⚠️ IMPORTANT: This is a cryptograFixphic library implementation. While it follows RFC 8391 specifications, +**⚠️ IMPORTANT: This is a cryptographic library implementation. While it follows RFC 8391 specifications, it has not undergone formal security audits. Use at your own risk in production environments.** WOTS+ is a **one-time signature scheme**. Each private key should only be used to sign **one message**. Reusing a private key to sign multiple messages can leak information about the private key and compromise security. +The seed passed to `WOTS::PrivateKey.from_seed` is secret key material and must come from a +cryptographically secure random number generator, e.g. `SecureRandom.hex(param.n)`. + +Only the Winternitz parameters defined in RFC 8391 (`w = 4` and `w = 16`) are accepted. Other values +overflow the checksum encoding, which drops the high bits of the checksum and allows an attacker to +forge signatures without the private key. + +Private key material is held in Ruby `String` objects and is not zeroized; it remains in memory until +garbage collected. + ## Specifications This implementation follows [RFC 8391 - XMSS: eXtended Merkle Signature Scheme](https://datatracker.ietf.org/doc/html/rfc8391), Section 3 (WOTS+). diff --git a/lib/wots/address.rb b/lib/wots/address.rb index f5ae6a7..0dc1f1f 100644 --- a/lib/wots/address.rb +++ b/lib/wots/address.rb @@ -1,19 +1,50 @@ module WOTS + # WOTS+ hash function address. + # Each field is packed into a fixed width big endian integer by #to_payload, + # so a value outside its range would silently wrap and collide with another + # address, breaking the domain separation the chaining function relies on. class Address - attr_accessor :layer_addr - attr_accessor :tree_addr - attr_accessor :ots_addr - attr_accessor :chain_addr - attr_accessor :hash_addr - attr_accessor :key_and_mask # 0: key generation, 1: bitmask generation + UINT32_MAX = 0xFFFFFFFF + UINT64_MAX = 0xFFFFFFFFFFFFFFFF + + attr_reader :layer_addr + attr_reader :tree_addr + attr_reader :ots_addr + attr_reader :chain_addr + attr_reader :hash_addr + attr_reader :key_and_mask # 0: key generation, 1: bitmask generation def initialize(layer_addr: 0, tree_addr: 0, ots_addr: 0, chain_addr: 0, hash_addr: 0, key_and_mask: 0) - @layer_addr = layer_addr - @tree_addr = tree_addr - @ots_addr = ots_addr - @chain_addr = chain_addr - @hash_addr = hash_addr - @key_and_mask = key_and_mask + self.layer_addr = layer_addr + self.tree_addr = tree_addr + self.ots_addr = ots_addr + self.chain_addr = chain_addr + self.hash_addr = hash_addr + self.key_and_mask = key_and_mask + end + + def layer_addr=(value) + @layer_addr = check_range(:layer_addr, value, UINT32_MAX) + end + + def tree_addr=(value) + @tree_addr = check_range(:tree_addr, value, UINT64_MAX) + end + + def ots_addr=(value) + @ots_addr = check_range(:ots_addr, value, UINT32_MAX) + end + + def chain_addr=(value) + @chain_addr = check_range(:chain_addr, value, UINT32_MAX) + end + + def hash_addr=(value) + @hash_addr = check_range(:hash_addr, value, UINT32_MAX) + end + + def key_and_mask=(value) + @key_and_mask = check_range(:key_and_mask, value, UINT32_MAX) end def type @@ -23,5 +54,15 @@ def type def to_payload [layer_addr, tree_addr, type, ots_addr, chain_addr, hash_addr, key_and_mask].pack('NQ>NNNNN') end + + private + + # @raise ArgumentError If +value+ would not survive the round trip through +pack+. + def check_range(name, value, max) + raise ArgumentError, "#{name} must be integer." unless value.is_a?(Integer) + raise ArgumentError, "#{name} must be between 0 and #{max}." unless value >= 0 && value <= max + + value + end end -end \ No newline at end of file +end diff --git a/lib/wots/param.rb b/lib/wots/param.rb index 5c8b029..d5e1e5a 100644 --- a/lib/wots/param.rb +++ b/lib/wots/param.rb @@ -10,25 +10,48 @@ class Param autoload :SHA512, 'wots/param/sha512' autoload :SHAKE256, 'wots/param/shake256' + # The parameter sets defined in RFC 8391 and the +n+ they require. + SUPPORTED_NAMES = { + 'WOTSP-SHA2_256' => 32, + 'WOTSP-SHA2_512' => 64, + 'WOTSP-SHAKE_256' => 32 + }.freeze + + # The Winternitz parameter is a member of the set {4, 16}. + # Other values break the checksum encoding below and allow signature forgery. + SUPPORTED_W = [4, 16].freeze + attr_reader :name attr_reader :n attr_reader :w # @param [Hash] opts + # @option opts [String] :name the name of the parameter set; it is a member of +SUPPORTED_NAMES+. # @option opts [Integer] :n the message length as well as the length of a private key, # public key, or signature element in bytes. # @option opts [Integer] :w the Winternitz parameter; it is a member of the set {4, 16}. - # @option opts [Integer] :len the number of n-byte string elements in a WOTS+ private key, public key, and signature. + # @raise ArgumentError def initialize(opts) raise ArgumentError, 'name must be string.' unless opts[:name].is_a?(String) raise ArgumentError, 'n must be integer.' unless opts[:n].is_a?(Integer) raise ArgumentError, 'w must be integer.' unless opts[:w].is_a?(Integer) + expected_n = SUPPORTED_NAMES[opts[:name]] + raise ArgumentError, "name must be one of #{SUPPORTED_NAMES.keys.join(', ')}." if expected_n.nil? + raise ArgumentError, "n must be #{expected_n} for #{opts[:name]}." unless opts[:n] == expected_n + raise ArgumentError, "w must be one of #{SUPPORTED_W.join(', ')}." unless SUPPORTED_W.include?(opts[:w]) + @name = opts[:name] @n = opts[:n] @w = opts[:w] end + # log2(w), i.e. the number of bits consumed by a single base w digit. + # @return [Integer] + def lg_w + @lg_w ||= Math.log2(w).to_i + end + def len1 @len1 ||= (8.0 * n / Math.log2(w)).ceil end @@ -41,42 +64,55 @@ def len @len ||= len1 + len2 end + # @param [String] k Hex string. + # @param [String] m Hex string. + # @return [String] Hex string. def f(k, m) keyed_hash(0, k, m) end + # @param [String] k Hex string. + # @param [String] m Hex string. + # @return [String] Hex string. def h(k, m) keyed_hash(1, k, m) end + # @param [String] k Hex string. + # @param [String] m Hex string. + # @return [String] Hex string. def h_msg(k, m) keyed_hash(2, k, m) end # PRF function. - # @param [String] k key - # @param [String] m message + # @param [String] k key Hex string. + # @param [String] m message Hex string. # @return [String] Hex string. def prf(k, m) keyed_hash(3, k, m) end # Convert data as base w representation. - # @param [String] data The data to be converted. + # @param [String] data The data to be converted. Hex string. # @param [Integer] out_len Output length. # @return [Array] An array of integer. + # @raise ArgumentError If +data+ is too short to produce +out_len+ digits. def base_w(data, out_len) x = hex_to_bin(data) + raise ArgumentError, 'out_len must be integer.' unless out_len.is_a?(Integer) + raise ArgumentError, 'out_len must be positive.' unless out_len.positive? + + required = ((out_len * lg_w) / 8.0).ceil + raise ArgumentError, "data must be at least #{required} bytes to produce #{out_len} digits." if x.bytesize < required basew = [] in_idx = 0 total = 0 bits = 0 - lg_w = Math.log2(w).to_i out_len.times do if bits == 0 - break if in_idx >= x.bytesize total = x.getbyte(in_idx) in_idx += 1 bits += 8 @@ -91,28 +127,35 @@ def base_w(data, out_len) # Compute checksum for +base_w+. # @param [Array] base_w - # @return [String] Checksum binary string. + # @return [String] Checksum hex string. def compute_checksum(base_w) c_sum = 0 len1.times do |i| c_sum = (c_sum + w - 1 - base_w[i]) end - c_sum = (c_sum << (8 - ((len2 * Math.log2(w).to_i) % 8))) - len_2_bytes = ((len2 * Math.log2(w).to_i) / 8.0).ceil - to_byte(c_sum, len_2_bytes) + c_sum = (c_sum << (8 - ((len2 * lg_w) % 8))) + len_2_bytes = ((len2 * lg_w) / 8.0).ceil + bin_to_hex(to_byte(c_sum, len_2_bytes)) end # WOTS+ Chaining Function. # @see https://datatracker.ietf.org/doc/html/rfc8391#autoid-16 - # @param [String] x Input string. + # @param [String] x Input string. Hex string. # @param [Integer] start_idx Start index. # @param [Integer] steps Number of steps. - # @param [String] seed Seed. + # @param [String] seed Seed. Hex string. # @param [WOTS::Address] addr Address. - # @return [String] Result. + # @return [String] Hex string. + # @raise ArgumentError def chain(x, start_idx, steps, seed, addr) + raise ArgumentError, 'x must be hex string.' unless hex_string?(x) + raise ArgumentError, 'start_idx must be integer.' unless start_idx.is_a?(Integer) + raise ArgumentError, 'steps must be integer.' unless steps.is_a?(Integer) + raise ArgumentError, 'start_idx must not be negative.' if start_idx.negative? + raise ArgumentError, 'steps must not be negative.' if steps.negative? + raise ArgumentError, 'Invalid range' if (start_idx + steps) > (w - 1) + return x if steps == 0 - raise "Invalid range" if (start_idx + steps) > (w - 1) result = x.dup @@ -120,10 +163,10 @@ def chain(x, start_idx, steps, seed, addr) addr.hash_addr = (start_idx + i) addr.key_and_mask = 0 - key = prf(seed, addr.to_payload) + key = prf(seed, bin_to_hex(addr.to_payload)) addr.key_and_mask = 1 - mask = prf(seed, addr.to_payload) + mask = prf(seed, bin_to_hex(addr.to_payload)) masked = xor_bytes(result, mask) result = f(key, masked) @@ -140,8 +183,14 @@ def ==(other) # Convert +value+ to +length+ size binary string. # @param [Integer] value # @param [Integer] length - # @return [String] + # @return [String] Binary string. + # @raise ArgumentError If +value+ does not fit in +length+ bytes. def to_byte(value, length) + raise ArgumentError, 'value must be integer.' unless value.is_a?(Integer) + raise ArgumentError, 'length must be integer.' unless length.is_a?(Integer) + raise ArgumentError, 'value must not be negative.' if value.negative? + raise ArgumentError, "value does not fit in #{length} bytes." if value.bit_length > length * 8 + bytes = [] length.times do @@ -154,12 +203,22 @@ def to_byte(value, length) private + # XOR two hex strings of the same length. + # @param [String] a Hex string. + # @param [String] b Hex string. + # @return [String] Hex string. def xor_bytes(a, b) - [a].pack('H*').unpack('C*').zip( - [b].pack('H*').unpack('C*') - ).map { |x, y| x ^ y }.pack("C*") + x = hex_to_bin(a) + y = hex_to_bin(b) + raise ArgumentError, 'length mismatch.' unless x.bytesize == y.bytesize + + bin_to_hex(x.unpack('C*').zip(y.unpack('C*')).map { |i, j| i ^ j }.pack("C*")) end + # @param [Integer] prefix Domain separation prefix. + # @param [String] k Hex string. + # @param [String] m Hex string. + # @return [String] Hex string. def keyed_hash(prefix, k, m) payload = to_byte(prefix, n) + hex_to_bin(k) + hex_to_bin(m) case name @@ -174,4 +233,4 @@ def keyed_hash(prefix, k, m) end end end -end \ No newline at end of file +end diff --git a/lib/wots/private_key.rb b/lib/wots/private_key.rb index 6008229..d455ad6 100644 --- a/lib/wots/private_key.rb +++ b/lib/wots/private_key.rb @@ -22,30 +22,33 @@ def initialize(param, keys) # Generate private key using +seed+. # @param [WOTS::Param] param - # @param [String] seed + # @param [String] seed The secret seed. Hex string of +param.n+ bytes. + # It must be generated with a cryptographically secure random number generator. # @raise ArgumentError def self.from_seed(param, seed) raise ArgumentError, "param must be WOTS::Param." unless param.is_a?(WOTS::Param) - raise ArgumentError, "seed must be String." unless seed.is_a?(String) + raise ArgumentError, "seed must be hex string." unless hex_string?(seed) raise ArgumentError, "seed must be #{param.n} bytes." unless hex_to_bin(seed).bytesize == param.n raise ArgumentError, "len parameter too large." if param.len.bit_length > 16 keys = param.len.times.map do |i| - param.prf(seed, param.to_byte(i, 32)) + param.prf(seed, bin_to_hex(param.to_byte(i, 32))) end PrivateKey.new(param, keys) end # Generate signature. - # @param [String] pub_seed The Public seed. - # @param [String] message The message to be signed. + # WOTS+ is a one-time signature scheme: this private key must never be used + # to sign more than one message. + # @param [String] pub_seed The Public seed. Hex string of +param.n+ bytes. + # @param [String] message The message to be signed. Hex string of +param.n+ bytes. # @return [WOTS::Signature] # @raise ArgumentError def sign(pub_seed, message) raise ArgumentError, 'pub_seed must be hex string.' unless hex_string?(pub_seed) raise ArgumentError, "pub_seed must be #{param.n} bytes." unless hex_to_bin(pub_seed).bytesize == param.n - raise ArgumentError, "message must be string." unless message.is_a?(String) + raise ArgumentError, "message must be hex string." unless hex_string?(message) raise ArgumentError, "message must be #{param.n} bytes." unless hex_to_bin(message).bytesize == param.n addr = Address.new @@ -65,5 +68,13 @@ def sign(pub_seed, message) Signature.new(param, sigs) end + + # Redacted inspect. + # The default implementation prints every instance variable, which would + # write the whole private key into logs, exception reports and REPL output. + # @return [String] + def inspect + "#" + end end end \ No newline at end of file diff --git a/lib/wots/public_key.rb b/lib/wots/public_key.rb index 6b1706c..eaa4a2d 100644 --- a/lib/wots/public_key.rb +++ b/lib/wots/public_key.rb @@ -21,7 +21,8 @@ def initialize(param, keys) # Generate public key from +private_key+ and +pub_seed+. # @param [WOTS::PrivateKey] private_key - # @param [String] pub_seed + # @param [String] pub_seed Hex string of +param.n+ bytes. + # @raise ArgumentError def self.from_private_key(private_key, pub_seed) raise ArgumentError, 'private_key must be WOTS::PrivateKey.' unless private_key.is_a?(WOTS::PrivateKey) param = private_key.param @@ -38,15 +39,16 @@ def self.from_private_key(private_key, pub_seed) # Generate public key from +signature+. # @param [WOTS::Signature] signature - # @param [String] pub_seed - # @param [String] message + # @param [String] pub_seed Hex string of +param.n+ bytes. + # @param [String] message Hex string of +param.n+ bytes. # @return [WOTS::PublicKey] # @raise ArgumentError def self.from_signature(signature, pub_seed, message) + raise ArgumentError, 'signature must be WOTS::Signature.' unless signature.is_a?(WOTS::Signature) param = signature.param raise ArgumentError, 'pub_seed must be hex string.' unless hex_string?(pub_seed) raise ArgumentError, "pub_seed must be #{param.n} bytes." unless hex_to_bin(pub_seed).bytesize == param.n - raise ArgumentError, "message must be string." unless message.is_a?(String) + raise ArgumentError, "message must be hex string." unless hex_string?(message) raise ArgumentError, "message must be #{param.n} bytes." unless hex_to_bin(message).bytesize == param.n base_w = param.base_w(message, param.len1) diff --git a/lib/wots/util.rb b/lib/wots/util.rb index 52a15e5..9ed4820 100644 --- a/lib/wots/util.rb +++ b/lib/wots/util.rb @@ -2,31 +2,33 @@ module WOTS module Util # Check whether +data+ is hex string or not. + # An odd-length string is NOT a valid hex string, since +Array#pack+ would + # silently zero-pad it and make two distinct inputs collide. # @param [String] data # @return [Boolean] # @raise [ArgumentError] def hex_string?(data) raise ArgumentError, 'data must be string' unless data.is_a?(String) - data.match?(/\A[0-9a-fA-F]+\z/) + data.length.even? && data.match?(/\A[0-9a-fA-F]+\z/) end # Convert hex string +data+ to binary. - # @param [String] data - # @return [String] - # @raise [ArgumentError] + # @param [String] data Hex string. + # @return [String] Binary string. + # @raise [ArgumentError] If +data+ is not a hex string. def hex_to_bin(data) - raise ArgumentError, 'data must be string' unless data.is_a?(String) - hex_string?(data) ? [data].pack('H*') : data + raise ArgumentError, 'data must be hex string' unless hex_string?(data) + [data].pack('H*') end # Convert binary string +data+ to hex string. - # @param [String] data - # @return [String] + # @param [String] data Binary string. + # @return [String] Hex string. # @raise [ArgumentError] def bin_to_hex(data) raise ArgumentError, 'data must be string' unless data.is_a?(String) - hex_string?(data) ? data : data.unpack1('H*') + data.unpack1('H*') end end -end \ No newline at end of file +end diff --git a/lib/wots/version.rb b/lib/wots/version.rb index 1c1675b..cf4c0b4 100644 --- a/lib/wots/version.rb +++ b/lib/wots/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module WOTS - VERSION = "0.1.0" + VERSION = "0.2.0" end diff --git a/spec/fixtures/key.json b/spec/fixtures/key.json index 858b0c9..205832e 100644 --- a/spec/fixtures/key.json +++ b/spec/fixtures/key.json @@ -274,41 +274,5 @@ "3ae89f4bf5180486c734b30931f9de378c95baa7d4512de7ed153f588f880c28", "da5f4d8921a67f7a665bc13e236ede83eab192cda0cb2a76e38a41473ae41a7b", "3da5df115a98e8b859512434ebf9e57dba45d2e2fe79afdea1ff76a39dbb65bf" - ], - "signatureW256": [ - "59365dc9bd119bdb98450caea4d070f016ee74cf43065698f9664f0aa1813620", - "b2607636707d81094400cfd6dae2fc5ab0e8914405c33dc6ae36ee6db9eaaadb", - "b8ac76ccc3f1ec3578119781aa255a2d63b2903d718af7e2deecb9296dbaf285", - "00eafbe7f07d24cadf3a5b75e4aafb604e03f0fedb1ce57394d7fb65c0155c61", - "0abe3300a22634b7f857893099b0f068ddd3fe6c4ec27621fe8895faf94aa8ce", - "5c92f1ea210af3d3717a17ede3143e7058fde15e09fd52cae46161d8bffb5586", - "88e1a3a1978cbc494b16c8268b79fc823c4939383e3a7c840e15b4e79bfcbc5b", - "9eed54eb5ae31bd94017f1002cfde2a2fef067cafe59f48b4d7bf639fcd6a76d", - "37987f432ed86e2c7a060f390664637e48cc45b186d26df5f073d5384ba86a3a", - "8be91b2f6de07a0dfb08f31892b34f3629485761748b00f7e466aedec27aba77", - "aefac21f85a2f35e734f98aaff55cd27fd90611a5713519b203e3f8da4ae4ce4", - "14a4a244f3fc6c5db71ababbe193f99d02c2b7f0b9270915b4427756b0023e6d", - "887c3c0fb9904cc7c6fd2e4a9850cbdcf74cd68ed5df604f8d1d62bd4020e769", - "09f00e0cb5df4e09d48a02172876942253b9c8126346ce61cc002f141859ad20", - "d9503251709feb463104972351554efd68b203fb05d4811d8bfc8239191b1fe3", - "b19e7db15fd22f8a9fee1224640b0da4d3340cd22806e59ee7cf7007e10606e2", - "525ca446856e6627c31fe1d3a8c09d0f29edb7ae16d1d696c43e02bd1916d80f", - "32bf6c1654a14b39b4bdc39eec48b942528c1530ed2452df75b243a047bd885f", - "78708dbade0db20b023ac6266e35106e7ea24cecb13e3f4e674bb7385860b4db", - "3daf69080b101dc4ad4faec2cc634296d80b7dbed4b0d52e042e248f730186c3", - "2da97be7c511ea3e33acb23504648990a9de0ff0248526a85b4fdacaa841528d", - "98e959bcfa07687070d41da96ac71687f02db22a89684e22bcbf15bf32d4c1a6", - "cd3c8f8cb23880bca1a0cf2c3daf94f16885939e8710d4c60246e25f77cf740e", - "c51d448f7adff05a8842dca6b8f78260c7f62e333850f4f0446631bed6d6771d", - "e8686028796de0283a73a104171dba030b905cfdee1441e84b45e1e68a6b637b", - "5f2de420edd1d1bf78654a5502e6f5c52cde9d7112d00f0bba042365979c17c0", - "ae4209dc44ca66a8d622d48b95a8a4de850cb7cd9796f9982147f54d9f697693", - "f8976a34e31e03e330fd3c3029db064459894a2ba2319b8bf1bf664985ba902f", - "85ecc8b44bb956dbe0c6b1a08802217c447e1707f3928a9b2ec86144e9e58f91", - "a3dcc3307232575fede7e6a8aaf1d6290a62584d70db24b277e9daa62d300be7", - "00341ec5a06458324911bfa92596a06aead6492b589cdfa83bfc14b756aab60e", - "ae7c89a75514fb57ae0565d153d438cf8a2ceb7818260ff7e92148cce09b46e1", - "3078109acf41d96c71b8b8c7e415ead2328137dcea928654c76da59ef8a2b077", - "9c65b0d740219df0c18c4937b8f8614b0a06917395f24133821068bc49884215" ] -} \ No newline at end of file +} diff --git a/spec/security_spec.rb b/spec/security_spec.rb new file mode 100644 index 0000000..320f391 --- /dev/null +++ b/spec/security_spec.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true +require 'spec_helper' +require 'pp' +require 'stringio' + +RSpec.describe 'Input validation and parameter safety' do + let(:param) { WOTS::Param::SHA256 } + let(:seed) { '693141c7ee701d13e1a7c733e0aa8326c19961429bfb54083f2f65b30c32e20b' } + let(:pub_seed) { '46ece585b4c0bfa1186209270e22fa07c4716461b5a026c268e594fb94404f3a' } + let(:message) { 'f16c96e88fb99a8287a43121962e89ed521699fa3e126c67eaaa168066354477' } + let(:private_key) { WOTS::PrivateKey.from_seed(param, seed) } + + describe 'WOTS::Param' do + it 'only accepts the Winternitz parameters defined in RFC 8391' do + # Any other w overflows the checksum encoding in #compute_checksum, which + # drops the high bits of the checksum and allows signature forgery. + [2, 3, 5, 8, 32, 256, 1024].each do |w| + expect { WOTS::Param.new(name: 'WOTSP-SHA2_256', n: 32, w: w) }.to raise_error(ArgumentError) + end + expect { WOTS::Param.new(name: 'WOTSP-SHA2_256', n: 32, w: 4) }.not_to raise_error + expect { WOTS::Param.new(name: 'WOTSP-SHA2_256', n: 32, w: 16) }.not_to raise_error + end + + it 'rejects an unknown parameter set name' do + expect { WOTS::Param.new(name: 'WOTSP-MD5', n: 32, w: 16) }.to raise_error(ArgumentError) + end + + it 'rejects an n that does not match the hash function' do + expect { WOTS::Param.new(name: 'WOTSP-SHA2_512', n: 32, w: 16) }.to raise_error(ArgumentError) + expect { WOTS::Param.new(name: 'WOTSP-SHA2_256', n: 64, w: 16) }.to raise_error(ArgumentError) + end + + it 'keeps the checksum within its encoded length for every supported parameter set' do + WOTS::Param::SUPPORTED_NAMES.each do |name, n| + WOTS::Param::SUPPORTED_W.each do |w| + p = WOTS::Param.new(name: name, n: n, w: w) + max_c_sum = p.len1 * (p.w - 1) + shifted = max_c_sum << (8 - ((p.len2 * p.lg_w) % 8)) + len_2_bytes = ((p.len2 * p.lg_w) / 8.0).ceil + expect(shifted.bit_length).to be <= len_2_bytes * 8 + + expect { p.compute_checksum(Array.new(p.len1, 0)) }.not_to raise_error + expect { p.compute_checksum(Array.new(p.len1, p.w - 1)) }.not_to raise_error + end + end + end + end + + describe 'WOTS::Param#to_byte' do + it 'refuses to silently truncate a value that does not fit' do + expect { param.to_byte(0x10000, 2) }.to raise_error(ArgumentError) + expect { param.to_byte(-1, 2) }.to raise_error(ArgumentError) + expect(param.to_byte(0xFFFF, 2)).to eq("\xFF\xFF".b) + end + end + + describe 'WOTS::Param#base_w' do + it 'refuses input that is too short to produce out_len digits' do + expect { param.base_w('ab', param.len1) }.to raise_error(ArgumentError) + expect(param.base_w(message, param.len1).length).to eq(param.len1) + end + end + + describe 'WOTS::Param#chain' do + it 'rejects an out-of-range chain, even for zero steps' do + addr = WOTS::Address.new + expect { param.chain(message, param.w - 1, 1, pub_seed, addr) }.to raise_error(ArgumentError) + expect { param.chain(message, param.w, 0, pub_seed, addr) }.to raise_error(ArgumentError) + expect { param.chain(message, 0, -1, pub_seed, addr) }.to raise_error(ArgumentError) + end + end + + describe 'WOTS::Util' do + let(:util) { Object.new.extend(WOTS::Util) } + + it 'does not treat an odd-length string as hex' do + # ["abc"].pack('H*') and ["abc0"].pack('H*') both yield "\xAB\xC0", + # so accepting odd-length hex would make two distinct inputs collide. + expect(util.hex_string?('abc')).to be false + expect(util.hex_string?('abc0')).to be true + expect { util.hex_to_bin('abc') }.to raise_error(ArgumentError) + end + + it 'does not silently pass through non-hex input' do + expect { util.hex_to_bin("\xAB\xC0".b) }.to raise_error(ArgumentError) + end + + it 'always hex-encodes in bin_to_hex' do + expect(util.bin_to_hex('abcd')).to eq('61626364') + end + end + + describe 'WOTS::Address' do + it 'rejects a value that would wrap when packed' do + # Wrapping would make two different addresses hash identically and break + # the domain separation of the chaining function. + expect { WOTS::Address.new(ots_addr: 2**32) }.to raise_error(ArgumentError) + expect { WOTS::Address.new(tree_addr: 2**64) }.to raise_error(ArgumentError) + expect { WOTS::Address.new(chain_addr: -1) }.to raise_error(ArgumentError) + + addr = WOTS::Address.new + expect { addr.chain_addr = 2**32 }.to raise_error(ArgumentError) + expect { addr.hash_addr = -1 }.to raise_error(ArgumentError) + expect { addr.key_and_mask = nil }.to raise_error(ArgumentError) + end + + it 'accepts the full range of each field' do + addr = WOTS::Address.new( + layer_addr: WOTS::Address::UINT32_MAX, + tree_addr: WOTS::Address::UINT64_MAX, + ots_addr: WOTS::Address::UINT32_MAX, + chain_addr: WOTS::Address::UINT32_MAX, + hash_addr: WOTS::Address::UINT32_MAX, + key_and_mask: WOTS::Address::UINT32_MAX + ) + expect(addr.to_payload.bytesize).to eq(32) + expect(WOTS::Address.new.to_payload.bytesize).to eq(32) + end + end + + describe 'WOTS::PrivateKey#inspect' do + it 'does not print key material' do + expect(private_key.inspect).not_to include(private_key.keys.first) + expect(private_key.inspect).to include('REDACTED') + end + + it 'does not print key material through pp or interpolation' do + out = StringIO.new + PP.pp(private_key, out) + expect(out.string).not_to include(private_key.keys.first) + expect("#{private_key}").not_to include(private_key.keys.first) + end + end + + describe 'signing and verification input' do + it 'rejects an odd-length hex message' do + # A 63 character string used to pass the "32 bytes" check and produce the + # same signature as its 64 character counterpart. + odd = message[0...-1] + expect(odd.length).to eq(63) + expect { private_key.sign(pub_seed, odd) }.to raise_error(ArgumentError) + end + + it 'rejects a raw binary message' do + expect { private_key.sign(pub_seed, [message].pack('H*')) }.to raise_error(ArgumentError) + end + + it 'rejects a non-hex seed' do + expect { WOTS::PrivateKey.from_seed(param, 'z' * 64) }.to raise_error(ArgumentError) + expect { WOTS::PrivateKey.from_seed(param, seed[0...-1]) }.to raise_error(ArgumentError) + end + + it 'rejects an odd-length hex key element' do + keys = Array.new(param.len) { message[0...-1] } + expect { WOTS::PrivateKey.new(param, keys) }.to raise_error(ArgumentError) + expect { WOTS::PublicKey.new(param, keys) }.to raise_error(ArgumentError) + expect { WOTS::Signature.new(param, keys) }.to raise_error(ArgumentError) + end + + it 'rejects a signature of the wrong type' do + expect { WOTS::PublicKey.from_signature('not a signature', pub_seed, message) } + .to raise_error(ArgumentError) + end + + it 'rejects an odd-length hex message on verification' do + signature = private_key.sign(pub_seed, message) + expect { WOTS::PublicKey.from_signature(signature, pub_seed, message[0...-1]) } + .to raise_error(ArgumentError) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index f6df8fc..196e52c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,9 @@ # frozen_string_literal: true +# Run the suite under YJIT. RubyVM::YJIT.enable exists from Ruby 3.3, so older +# rubies in the CI matrix just run without it. +RubyVM::YJIT.enable if defined?(RubyVM::YJIT) && RubyVM::YJIT.respond_to?(:enable) + require "wots" require "json" diff --git a/spec/wots_spec.rb b/spec/wots_spec.rb index e68165b..21761f3 100644 --- a/spec/wots_spec.rb +++ b/spec/wots_spec.rb @@ -14,8 +14,7 @@ params = [ WOTS::Param::SHA256, - WOTS::Param.new(name: 'WOTSP-SHA2_256', n: 32, w: 4), - WOTS::Param.new(name: 'WOTSP-SHA2_256', n: 32, w: 256) + WOTS::Param.new(name: 'WOTSP-SHA2_256', n: 32, w: 4) ] params.each do |param| @@ -27,14 +26,7 @@ end signature = private_key.sign(pub_seed, message) - expect_signature = case param.w - when 4 - vector['signatureW4'] - when 256 - vector['signatureW256'] - else - vector['signature'] - end + expect_signature = param.w == 4 ? vector['signatureW4'] : vector['signature'] expect(signature.sigs).to eq(expect_signature)