wots 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: af0b30e037fbb6e5727fa6f90a7c601625d9a959268b28e7d48bb92ab5db1b37
4
- data.tar.gz: 5af9c89ca3b088bef40f3f6c1307a4c8fc9c108d54967ad5670118b0e87730bc
3
+ metadata.gz: 44928dd35ba7e5a8294069e3f85916e3a635440d52d63304337f218503d66443
4
+ data.tar.gz: 1e2b102c71bb6d73c96a5aa31cee0a74b4de756179b791a68733e3233e86c396
5
5
  SHA512:
6
- metadata.gz: f4171a56cc4b0245e5e5b2f73951a8a5f2d743d488161bc5d7775370a77e1ba0e992d928a933cb30cd7657ed494d65c8864ae17ba79ac1cad4fc0838b70b9ec3
7
- data.tar.gz: '009254475b5624eabe23d3f7d96a050323c36d0894c911f078a7cb7c3c4dd1cae000fb3683637441b8d2b242ff6e2678b1adb9e6c08a301995712ea0eee38dd8'
6
+ metadata.gz: c92615a1dece0f8ed08dcd809c32dd8f4c4bf2476accd34799a66f3dd277ecb6bedf3ce97c6f67132bd46a8ca09b7e542497b715f515b6d4180ab9f59426e386
7
+ data.tar.gz: 2a1f7a07e0860068fec8b1dc2c685efd02f8e287971d711244642979bb26a52233f338135696b86d77ef59b0eb00baf6bdb2099b23956d482d57677d0c509ce6
data/CHANGELOG.md ADDED
@@ -0,0 +1,70 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ ## [0.3.0] - 2026-11-08
7
+
8
+ This release contains **breaking changes**. Keys and signatures created with 0.2.0 are not compatible.
9
+
10
+ ### Breaking changes
11
+
12
+ - `WOTSP-SHAKE_256` (`WOTS::Param::SHAKE256`) now uses **SHAKE128** with a 256-bit output, as defined in
13
+ RFC 8391 Sections 5.1 and 5.2. It used SHAKE256 with OpenSSL's default output length before, which is not
14
+ RFC 8391 and fails on OpenSSL 3.4 and later. Its outputs change.
15
+ - `PrivateKey.from_seed(param, seed, pub_seed, addr: Address.new)` now requires `pub_seed`, and derives each
16
+ private key element as `PRF_keygen(seed, pub_seed || ADRS)` (NIST SP 800-208 Section 6.2), the method of the
17
+ XMSS reference implementation. It used `PRF(seed, toByte(i, 32))` before, so the same seed always gave the
18
+ same key regardless of `pub_seed` and address. The derived keys change.
19
+ - `PrivateKey#keys`, `PublicKey#keys` and `Signature#sigs` are frozen arrays of frozen, lowercase hex strings.
20
+ - `WOTS::Param` instances are frozen.
21
+ - Stricter input validation, raising `ArgumentError`:
22
+ - `Param#f`, `#h`, `#h_msg`, `#prf` and `#prf_keygen` accept only the key and message lengths defined in
23
+ RFC 8391 and SP 800-208.
24
+ - `Param#chain` validates the seed length, and `Param#compute_checksum` rejects digits outside `[0, w - 1]`.
25
+ - Strings in encodings that are not ASCII compatible (e.g. UTF-16) are not accepted as hex strings.
26
+ - `Param#chain` no longer modifies the given address.
27
+
28
+ ### Added
29
+
30
+ - `addr:` keyword (default `WOTS::Address.new`) on `PrivateKey.from_seed`, `PrivateKey#sign`,
31
+ `PublicKey.from_private_key` and `PublicKey.from_signature`, so that one seed can derive several keys.
32
+ - `PublicKey#verify(signature, pub_seed, message, addr:)`.
33
+ - `Signature#==`, and `eql?`/`hash` consistent with `==` for `PublicKey` and `Signature`.
34
+ - `WOTS::Core`, a scheme-independent WOTS+ engine on binary strings, driven by a hasher object
35
+ (`secret`, `step`, `compress`), so that other WOTS+ instantiations such as the one of SLH-DSA can be built on it.
36
+ - `WOTS::RFC8391Hasher`, the hasher of RFC 8391.
37
+ - `Param#keyed_digest` (binary keyed hash) and the prefix constants `Param::F`, `H`, `H_MSG`, `PRF`, `PRF_KEYGEN`.
38
+ - Test vectors generated with the XMSS reference implementation for every parameter set, `w = 4` and `16`.
39
+ - Runtime dependency on the `sha3` gem.
40
+
41
+ ### Changed
42
+
43
+ - Signing, key generation and verification are about 30% faster.
44
+ - Public keys and signatures compare equal regardless of the letter case of their hex strings.
45
+
46
+ ### Security
47
+
48
+ - Documented how to manage one-time keys: one address per key, persist the state before releasing a
49
+ signature, never sign twice, and never roll back or share the state. The library does not track key use.
50
+ - `RFC8391Hasher#inspect` redacts the seed and private key elements.
51
+ - Releases require MFA on RubyGems (`rubygems_mfa_required`).
52
+ - CI runs with a read-only token and actions pinned to commit SHAs.
53
+
54
+ ## [0.2.0] - 2026-08-11
55
+
56
+ ### Security
57
+
58
+ - Restricted parameters to the sets and Winternitz parameters (`w = 4, 16`) defined in RFC 8391.
59
+ - Rejected odd-length hex and raw binary input, and validated the remaining inputs.
60
+ - Validated `Address` field ranges.
61
+ - Redacted private key material from `PrivateKey#inspect`.
62
+ - Documented the input format and security constraints.
63
+
64
+ ## [0.1.0] - 2025-09-18
65
+
66
+ - Initial release.
67
+
68
+ [0.3.0]: https://github.com/azuchi/wots/compare/v0.2.0...v0.3.0
69
+ [0.2.0]: https://github.com/azuchi/wots/compare/v0.1.0...v0.2.0
70
+ [0.1.0]: https://github.com/azuchi/wots/releases/tag/v0.1.0
data/README.md CHANGED
@@ -10,7 +10,7 @@ WOTS+ is a post-quantum cryptographic signature scheme that provides security ag
10
10
  - Support for multiple hash functions:
11
11
  - `WOTSP-SHA2_256` (SHA-256)
12
12
  - `WOTSP-SHA2_512` (SHA-512)
13
- - `WOTSP-SHAKE_256` (SHAKE-256)
13
+ - `WOTSP-SHAKE_256` (SHAKE128 with a 256-bit output, as defined in RFC 8391)
14
14
  - Support for Winternitz parameters w=4 and w=16
15
15
  - Key generation from seed
16
16
  - Digital signature generation and verification
@@ -41,24 +41,42 @@ require 'wots'
41
41
  # Use predefined parameters
42
42
  param = WOTS::Param::SHA256
43
43
 
44
- # Generate private key from seed
44
+ # Generate private key from the secret seed and the public seed
45
45
  seed = "693141c7ee701d13e1a7c733e0aa8326c19961429bfb54083f2f65b30c32e20b"
46
- private_key = WOTS::PrivateKey.from_seed(param, seed)
46
+ pub_seed = "46ece585b4c0bfa1186209270e22fa07c4716461b5a026c268e594fb94404f3a"
47
+ private_key = WOTS::PrivateKey.from_seed(param, seed, pub_seed)
47
48
 
48
49
  # Generate public key
49
- pub_seed = "46ece585b4c0bfa1186209270e22fa07c4716461b5a026c268e594fb94404f3a"
50
50
  public_key = WOTS::PublicKey.from_private_key(private_key, pub_seed)
51
51
 
52
52
  # Sign a message
53
53
  message = "f16c96e88fb99a8287a43121962e89ed521699fa3e126c67eaaa168066354477"
54
54
  signature = private_key.sign(pub_seed, message)
55
55
 
56
- # Verify signature by reconstructing public key
57
- recovered_pk = WOTS::PublicKey.from_signature(signature, pub_seed, message)
58
- valid = (recovered_pk == public_key)
56
+ # Verify signature
57
+ valid = public_key.verify(signature, pub_seed, message)
59
58
  puts "Signature valid: #{valid}"
60
59
  ```
61
60
 
61
+ ### Deriving several keys from one seed
62
+
63
+ Private keys are derived as `sk[i] = PRF_keygen(seed, pub_seed || ADRS)`
64
+ ([NIST SP 800-208](https://doi.org/10.6028/NIST.SP.800-208) Section 6.2), so each address gives a different key.
65
+ Pass a distinct `WOTS::Address` (e.g. a different `ots_addr`) for each key, and pass the **same** address to
66
+ every operation on that key:
67
+
68
+ ```ruby
69
+ addr = WOTS::Address.new(ots_addr: 5)
70
+ private_key = WOTS::PrivateKey.from_seed(param, seed, pub_seed, addr: addr)
71
+ public_key = WOTS::PublicKey.from_private_key(private_key, pub_seed, addr: addr)
72
+ signature = private_key.sign(pub_seed, message, addr: addr)
73
+ public_key.verify(signature, pub_seed, message, addr: addr) # => true
74
+ ```
75
+
76
+ `addr` defaults to `WOTS::Address.new` (all fields zero). Never use the same `seed`, `pub_seed` and address for
77
+ two keys: they would be the same key, and signing with both would reuse a one-time key.
78
+ See [One-time keys](#one-time-keys-state-management-is-up-to-you) for how to manage the addresses.
79
+
62
80
  ### Available Parameter Sets
63
81
 
64
82
  ```ruby
@@ -68,10 +86,40 @@ param = WOTS::Param::SHA256
68
86
  # SHA-512 based (n=64 bytes, w=16)
69
87
  param = WOTS::Param::SHA512
70
88
 
71
- # SHAKE-256 based (n=32 bytes, w=16)
89
+ # SHAKE128 based (n=32 bytes, w=16), i.e. RFC 8391 WOTSP-SHAKE_256
72
90
  param = WOTS::Param::SHAKE256
73
91
  ```
74
92
 
93
+ `WOTS::Param::SHAKE256` is named after the RFC 8391 parameter set `WOTSP-SHAKE_256`, where `256` is the output
94
+ length in bits. It uses **SHAKE128**, not SHAKE256 (RFC 8391 Section 5.1).
95
+
96
+ ## Using the WOTS+ engine from another library
97
+
98
+ `WOTS::Core` is the scheme-independent WOTS+ engine behind the API above. It works on **binary strings**
99
+ and leaves the hashing and the hash address to a *hasher* object, so the same engine can drive other WOTS+
100
+ instantiations, such as the one in SLH-DSA (FIPS 205), implemented in another library.
101
+
102
+ ```ruby
103
+ core = WOTS::Core.new(n: 16, w: 16) # w is 4 or 16
104
+ pk = core.pk_gen(hasher)
105
+ sig = core.sign(hasher, msg) # msg: n-byte binary string; returns len n-byte strings
106
+ pk2 = core.pk_from_sig(hasher, sig, msg)
107
+ ```
108
+
109
+ A hasher is bound to one WOTS+ key pair and responds to:
110
+
111
+ | Method | Returns |
112
+ |---|---|
113
+ | `secret(chain_idx)` | the `n`-byte secret value of chain `chain_idx` (used by `pk_gen` and `sign` only) |
114
+ | `step(x, chain_idx, hash_idx)` | one application of the chaining hash to the `n`-byte value `x` at position `hash_idx` of chain `chain_idx` |
115
+ | `compress(values)` | the public key built from the `len` chain end values |
116
+
117
+ `WOTS::Core` calls them in the order of FIPS 205 Algorithms 6–8: for each chain `i` in ascending order,
118
+ `secret(i)` (when needed) and then `step(x, i, j)` for ascending `j`. It never sees an address, so the hasher
119
+ sets the chain and hash address itself. `WOTS::RFC8391Hasher` is the hasher of RFC 8391 used by this library.
120
+
121
+ A hasher keeps a mutable address, so do not share one hasher between threads; create one per operation.
122
+
75
123
  ## Input format
76
124
 
77
125
  All key material, seeds and messages are **even-length hexadecimal strings**. Raw binary strings and
@@ -87,11 +135,39 @@ signature = private_key.sign(pub_seed, message)
87
135
  **⚠️ IMPORTANT: This is a cryptographic library implementation. While it follows RFC 8391 specifications,
88
136
  it has not undergone formal security audits. Use at your own risk in production environments.**
89
137
 
90
- WOTS+ is a **one-time signature scheme**. Each private key should only be used to sign **one message**.
91
- Reusing a private key to sign multiple messages can leak information about the private key and compromise security.
138
+ ### One-time keys: state management is up to you
139
+
140
+ WOTS+ is a **one-time signature scheme**. Each private key must be used to sign **one message, once**.
141
+ Signatures of two different messages under the same key let an attacker forge signatures.
142
+
143
+ **This library does not track whether a key has been used.** A flag inside `WOTS::PrivateKey` would not protect
144
+ you: calling `PrivateKey.from_seed` again recreates the same key as a fresh object, and real reuse happens across
145
+ process restarts, devices and backups, which no in-memory flag can see. The state belongs to the code that assigns
146
+ one-time keys, typically a counter of the next address to use. Follow these rules
147
+ (based on [NIST SP 800-208](https://doi.org/10.6028/NIST.SP.800-208) Sections 8.1 and 9.1):
148
+
149
+ - **One address per key.** `seed`, `pub_seed` and `addr` determine the key: the same triple always gives the same
150
+ key. Give every key its own address, e.g. by incrementing `ots_addr`.
151
+ - **Persist before you release.** Write the updated state (e.g. the next `ots_addr`) to non-volatile storage, and
152
+ make sure it has really reached it (e.g. `File#fsync`, a committed database transaction), *before* the signature
153
+ leaves your process. If the process crashes after exporting a signature but before the state is durable, the key
154
+ will be used again after the restart.
155
+ - **Never sign twice, not even the same message.** Re-computing a signature over the same message is open to fault
156
+ injection attacks (SP 800-208, Section 8.1, footnote 9). Sign once and store the signature if you need it again.
157
+ - **Never roll the state back or share it.** Do not restore the state from backups or VM snapshots, and do not use
158
+ the same `seed` on several devices or processes at once.
159
+
160
+ SP 800-208 additionally requires stateful hash-based key generation and signing to run inside a validated hardware
161
+ cryptographic module. This software library cannot meet that requirement.
162
+
163
+ ### Key material
92
164
 
93
165
  The seed passed to `WOTS::PrivateKey.from_seed` is secret key material and must come from a
94
166
  cryptographically secure random number generator, e.g. `SecureRandom.hex(param.n)`.
167
+ The private key is bound to `pub_seed` and the address, using the key generation method of NIST SP 800-208
168
+ (Section 6.2). Of the parameter sets supported here, SP 800-208 defines
169
+ `PRF_keygen` only for `WOTSP-SHA2_256`; this library applies the same construction
170
+ (`Hash(toByte(4, n) || KEY || M)`) to every parameter set, as the XMSS reference implementation does.
95
171
 
96
172
  Only the Winternitz parameters defined in RFC 8391 (`w = 4` and `w = 16`) are accepted. Other values
97
173
  overflow the checksum encoding, which drops the high bits of the checksum and allows an attacker to
@@ -103,3 +179,26 @@ garbage collected.
103
179
  ## Specifications
104
180
 
105
181
  This implementation follows [RFC 8391 - XMSS: eXtended Merkle Signature Scheme](https://datatracker.ietf.org/doc/html/rfc8391), Section 3 (WOTS+).
182
+ Private keys are derived with the method of [NIST SP 800-208](https://doi.org/10.6028/NIST.SP.800-208) Section 6.2.
183
+
184
+ ### How it is tested
185
+
186
+ RFC 8391 and SP 800-208 do not publish test vectors; RFC 8391 (Section 7) points to its reference implementation
187
+ instead, and NIST ACVP has no XMSS or WOTS+ test vectors yet. `spec/fixtures/reference_vectors.json` was therefore
188
+ generated with the WOTS+ part (`wots.c`) of the
189
+ [XMSS reference implementation](https://github.com/XMSS/xmss-reference)
190
+ (commit `171ccbd26f098542a67eb5d2b128281c80bd71a6`) for every parameter set, `w = 4` and `16`, and a non-zero
191
+ address. The specs check that key generation, signing and verification reproduce those vectors.
192
+
193
+ Note that this is the reference implementation of XMSS, not of a standalone WOTS+. The chaining, signing and
194
+ verification are RFC 8391 WOTS+ itself. The private key derivation is the one XMSS uses (SP 800-208); RFC 8391
195
+ leaves the derivation of WOTS+ private keys open (Section 3.1.7).
196
+
197
+ ### Not compatible with SLH-DSA
198
+
199
+ The WOTS+ of SLH-DSA ([FIPS 205](https://doi.org/10.6028/NIST.FIPS.205)) is a different instantiation: its chain
200
+ step is the tweakable hash `F(PK.seed, ADRS, M)` without bitmasks, its address layout differs (and is compressed
201
+ for SHA2), the public key is compressed with `T_len`, and it uses `n = 16, 24, 32` with `w = 16` only.
202
+ Keys and signatures of this library cannot be used with SLH-DSA, nor with schemes built on it. Such a WOTS+ can be
203
+ implemented on top of `WOTS::Core` with its own hasher (see
204
+ [Using the WOTS+ engine from another library](#using-the-wots-engine-from-another-library)).
data/lib/wots/address.rb CHANGED
@@ -51,6 +51,17 @@ module WOTS
51
51
  0
52
52
  end
53
53
 
54
+ # Return a copy of +addr+ that can be freely mutated by the chaining function,
55
+ # so that the caller's address object is never modified.
56
+ # @param [WOTS::Address] addr
57
+ # @return [WOTS::Address]
58
+ # @raise ArgumentError If +addr+ is not a WOTS::Address.
59
+ def self.copy_of(addr)
60
+ raise ArgumentError, 'addr must be WOTS::Address.' unless addr.is_a?(Address)
61
+
62
+ addr.dup
63
+ end
64
+
54
65
  def to_payload
55
66
  [layer_addr, tree_addr, type, ots_addr, chain_addr, hash_addr, key_and_mask].pack('NQ>NNNNN')
56
67
  end
data/lib/wots/core.rb ADDED
@@ -0,0 +1,189 @@
1
+ module WOTS
2
+ # Scheme independent WOTS+ engine working on binary strings.
3
+ #
4
+ # The WOTS+ of RFC 8391 and of FIPS 205 (SLH-DSA) share the message encoding,
5
+ # the checksum and the way the chains are walked; they differ only in how a
6
+ # chain secret is derived, how one chain step is hashed, what goes into the
7
+ # hash address and how the chain end values form the public key. Those parts
8
+ # are supplied by a +hasher+ object, so this class never sees an address.
9
+ #
10
+ # A hasher bound to one WOTS+ key pair responds to:
11
+ #
12
+ # * +secret(chain_idx)+ -> the +n+-byte secret value of chain +chain_idx+.
13
+ # Only needed by #pk_gen and #sign.
14
+ # * +step(x, chain_idx, hash_idx)+ -> one application of the chaining hash to the
15
+ # +n+-byte value +x+ at position +hash_idx+ of chain +chain_idx+
16
+ # (RFC 8391 Algorithm 2 with hash address +hash_idx+, FIPS 205 Algorithm 5 line 3-4).
17
+ # * +compress(values)+ -> the public key built from the +len+ chain end values.
18
+ # RFC 8391 returns the values as they are; FIPS 205 applies T_len.
19
+ #
20
+ # The calls are made in the order of FIPS 205 Algorithms 6-8: for each chain i
21
+ # (ascending), +secret(i)+ (if needed) and then +step(x, i, j)+ for ascending j.
22
+ #
23
+ # This class is the API other gems build on, so its interface is kept stable.
24
+ class Core
25
+ # The Winternitz parameters this engine supports.
26
+ SUPPORTED_W = [4, 16].freeze
27
+
28
+ attr_reader :n, :w, :lg_w, :len1, :len2, :len
29
+
30
+ # @param [Integer] n The byte length of messages, chain values and secrets.
31
+ # @param [Integer] w The Winternitz parameter, one of +SUPPORTED_W+.
32
+ # @raise ArgumentError
33
+ def initialize(n:, w:)
34
+ raise ArgumentError, 'n must be integer.' unless n.is_a?(Integer)
35
+ raise ArgumentError, 'n must be positive.' unless n.positive?
36
+ raise ArgumentError, 'w must be integer.' unless w.is_a?(Integer)
37
+ raise ArgumentError, "w must be one of #{SUPPORTED_W.join(', ')}." unless SUPPORTED_W.include?(w)
38
+
39
+ @n = n
40
+ @w = w
41
+ @lg_w = w.bit_length - 1
42
+ @len1 = (8 * n + lg_w - 1) / lg_w
43
+ # floor(log2(len1 * (w - 1)) / lg_w) + 1, computed without floating point.
44
+ @len2 = ((len1 * (w - 1)).bit_length - 1) / lg_w + 1
45
+ @len = len1 + len2
46
+ @csum_shift = (8 - ((len2 * lg_w) % 8)) % 8
47
+ @csum_bytes = (len2 * lg_w + 7) / 8
48
+ freeze
49
+ end
50
+
51
+ # Convert +data+ into +out_len+ base w digits (RFC 8391 base_w, FIPS 205 base_2b).
52
+ # @param [String] data Binary string.
53
+ # @param [Integer] out_len
54
+ # @return [Array<Integer>]
55
+ # @raise ArgumentError If +data+ is too short to produce +out_len+ digits.
56
+ def base_w(data, out_len)
57
+ raise ArgumentError, 'data must be string.' unless data.is_a?(String)
58
+ raise ArgumentError, 'out_len must be integer.' unless out_len.is_a?(Integer)
59
+ raise ArgumentError, 'out_len must be positive.' unless out_len.positive?
60
+
61
+ required = (out_len * lg_w + 7) / 8
62
+ raise ArgumentError, "data must be at least #{required} bytes to produce #{out_len} digits." if data.bytesize < required
63
+
64
+ digits = []
65
+ in_idx = 0
66
+ total = 0
67
+ bits = 0
68
+ mask = w - 1
69
+ out_len.times do
70
+ if bits == 0
71
+ total = data.getbyte(in_idx)
72
+ in_idx += 1
73
+ bits = 8
74
+ end
75
+ bits -= lg_w
76
+ digits << ((total >> bits) & mask)
77
+ end
78
+ digits
79
+ end
80
+
81
+ # Encode the checksum of the first +len1+ message digits.
82
+ # @param [Array<Integer>] msg_digits
83
+ # @return [String] Binary string of ceil(len2 * lg_w / 8) bytes.
84
+ # @raise ArgumentError
85
+ def checksum_bytes(msg_digits)
86
+ raise ArgumentError, 'msg_digits must be Array.' unless msg_digits.is_a?(Array)
87
+ raise ArgumentError, "msg_digits must have at least #{len1} digits." if msg_digits.length < len1
88
+
89
+ csum = 0
90
+ len1.times do |i|
91
+ d = msg_digits[i]
92
+ raise ArgumentError, "digit must be between 0 and #{w - 1}." unless d.is_a?(Integer) && d >= 0 && d < w
93
+
94
+ csum += w - 1 - d
95
+ end
96
+ csum <<= @csum_shift
97
+ bytes = Array.new(@csum_bytes) { |i| (csum >> (8 * (@csum_bytes - 1 - i))) & 0xFF }
98
+ bytes.pack('C*')
99
+ end
100
+
101
+ # The +len+ base w digits signed for +msg+: the message digits followed by the checksum digits.
102
+ # @param [String] msg Binary string of +n+ bytes.
103
+ # @return [Array<Integer>]
104
+ # @raise ArgumentError
105
+ def digits(msg)
106
+ check_value!(msg, 'msg')
107
+ msg_digits = base_w(msg, len1)
108
+ msg_digits + base_w(checksum_bytes(msg_digits), len2)
109
+ end
110
+
111
+ # Walk +steps+ steps of chain +chain_idx+ from position +start+.
112
+ # @param hasher
113
+ # @param [String] x Binary string of +n+ bytes, the value at position +start+.
114
+ # @param [Integer] chain_idx
115
+ # @param [Integer] start
116
+ # @param [Integer] steps
117
+ # @return [String] Binary string of +n+ bytes, the value at position +start+ + +steps+.
118
+ # @raise ArgumentError
119
+ def chain(hasher, x, chain_idx, start, steps)
120
+ check_value!(x, 'x')
121
+ raise ArgumentError, 'chain_idx must be integer.' unless chain_idx.is_a?(Integer)
122
+ raise ArgumentError, "chain_idx must be between 0 and #{len - 1}." unless chain_idx >= 0 && chain_idx < len
123
+ raise ArgumentError, 'start must be integer.' unless start.is_a?(Integer)
124
+ raise ArgumentError, 'steps must be integer.' unless steps.is_a?(Integer)
125
+ raise ArgumentError, 'start must not be negative.' if start.negative?
126
+ raise ArgumentError, 'steps must not be negative.' if steps.negative?
127
+ raise ArgumentError, 'Invalid range' if start + steps > w - 1
128
+
129
+ steps.times do |j|
130
+ x = hasher.step(x, chain_idx, start + j)
131
+ end
132
+ check_value!(x, 'hasher#step result') if steps.positive?
133
+ x
134
+ end
135
+
136
+ # Generate the public key (RFC 8391 WOTS_genPK, FIPS 205 Algorithm 6).
137
+ # @param hasher
138
+ # @return The result of +hasher.compress+.
139
+ def pk_gen(hasher)
140
+ values = Array.new(len) do |i|
141
+ chain(hasher, secret(hasher, i), i, 0, w - 1)
142
+ end
143
+ hasher.compress(values)
144
+ end
145
+
146
+ # Sign +msg+ (RFC 8391 WOTS_sign, FIPS 205 Algorithm 7).
147
+ # @param hasher
148
+ # @param [String] msg Binary string of +n+ bytes.
149
+ # @return [Array<String>] +len+ binary strings of +n+ bytes.
150
+ # @raise ArgumentError
151
+ def sign(hasher, msg)
152
+ ds = digits(msg)
153
+ Array.new(len) do |i|
154
+ chain(hasher, secret(hasher, i), i, 0, ds[i])
155
+ end
156
+ end
157
+
158
+ # Compute the public key from a signature (RFC 8391 WOTS_pkFromSig, FIPS 205 Algorithm 8).
159
+ # @param hasher
160
+ # @param [Array<String>] sig +len+ binary strings of +n+ bytes.
161
+ # @param [String] msg Binary string of +n+ bytes.
162
+ # @return The result of +hasher.compress+.
163
+ # @raise ArgumentError
164
+ def pk_from_sig(hasher, sig, msg)
165
+ raise ArgumentError, 'sig must be Array.' unless sig.is_a?(Array)
166
+ raise ArgumentError, "sig must have #{len} elements." unless sig.length == len
167
+
168
+ sig.each { |s| check_value!(s, 'sig element') }
169
+ ds = digits(msg)
170
+ values = Array.new(len) do |i|
171
+ chain(hasher, sig[i], i, ds[i], w - 1 - ds[i])
172
+ end
173
+ hasher.compress(values)
174
+ end
175
+
176
+ private
177
+
178
+ def secret(hasher, chain_idx)
179
+ sk = hasher.secret(chain_idx)
180
+ check_value!(sk, 'hasher#secret result')
181
+ sk
182
+ end
183
+
184
+ def check_value!(value, name)
185
+ raise ArgumentError, "#{name} must be string." unless value.is_a?(String)
186
+ raise ArgumentError, "#{name} must be #{n} bytes." unless value.bytesize == n
187
+ end
188
+ end
189
+ end
data/lib/wots/param.rb CHANGED
@@ -1,4 +1,4 @@
1
- require 'openssl'
1
+ require 'sha3'
2
2
 
3
3
  module WOTS
4
4
  # WOTS+ parameters defined in rfc8391.
@@ -11,6 +11,7 @@ module WOTS
11
11
  autoload :SHAKE256, 'wots/param/shake256'
12
12
 
13
13
  # The parameter sets defined in RFC 8391 and the +n+ they require.
14
+ # WOTSP-SHAKE_256 uses SHAKE128 with a 256-bit output (RFC 8391 Section 5.1 and 5.2).
14
15
  SUPPORTED_NAMES = {
15
16
  'WOTSP-SHA2_256' => 32,
16
17
  'WOTSP-SHA2_512' => 64,
@@ -18,12 +19,35 @@ module WOTS
18
19
  }.freeze
19
20
 
20
21
  # The Winternitz parameter is a member of the set {4, 16}.
21
- # Other values break the checksum encoding below and allow signature forgery.
22
- SUPPORTED_W = [4, 16].freeze
22
+ # Other values break the checksum encoding and allow signature forgery.
23
+ SUPPORTED_W = Core::SUPPORTED_W
24
+
25
+ # Domain separation prefixes of the keyed hash functions (RFC 8391 Section 5.1,
26
+ # and NIST SP 800-208 Section 5 for PRF_KEYGEN).
27
+ F = 0
28
+ H = 1
29
+ H_MSG = 2
30
+ PRF = 3
31
+ PRF_KEYGEN = 4
32
+
33
+ # The byte lengths of KEY and M accepted by each keyed hash function, as multiples of n
34
+ # plus a constant: [[key_n, key_extra], [message_n, message_extra]], or nil for any length.
35
+ # RFC 8391 Section 3.1.1.1 (F, PRF), Section 4.1.2 (H, H_msg), NIST SP 800-208 Section 6.2
36
+ # (PRF_keygen, whose M is SEED || ADRS).
37
+ INPUT_LENGTHS = {
38
+ F => [[1, 0], [1, 0]],
39
+ H => [[1, 0], [2, 0]],
40
+ H_MSG => [[3, 0], nil],
41
+ PRF => [[1, 0], [0, 32]],
42
+ PRF_KEYGEN => [[1, 0], [1, 32]]
43
+ }.freeze
44
+ private_constant :INPUT_LENGTHS
23
45
 
24
46
  attr_reader :name
25
47
  attr_reader :n
26
48
  attr_reader :w
49
+ # @return [WOTS::Core] The WOTS+ engine for this parameter set.
50
+ attr_reader :core
27
51
 
28
52
  # @param [Hash] opts
29
53
  # @option opts [String] :name the name of the parameter set; it is a member of +SUPPORTED_NAMES+.
@@ -41,56 +65,103 @@ module WOTS
41
65
  raise ArgumentError, "n must be #{expected_n} for #{opts[:name]}." unless opts[:n] == expected_n
42
66
  raise ArgumentError, "w must be one of #{SUPPORTED_W.join(', ')}." unless SUPPORTED_W.include?(opts[:w])
43
67
 
44
- @name = opts[:name]
68
+ @name = opts[:name].dup.freeze
45
69
  @n = opts[:n]
46
70
  @w = opts[:w]
71
+ @core = Core.new(n: n, w: w)
72
+ freeze
47
73
  end
48
74
 
49
75
  # log2(w), i.e. the number of bits consumed by a single base w digit.
50
76
  # @return [Integer]
51
77
  def lg_w
52
- @lg_w ||= Math.log2(w).to_i
78
+ core.lg_w
53
79
  end
54
80
 
55
81
  def len1
56
- @len1 ||= (8.0 * n / Math.log2(w)).ceil
82
+ core.len1
57
83
  end
58
84
 
59
85
  def len2
60
- @len2 ||= (Math.log2(len1 * (w - 1)) / Math.log2(w)).floor + 1
86
+ core.len2
61
87
  end
62
88
 
63
89
  def len
64
- @len ||= len1 + len2
90
+ core.len
65
91
  end
66
92
 
67
- # @param [String] k Hex string.
68
- # @param [String] m Hex string.
93
+ # @param [String] k Hex string of +n+ bytes.
94
+ # @param [String] m Hex string of +n+ bytes.
69
95
  # @return [String] Hex string.
70
96
  def f(k, m)
71
- keyed_hash(0, k, m)
97
+ keyed_hash(F, k, m)
72
98
  end
73
99
 
74
- # @param [String] k Hex string.
75
- # @param [String] m Hex string.
100
+ # @param [String] k Hex string of +n+ bytes.
101
+ # @param [String] m Hex string of 2n bytes.
76
102
  # @return [String] Hex string.
77
103
  def h(k, m)
78
- keyed_hash(1, k, m)
104
+ keyed_hash(H, k, m)
79
105
  end
80
106
 
81
- # @param [String] k Hex string.
107
+ # @param [String] k Hex string of 3n bytes.
82
108
  # @param [String] m Hex string.
83
109
  # @return [String] Hex string.
84
110
  def h_msg(k, m)
85
- keyed_hash(2, k, m)
111
+ keyed_hash(H_MSG, k, m)
86
112
  end
87
113
 
88
114
  # PRF function.
89
- # @param [String] k key Hex string.
90
- # @param [String] m message Hex string.
115
+ # @param [String] k key Hex string of +n+ bytes.
116
+ # @param [String] m message Hex string of 32 bytes.
91
117
  # @return [String] Hex string.
92
118
  def prf(k, m)
93
- keyed_hash(3, k, m)
119
+ keyed_hash(PRF, k, m)
120
+ end
121
+
122
+ # PRF used for pseudorandom key generation (NIST SP 800-208 Section 5).
123
+ # RFC 8391 does not define it; SP 800-208 defines it with the domain separation prefix 4,
124
+ # which is also what the XMSS reference implementation uses for every parameter set.
125
+ # @param [String] k key Hex string of +n+ bytes.
126
+ # @param [String] m message Hex string of n + 32 bytes (SEED || ADRS).
127
+ # @return [String] Hex string.
128
+ def prf_keygen(k, m)
129
+ keyed_hash(PRF_KEYGEN, k, m)
130
+ end
131
+
132
+ # The keyed hash functions of RFC 8391 Section 5.1 on binary strings:
133
+ # Hash(toByte(prefix, n) || KEY || M).
134
+ # @param [Integer] prefix One of F, H, H_MSG, PRF and PRF_KEYGEN.
135
+ # @param [String] key Binary string; +n+ bytes, or 3n bytes for H_MSG.
136
+ # @param [String] message Binary string; n bytes for F, 2n for H, any length for H_MSG,
137
+ # 32 for PRF and n + 32 for PRF_KEYGEN.
138
+ # @return [String] Binary string of +n+ bytes.
139
+ # @raise ArgumentError If +prefix+ is unknown or +key+ or +message+ has the wrong length.
140
+ def keyed_digest(prefix, key, message)
141
+ lengths = INPUT_LENGTHS[prefix]
142
+ raise ArgumentError, 'prefix must be one of F, H, H_MSG, PRF and PRF_KEYGEN.' unless lengths
143
+ raise ArgumentError, 'key must be string.' unless key.is_a?(String)
144
+ raise ArgumentError, 'message must be string.' unless message.is_a?(String)
145
+
146
+ key_len = lengths[0][0] * n + lengths[0][1]
147
+ raise ArgumentError, "key must be #{key_len} bytes." unless key.bytesize == key_len
148
+ if lengths[1]
149
+ message_len = lengths[1][0] * n + lengths[1][1]
150
+ raise ArgumentError, "message must be #{message_len} bytes." unless message.bytesize == message_len
151
+ end
152
+
153
+ payload = to_byte(prefix, n) + key.b + message.b
154
+ case name
155
+ when 'WOTSP-SHA2_256'
156
+ Digest::SHA256.digest(payload)
157
+ when 'WOTSP-SHA2_512'
158
+ Digest::SHA512.digest(payload)
159
+ when 'WOTSP-SHAKE_256'
160
+ # RFC 8391 Section 5.1: SHAKE128(toByte(prefix, 32) || KEY || M, 256)
161
+ SHA3::Digest::SHAKE_128.new(payload).squeeze(n)
162
+ else
163
+ raise 'Unknown param'
164
+ end
94
165
  end
95
166
 
96
167
  # Convert data as base w representation.
@@ -99,46 +170,18 @@ module WOTS
99
170
  # @return [Array] An array of integer.
100
171
  # @raise ArgumentError If +data+ is too short to produce +out_len+ digits.
101
172
  def base_w(data, out_len)
102
- x = hex_to_bin(data)
103
- raise ArgumentError, 'out_len must be integer.' unless out_len.is_a?(Integer)
104
- raise ArgumentError, 'out_len must be positive.' unless out_len.positive?
105
-
106
- required = ((out_len * lg_w) / 8.0).ceil
107
- raise ArgumentError, "data must be at least #{required} bytes to produce #{out_len} digits." if x.bytesize < required
108
-
109
- basew = []
110
- in_idx = 0
111
- total = 0
112
- bits = 0
113
-
114
- out_len.times do
115
- if bits == 0
116
- total = x.getbyte(in_idx)
117
- in_idx += 1
118
- bits += 8
119
- end
120
-
121
- bits -= lg_w
122
- basew << ((total >> bits) & (w - 1))
123
- end
124
-
125
- basew
173
+ core.base_w(hex_to_bin(data), out_len)
126
174
  end
127
175
 
128
176
  # Compute checksum for +base_w+.
129
177
  # @param [Array] base_w
130
178
  # @return [String] Checksum hex string.
131
179
  def compute_checksum(base_w)
132
- c_sum = 0
133
- len1.times do |i|
134
- c_sum = (c_sum + w - 1 - base_w[i])
135
- end
136
- c_sum = (c_sum << (8 - ((len2 * lg_w) % 8)))
137
- len_2_bytes = ((len2 * lg_w) / 8.0).ceil
138
- bin_to_hex(to_byte(c_sum, len_2_bytes))
180
+ bin_to_hex(core.checksum_bytes(base_w))
139
181
  end
140
182
 
141
183
  # WOTS+ Chaining Function.
184
+ # The chain address is taken from +addr+; +addr+ itself is not modified.
142
185
  # @see https://datatracker.ietf.org/doc/html/rfc8391#autoid-16
143
186
  # @param [String] x Input string. Hex string.
144
187
  # @param [Integer] start_idx Start index.
@@ -149,30 +192,11 @@ module WOTS
149
192
  # @raise ArgumentError
150
193
  def chain(x, start_idx, steps, seed, addr)
151
194
  raise ArgumentError, 'x must be hex string.' unless hex_string?(x)
152
- raise ArgumentError, 'start_idx must be integer.' unless start_idx.is_a?(Integer)
153
- raise ArgumentError, 'steps must be integer.' unless steps.is_a?(Integer)
154
- raise ArgumentError, 'start_idx must not be negative.' if start_idx.negative?
155
- raise ArgumentError, 'steps must not be negative.' if steps.negative?
156
- raise ArgumentError, 'Invalid range' if (start_idx + steps) > (w - 1)
157
-
158
- return x if steps == 0
159
-
160
- result = x.dup
161
-
162
- steps.times do |i|
163
- addr.hash_addr = (start_idx + i)
164
-
165
- addr.key_and_mask = 0
166
- key = prf(seed, bin_to_hex(addr.to_payload))
195
+ raise ArgumentError, 'seed must be hex string.' unless hex_string?(seed)
196
+ raise ArgumentError, 'addr must be WOTS::Address.' unless addr.is_a?(Address)
167
197
 
168
- addr.key_and_mask = 1
169
- mask = prf(seed, bin_to_hex(addr.to_payload))
170
-
171
- masked = xor_bytes(result, mask)
172
- result = f(key, masked)
173
- end
174
-
175
- result
198
+ hasher = RFC8391Hasher.new(self, pub_seed: hex_to_bin(seed), addr: addr)
199
+ bin_to_hex(core.chain(hasher, hex_to_bin(x), addr.chain_addr, start_idx, steps))
176
200
  end
177
201
 
178
202
  def ==(other)
@@ -203,34 +227,12 @@ module WOTS
203
227
 
204
228
  private
205
229
 
206
- # XOR two hex strings of the same length.
207
- # @param [String] a Hex string.
208
- # @param [String] b Hex string.
209
- # @return [String] Hex string.
210
- def xor_bytes(a, b)
211
- x = hex_to_bin(a)
212
- y = hex_to_bin(b)
213
- raise ArgumentError, 'length mismatch.' unless x.bytesize == y.bytesize
214
-
215
- bin_to_hex(x.unpack('C*').zip(y.unpack('C*')).map { |i, j| i ^ j }.pack("C*"))
216
- end
217
-
218
230
  # @param [Integer] prefix Domain separation prefix.
219
231
  # @param [String] k Hex string.
220
232
  # @param [String] m Hex string.
221
233
  # @return [String] Hex string.
222
234
  def keyed_hash(prefix, k, m)
223
- payload = to_byte(prefix, n) + hex_to_bin(k) + hex_to_bin(m)
224
- case name
225
- when 'WOTSP-SHA2_256'
226
- Digest::SHA256.hexdigest(payload)
227
- when 'WOTSP-SHA2_512'
228
- Digest::SHA512.hexdigest(payload)
229
- when 'WOTSP-SHAKE_256'
230
- OpenSSL::Digest.new('shake256', payload).hexdigest
231
- else
232
- raise 'Unknown param'
233
- end
235
+ bin_to_hex(keyed_digest(prefix, hex_to_bin(k), hex_to_bin(m)))
234
236
  end
235
237
  end
236
238
  end
@@ -17,54 +17,55 @@ module WOTS
17
17
  end
18
18
 
19
19
  @param = param
20
- @keys = keys
20
+ @keys = normalize_hex_array(keys)
21
21
  end
22
22
 
23
23
  # Generate private key using +seed+.
24
+ # Each element is derived as sk[i] = PRF_keygen(seed, pub_seed || ADRS) with the
25
+ # chain address of ADRS set to i (NIST SP 800-208 Section 6.2, Algorithm 10').
26
+ # Binding the key to +pub_seed+ and +addr+ gives every key pair and every OTS address
27
+ # its own private key, so one +seed+ can derive several keys by using a different
28
+ # +addr+ (e.g. +ots_addr+) for each of them.
24
29
  # @param [WOTS::Param] param
25
30
  # @param [String] seed The secret seed. Hex string of +param.n+ bytes.
26
31
  # It must be generated with a cryptographically secure random number generator.
32
+ # @param [String] pub_seed The public seed. Hex string of +param.n+ bytes.
33
+ # @param [WOTS::Address] addr The address of this key pair. The same address must be passed
34
+ # to #sign, PublicKey.from_private_key and PublicKey.from_signature.
35
+ # @return [WOTS::PrivateKey]
27
36
  # @raise ArgumentError
28
- def self.from_seed(param, seed)
37
+ def self.from_seed(param, seed, pub_seed, addr: Address.new)
29
38
  raise ArgumentError, "param must be WOTS::Param." unless param.is_a?(WOTS::Param)
30
39
  raise ArgumentError, "seed must be hex string." unless hex_string?(seed)
31
40
  raise ArgumentError, "seed must be #{param.n} bytes." unless hex_to_bin(seed).bytesize == param.n
32
- raise ArgumentError, "len parameter too large." if param.len.bit_length > 16
41
+ raise ArgumentError, 'pub_seed must be hex string.' unless hex_string?(pub_seed)
42
+ raise ArgumentError, "pub_seed must be #{param.n} bytes." unless hex_to_bin(pub_seed).bytesize == param.n
33
43
 
34
- keys = param.len.times.map do |i|
35
- param.prf(seed, bin_to_hex(param.to_byte(i, 32)))
36
- end
44
+ hasher = RFC8391Hasher.new(param, pub_seed: hex_to_bin(pub_seed), addr: addr, seed: hex_to_bin(seed))
45
+ keys = Array.new(param.len) { |i| bin_to_hex(hasher.secret(i)) }
37
46
 
38
47
  PrivateKey.new(param, keys)
39
48
  end
40
49
 
41
50
  # Generate signature.
42
- # WOTS+ is a one-time signature scheme: this private key must never be used
43
- # to sign more than one message.
51
+ # WOTS+ is a one-time signature scheme: this private key must be used to sign
52
+ # one message, once. This library does not track whether a key has been used;
53
+ # the caller must persist its state (e.g. the next address) to non-volatile
54
+ # storage before releasing the signature. See "One-time keys" in README.md.
44
55
  # @param [String] pub_seed The Public seed. Hex string of +param.n+ bytes.
45
56
  # @param [String] message The message to be signed. Hex string of +param.n+ bytes.
57
+ # @param [WOTS::Address] addr The address passed to PrivateKey.from_seed.
46
58
  # @return [WOTS::Signature]
47
59
  # @raise ArgumentError
48
- def sign(pub_seed, message)
60
+ def sign(pub_seed, message, addr: Address.new)
49
61
  raise ArgumentError, 'pub_seed must be hex string.' unless hex_string?(pub_seed)
50
62
  raise ArgumentError, "pub_seed must be #{param.n} bytes." unless hex_to_bin(pub_seed).bytesize == param.n
51
63
  raise ArgumentError, "message must be hex string." unless hex_string?(message)
52
64
  raise ArgumentError, "message must be #{param.n} bytes." unless hex_to_bin(message).bytesize == param.n
53
65
 
54
- addr = Address.new
55
-
56
- # Convert message to base w
57
- base_w = param.base_w(message, param.len1)
58
-
59
- # Compute checksum
60
- c_sum = param.compute_checksum(base_w)
61
-
62
- base_w = base_w + param.base_w(c_sum, param.len2)
63
-
64
- sigs = param.len.times.map do |i|
65
- addr.chain_addr = i
66
- param.chain(keys[i], 0, base_w[i], pub_seed, addr)
67
- end
66
+ hasher = RFC8391Hasher.new(param, pub_seed: hex_to_bin(pub_seed), addr: addr,
67
+ keys: keys.map { |k| hex_to_bin(k) })
68
+ sigs = param.core.sign(hasher, hex_to_bin(message)).map { |s| bin_to_hex(s) }
68
69
 
69
70
  Signature.new(param, sigs)
70
71
  end
@@ -16,34 +16,33 @@ module WOTS
16
16
  raise ArgumentError, "key must be #{param.n} bytes." unless hex_to_bin(key).bytesize == param.n
17
17
  end
18
18
  @param = param
19
- @keys = keys
19
+ @keys = normalize_hex_array(keys)
20
20
  end
21
21
 
22
22
  # Generate public key from +private_key+ and +pub_seed+.
23
23
  # @param [WOTS::PrivateKey] private_key
24
24
  # @param [String] pub_seed Hex string of +param.n+ bytes.
25
+ # @param [WOTS::Address] addr The address passed to PrivateKey.from_seed.
25
26
  # @raise ArgumentError
26
- def self.from_private_key(private_key, pub_seed)
27
+ def self.from_private_key(private_key, pub_seed, addr: Address.new)
27
28
  raise ArgumentError, 'private_key must be WOTS::PrivateKey.' unless private_key.is_a?(WOTS::PrivateKey)
28
29
  param = private_key.param
29
30
  raise ArgumentError, 'pub_seed must be hex string.' unless hex_string?(pub_seed)
30
31
  raise ArgumentError, "pub_seed must be #{param.n} bytes." unless hex_to_bin(pub_seed).bytesize == param.n
31
- addr = WOTS::Address.new
32
- keys = param.len.times.map do |i|
33
- addr.chain_addr = i
34
- sk = private_key.keys[i]
35
- param.chain(sk, 0, param.w - 1, pub_seed, addr)
36
- end
37
- PublicKey.new(private_key.param, keys)
32
+ hasher = RFC8391Hasher.new(param, pub_seed: hex_to_bin(pub_seed), addr: addr,
33
+ keys: private_key.keys.map { |k| hex_to_bin(k) })
34
+ keys = param.core.pk_gen(hasher).map { |k| bin_to_hex(k) }
35
+ PublicKey.new(param, keys)
38
36
  end
39
37
 
40
38
  # Generate public key from +signature+.
41
39
  # @param [WOTS::Signature] signature
42
40
  # @param [String] pub_seed Hex string of +param.n+ bytes.
43
41
  # @param [String] message Hex string of +param.n+ bytes.
42
+ # @param [WOTS::Address] addr The address passed to PrivateKey.from_seed.
44
43
  # @return [WOTS::PublicKey]
45
44
  # @raise ArgumentError
46
- def self.from_signature(signature, pub_seed, message)
45
+ def self.from_signature(signature, pub_seed, message, addr: Address.new)
47
46
  raise ArgumentError, 'signature must be WOTS::Signature.' unless signature.is_a?(WOTS::Signature)
48
47
  param = signature.param
49
48
  raise ArgumentError, 'pub_seed must be hex string.' unless hex_string?(pub_seed)
@@ -51,23 +50,37 @@ module WOTS
51
50
  raise ArgumentError, "message must be hex string." unless hex_string?(message)
52
51
  raise ArgumentError, "message must be #{param.n} bytes." unless hex_to_bin(message).bytesize == param.n
53
52
 
54
- base_w = param.base_w(message, param.len1)
55
- c_sum = param.compute_checksum(base_w)
56
- base_w = base_w + param.base_w(c_sum, param.len2)
53
+ hasher = RFC8391Hasher.new(param, pub_seed: hex_to_bin(pub_seed), addr: addr)
54
+ sigs = signature.sigs.map { |s| hex_to_bin(s) }
55
+ keys = param.core.pk_from_sig(hasher, sigs, hex_to_bin(message)).map { |k| bin_to_hex(k) }
57
56
 
58
- addr = WOTS::Address.new
57
+ PublicKey.new(param, keys)
58
+ end
59
59
 
60
- keys = param.len.times.map do |i|
61
- addr.chain_addr = i
62
- param.chain(signature.sigs[i], base_w[i], param.w - 1 - base_w[i], pub_seed, addr)
63
- end
60
+ # Verify +signature+ on +message+.
61
+ # Invalid input (wrong types or sizes) raises ArgumentError; a well-formed signature
62
+ # that does not verify, including one made with another parameter set, returns false.
63
+ # @param [WOTS::Signature] signature
64
+ # @param [String] pub_seed Hex string of +param.n+ bytes.
65
+ # @param [String] message Hex string of +param.n+ bytes.
66
+ # @param [WOTS::Address] addr The address passed to PrivateKey.from_seed.
67
+ # @return [Boolean]
68
+ # @raise ArgumentError
69
+ def verify(signature, pub_seed, message, addr: Address.new)
70
+ raise ArgumentError, 'signature must be WOTS::Signature.' unless signature.is_a?(WOTS::Signature)
71
+ return false unless signature.param == param
64
72
 
65
- PublicKey.new(param, keys)
73
+ PublicKey.from_signature(signature, pub_seed, message, addr: addr) == self
66
74
  end
67
75
 
68
76
  def ==(other)
69
77
  return false unless other.is_a?(PublicKey)
70
78
  param == other.param && keys == other.keys
71
79
  end
80
+ alias eql? ==
81
+
82
+ def hash
83
+ [self.class, param.name, param.w, keys].hash
84
+ end
72
85
  end
73
86
  end
@@ -0,0 +1,105 @@
1
+ module WOTS
2
+ # WOTS::Core hasher for the WOTS+ of RFC 8391, bound to one key pair.
3
+ #
4
+ # * secret: PRF_keygen(seed, pub_seed || ADRS) (NIST SP 800-208 Section 6.2), or the
5
+ # stored private key elements when the key was given explicitly.
6
+ # * step: RFC 8391 Algorithm 2 (chain), i.e. F(KEY, X xor BM) with KEY and BM derived by PRF.
7
+ # * compress: the chain end values as they are (an RFC 8391 WOTS+ public key is the array).
8
+ #
9
+ # All values are binary strings.
10
+ class RFC8391Hasher
11
+ # @param [WOTS::Param] param
12
+ # @param [String] pub_seed Binary string of +param.n+ bytes.
13
+ # @param [WOTS::Address] addr The address of the key pair. It is copied, never modified.
14
+ # @param [String] seed Optional secret seed (binary string of +param.n+ bytes).
15
+ # @param [Array<String>] keys Optional private key elements (+param.len+ binary strings).
16
+ # Give at most one of +seed+ and +keys+; with neither, the hasher can only verify.
17
+ # @raise ArgumentError
18
+ def initialize(param, pub_seed:, addr:, seed: nil, keys: nil)
19
+ raise ArgumentError, 'param must be WOTS::Param.' unless param.is_a?(WOTS::Param)
20
+ raise ArgumentError, 'give at most one of seed and keys.' if seed && keys
21
+
22
+ check_value!(param, pub_seed, 'pub_seed')
23
+ check_value!(param, seed, 'seed') if seed
24
+ if keys
25
+ raise ArgumentError, 'keys must be Array.' unless keys.is_a?(Array)
26
+ raise ArgumentError, "keys must have #{param.len} elements." unless keys.length == param.len
27
+
28
+ keys.each { |k| check_value!(param, k, 'key') }
29
+ end
30
+
31
+ @param = param
32
+ @pub_seed = pub_seed.b.freeze
33
+ @seed = seed&.b&.freeze
34
+ @keys = keys&.map { |k| k.b.freeze }&.freeze
35
+ @addr = Address.copy_of(addr)
36
+ @keygen_addr = Address.copy_of(addr)
37
+ @keygen_addr.hash_addr = 0
38
+ @keygen_addr.key_and_mask = 0
39
+ end
40
+
41
+ # @param [Integer] chain_idx
42
+ # @return [String]
43
+ def secret(chain_idx)
44
+ check_index!(chain_idx, @param.len, 'chain_idx')
45
+ return @keys[chain_idx] if @keys
46
+ raise ArgumentError, 'this hasher has no secret seed or private key.' unless @seed
47
+
48
+ @keygen_addr.chain_addr = chain_idx
49
+ @param.keyed_digest(Param::PRF_KEYGEN, @seed, @pub_seed + @keygen_addr.to_payload)
50
+ end
51
+
52
+ # @param [String] x
53
+ # @param [Integer] chain_idx
54
+ # @param [Integer] hash_idx
55
+ # @return [String]
56
+ def step(x, chain_idx, hash_idx)
57
+ check_value!(@param, x, 'x')
58
+ check_index!(chain_idx, @param.len, 'chain_idx')
59
+ check_index!(hash_idx, @param.w - 1, 'hash_idx')
60
+
61
+ @addr.chain_addr = chain_idx
62
+ @addr.hash_addr = hash_idx
63
+
64
+ @addr.key_and_mask = 0
65
+ key = @param.keyed_digest(Param::PRF, @pub_seed, @addr.to_payload)
66
+
67
+ @addr.key_and_mask = 1
68
+ mask = @param.keyed_digest(Param::PRF, @pub_seed, @addr.to_payload)
69
+
70
+ @param.keyed_digest(Param::F, key, xor(x, mask))
71
+ end
72
+
73
+ # @param [Array<String>] values
74
+ # @return [Array<String>]
75
+ def compress(values)
76
+ values
77
+ end
78
+
79
+ # Redacted inspect, so that the secret seed and private key elements never reach
80
+ # logs, exception reports or REPL output.
81
+ # @return [String]
82
+ def inspect
83
+ secret = if @keys then 'keys' elsif @seed then 'seed' else 'none' end
84
+ "#<WOTS::RFC8391Hasher param=#{@param.name} w=#{@param.w} secret=#{secret} [REDACTED]>"
85
+ end
86
+
87
+ private
88
+
89
+ def xor(a, b)
90
+ raise ArgumentError, 'length mismatch.' unless a.bytesize == b.bytesize
91
+
92
+ a.bytes.zip(b.bytes).map { |i, j| i ^ j }.pack('C*')
93
+ end
94
+
95
+ def check_index!(value, limit, name)
96
+ raise ArgumentError, "#{name} must be integer." unless value.is_a?(Integer)
97
+ raise ArgumentError, "#{name} must be between 0 and #{limit - 1}." unless value >= 0 && value < limit
98
+ end
99
+
100
+ def check_value!(param, value, name)
101
+ raise ArgumentError, "#{name} must be string." unless value.is_a?(String)
102
+ raise ArgumentError, "#{name} must be #{param.n} bytes." unless value.bytesize == param.n
103
+ end
104
+ end
105
+ end
@@ -15,7 +15,17 @@ module WOTS
15
15
  end
16
16
 
17
17
  @param = param
18
- @sigs = sigs
18
+ @sigs = normalize_hex_array(sigs)
19
+ end
20
+
21
+ def ==(other)
22
+ return false unless other.is_a?(Signature)
23
+ param == other.param && sigs == other.sigs
24
+ end
25
+ alias eql? ==
26
+
27
+ def hash
28
+ [self.class, param.name, param.w, sigs].hash
19
29
  end
20
30
  end
21
31
  end
data/lib/wots/util.rb CHANGED
@@ -3,12 +3,15 @@ module WOTS
3
3
 
4
4
  # Check whether +data+ is hex string or not.
5
5
  # An odd-length string is NOT a valid hex string, since +Array#pack+ would
6
- # silently zero-pad it and make two distinct inputs collide.
6
+ # silently zero-pad it and make two distinct inputs collide. A string in an
7
+ # encoding that is not ASCII compatible (e.g. UTF-16) is not a hex string either.
7
8
  # @param [String] data
8
9
  # @return [Boolean]
9
10
  # @raise [ArgumentError]
10
11
  def hex_string?(data)
11
12
  raise ArgumentError, 'data must be string' unless data.is_a?(String)
13
+ return false unless data.encoding.ascii_compatible?
14
+
12
15
  data.length.even? && data.match?(/\A[0-9a-fA-F]+\z/)
13
16
  end
14
17
 
@@ -21,6 +24,15 @@ module WOTS
21
24
  [data].pack('H*')
22
25
  end
23
26
 
27
+ # Normalize the validated hex strings in +values+ to lowercase and freeze them,
28
+ # so that equality does not depend on letter case and later changes to the
29
+ # caller's strings or array cannot affect an object that has validated them.
30
+ # @param [Array<String>] values Hex strings.
31
+ # @return [Array<String>] A frozen array of frozen lowercase hex strings.
32
+ def normalize_hex_array(values)
33
+ values.map { |v| v.downcase.freeze }.freeze
34
+ end
35
+
24
36
  # Convert binary string +data+ to hex string.
25
37
  # @param [String] data Binary string.
26
38
  # @return [String] Hex string.
data/lib/wots/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module WOTS
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/wots.rb CHANGED
@@ -2,8 +2,10 @@
2
2
  require 'digest'
3
3
  require_relative "wots/version"
4
4
  require_relative 'wots/util'
5
+ require_relative 'wots/core'
5
6
  require_relative 'wots/param'
6
7
  require_relative 'wots/address'
8
+ require_relative 'wots/rfc8391_hasher'
7
9
  require_relative 'wots/private_key'
8
10
  require_relative 'wots/public_key'
9
11
  require_relative 'wots/signature'
metadata CHANGED
@@ -1,14 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wots
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - azuchi
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: sha3
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.2'
12
26
  description: A Ruby implementation of the Winternitz OTS (W-OTS+), as described in
13
27
  RFC8391.
14
28
  email:
@@ -20,18 +34,21 @@ files:
20
34
  - ".rspec"
21
35
  - ".ruby-gemset"
22
36
  - ".ruby-version"
37
+ - CHANGELOG.md
23
38
  - CODE_OF_CONDUCT.md
24
39
  - LICENSE.txt
25
40
  - README.md
26
41
  - Rakefile
27
42
  - lib/wots.rb
28
43
  - lib/wots/address.rb
44
+ - lib/wots/core.rb
29
45
  - lib/wots/param.rb
30
46
  - lib/wots/param/sha256.rb
31
47
  - lib/wots/param/sha512.rb
32
48
  - lib/wots/param/shake256.rb
33
49
  - lib/wots/private_key.rb
34
50
  - lib/wots/public_key.rb
51
+ - lib/wots/rfc8391_hasher.rb
35
52
  - lib/wots/signature.rb
36
53
  - lib/wots/util.rb
37
54
  - lib/wots/version.rb
@@ -42,7 +59,8 @@ licenses:
42
59
  metadata:
43
60
  homepage_uri: https://github.com/azuchi/wots
44
61
  source_code_uri: https://github.com/azuchi/wots
45
- changelog_uri: https://github.com/azuchi/wots
62
+ changelog_uri: https://github.com/azuchi/wots/blob/main/CHANGELOG.md
63
+ rubygems_mfa_required: 'true'
46
64
  rdoc_options: []
47
65
  require_paths:
48
66
  - lib
@@ -57,7 +75,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
57
75
  - !ruby/object:Gem::Version
58
76
  version: '0'
59
77
  requirements: []
60
- rubygems_version: 4.0.3
78
+ rubygems_version: 3.6.7
61
79
  specification_version: 4
62
80
  summary: A Ruby implementation of the Winternitz OTS (W-OTS+), as described in RFC8391.
63
81
  test_files: []