wots 0.1.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: d6efca98c49499aae50fd5225762614026111f16cfc4ccee573e6ea6857e0664
4
- data.tar.gz: 236faef3ed700463ea083a7cae7de8d6365663e5ad1e65c9819dab21e92fb6d3
3
+ metadata.gz: 44928dd35ba7e5a8294069e3f85916e3a635440d52d63304337f218503d66443
4
+ data.tar.gz: 1e2b102c71bb6d73c96a5aa31cee0a74b4de756179b791a68733e3233e86c396
5
5
  SHA512:
6
- metadata.gz: b8eefc4c86bf05cfa7820e291bcbacf3d1fba792d91a7392f086147979468e8fe8caa62de59c7f47c00bc65145ab9d82f91cbba35215f295ddb85484dea6d477
7
- data.tar.gz: 0a3a1affaa6a0d9e9298927cfd16e12a3e84aaa4a8b558f4b739002e2005a4d07f0747156c2716c12e879d5770b3c7aa06a852a47f148b9b3ebeb32f7026d27a
6
+ metadata.gz: c92615a1dece0f8ed08dcd809c32dd8f4c4bf2476accd34799a66f3dd277ecb6bedf3ce97c6f67132bd46a8ca09b7e542497b715f515b6d4180ab9f59426e386
7
+ data.tar.gz: 2a1f7a07e0860068fec8b1dc2c685efd02f8e287971d711244642979bb26a52233f338135696b86d77ef59b0eb00baf6bdb2099b23956d482d57677d0c509ce6
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- ruby-3.4.3
1
+ ruby-4.0.0
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,18 +86,119 @@ 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
+
123
+ ## Input format
124
+
125
+ All key material, seeds and messages are **even-length hexadecimal strings**. Raw binary strings and
126
+ odd-length hex strings are rejected with an `ArgumentError`; convert them with `unpack1('H*')` first.
127
+
128
+ ```ruby
129
+ message = Digest::SHA256.hexdigest("some payload") # 64 hex chars = 32 bytes
130
+ signature = private_key.sign(pub_seed, message)
131
+ ```
132
+
75
133
  ## Security Considerations
76
134
 
77
- **⚠️ IMPORTANT: This is a cryptograFixphic library implementation. While it follows RFC 8391 specifications,
135
+ **⚠️ IMPORTANT: This is a cryptographic library implementation. While it follows RFC 8391 specifications,
78
136
  it has not undergone formal security audits. Use at your own risk in production environments.**
79
137
 
80
- WOTS+ is a **one-time signature scheme**. Each private key should only be used to sign **one message**.
81
- 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
164
+
165
+ The seed passed to `WOTS::PrivateKey.from_seed` is secret key material and must come from a
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.
171
+
172
+ Only the Winternitz parameters defined in RFC 8391 (`w = 4` and `w = 16`) are accepted. Other values
173
+ overflow the checksum encoding, which drops the high bits of the checksum and allows an attacker to
174
+ forge signatures without the private key.
175
+
176
+ Private key material is held in Ruby `String` objects and is not zeroized; it remains in memory until
177
+ garbage collected.
82
178
 
83
179
  ## Specifications
84
180
 
85
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
@@ -1,27 +1,79 @@
1
1
  module WOTS
2
+ # WOTS+ hash function address.
3
+ # Each field is packed into a fixed width big endian integer by #to_payload,
4
+ # so a value outside its range would silently wrap and collide with another
5
+ # address, breaking the domain separation the chaining function relies on.
2
6
  class Address
3
- attr_accessor :layer_addr
4
- attr_accessor :tree_addr
5
- attr_accessor :ots_addr
6
- attr_accessor :chain_addr
7
- attr_accessor :hash_addr
8
- attr_accessor :key_and_mask # 0: key generation, 1: bitmask generation
7
+ UINT32_MAX = 0xFFFFFFFF
8
+ UINT64_MAX = 0xFFFFFFFFFFFFFFFF
9
+
10
+ attr_reader :layer_addr
11
+ attr_reader :tree_addr
12
+ attr_reader :ots_addr
13
+ attr_reader :chain_addr
14
+ attr_reader :hash_addr
15
+ attr_reader :key_and_mask # 0: key generation, 1: bitmask generation
9
16
 
10
17
  def initialize(layer_addr: 0, tree_addr: 0, ots_addr: 0, chain_addr: 0, hash_addr: 0, key_and_mask: 0)
11
- @layer_addr = layer_addr
12
- @tree_addr = tree_addr
13
- @ots_addr = ots_addr
14
- @chain_addr = chain_addr
15
- @hash_addr = hash_addr
16
- @key_and_mask = key_and_mask
18
+ self.layer_addr = layer_addr
19
+ self.tree_addr = tree_addr
20
+ self.ots_addr = ots_addr
21
+ self.chain_addr = chain_addr
22
+ self.hash_addr = hash_addr
23
+ self.key_and_mask = key_and_mask
24
+ end
25
+
26
+ def layer_addr=(value)
27
+ @layer_addr = check_range(:layer_addr, value, UINT32_MAX)
28
+ end
29
+
30
+ def tree_addr=(value)
31
+ @tree_addr = check_range(:tree_addr, value, UINT64_MAX)
32
+ end
33
+
34
+ def ots_addr=(value)
35
+ @ots_addr = check_range(:ots_addr, value, UINT32_MAX)
36
+ end
37
+
38
+ def chain_addr=(value)
39
+ @chain_addr = check_range(:chain_addr, value, UINT32_MAX)
40
+ end
41
+
42
+ def hash_addr=(value)
43
+ @hash_addr = check_range(:hash_addr, value, UINT32_MAX)
44
+ end
45
+
46
+ def key_and_mask=(value)
47
+ @key_and_mask = check_range(:key_and_mask, value, UINT32_MAX)
17
48
  end
18
49
 
19
50
  def type
20
51
  0
21
52
  end
22
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
+
23
65
  def to_payload
24
66
  [layer_addr, tree_addr, type, ots_addr, chain_addr, hash_addr, key_and_mask].pack('NQ>NNNNN')
25
67
  end
68
+
69
+ private
70
+
71
+ # @raise ArgumentError If +value+ would not survive the round trip through +pack+.
72
+ def check_range(name, value, max)
73
+ raise ArgumentError, "#{name} must be integer." unless value.is_a?(Integer)
74
+ raise ArgumentError, "#{name} must be between 0 and #{max}." unless value >= 0 && value <= max
75
+
76
+ value
77
+ end
26
78
  end
27
- end
79
+ 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.
@@ -10,126 +10,193 @@ module WOTS
10
10
  autoload :SHA512, 'wots/param/sha512'
11
11
  autoload :SHAKE256, 'wots/param/shake256'
12
12
 
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).
15
+ SUPPORTED_NAMES = {
16
+ 'WOTSP-SHA2_256' => 32,
17
+ 'WOTSP-SHA2_512' => 64,
18
+ 'WOTSP-SHAKE_256' => 32
19
+ }.freeze
20
+
21
+ # The Winternitz parameter is a member of the set {4, 16}.
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
45
+
13
46
  attr_reader :name
14
47
  attr_reader :n
15
48
  attr_reader :w
49
+ # @return [WOTS::Core] The WOTS+ engine for this parameter set.
50
+ attr_reader :core
16
51
 
17
52
  # @param [Hash] opts
53
+ # @option opts [String] :name the name of the parameter set; it is a member of +SUPPORTED_NAMES+.
18
54
  # @option opts [Integer] :n the message length as well as the length of a private key,
19
55
  # public key, or signature element in bytes.
20
56
  # @option opts [Integer] :w the Winternitz parameter; it is a member of the set {4, 16}.
21
- # @option opts [Integer] :len the number of n-byte string elements in a WOTS+ private key, public key, and signature.
57
+ # @raise ArgumentError
22
58
  def initialize(opts)
23
59
  raise ArgumentError, 'name must be string.' unless opts[:name].is_a?(String)
24
60
  raise ArgumentError, 'n must be integer.' unless opts[:n].is_a?(Integer)
25
61
  raise ArgumentError, 'w must be integer.' unless opts[:w].is_a?(Integer)
26
62
 
27
- @name = opts[:name]
63
+ expected_n = SUPPORTED_NAMES[opts[:name]]
64
+ raise ArgumentError, "name must be one of #{SUPPORTED_NAMES.keys.join(', ')}." if expected_n.nil?
65
+ raise ArgumentError, "n must be #{expected_n} for #{opts[:name]}." unless opts[:n] == expected_n
66
+ raise ArgumentError, "w must be one of #{SUPPORTED_W.join(', ')}." unless SUPPORTED_W.include?(opts[:w])
67
+
68
+ @name = opts[:name].dup.freeze
28
69
  @n = opts[:n]
29
70
  @w = opts[:w]
71
+ @core = Core.new(n: n, w: w)
72
+ freeze
73
+ end
74
+
75
+ # log2(w), i.e. the number of bits consumed by a single base w digit.
76
+ # @return [Integer]
77
+ def lg_w
78
+ core.lg_w
30
79
  end
31
80
 
32
81
  def len1
33
- @len1 ||= (8.0 * n / Math.log2(w)).ceil
82
+ core.len1
34
83
  end
35
84
 
36
85
  def len2
37
- @len2 ||= (Math.log2(len1 * (w - 1)) / Math.log2(w)).floor + 1
86
+ core.len2
38
87
  end
39
88
 
40
89
  def len
41
- @len ||= len1 + len2
90
+ core.len
42
91
  end
43
92
 
93
+ # @param [String] k Hex string of +n+ bytes.
94
+ # @param [String] m Hex string of +n+ bytes.
95
+ # @return [String] Hex string.
44
96
  def f(k, m)
45
- keyed_hash(0, k, m)
97
+ keyed_hash(F, k, m)
46
98
  end
47
99
 
100
+ # @param [String] k Hex string of +n+ bytes.
101
+ # @param [String] m Hex string of 2n bytes.
102
+ # @return [String] Hex string.
48
103
  def h(k, m)
49
- keyed_hash(1, k, m)
104
+ keyed_hash(H, k, m)
50
105
  end
51
106
 
107
+ # @param [String] k Hex string of 3n bytes.
108
+ # @param [String] m Hex string.
109
+ # @return [String] Hex string.
52
110
  def h_msg(k, m)
53
- keyed_hash(2, k, m)
111
+ keyed_hash(H_MSG, k, m)
54
112
  end
55
113
 
56
114
  # PRF function.
57
- # @param [String] k key
58
- # @param [String] m message
115
+ # @param [String] k key Hex string of +n+ bytes.
116
+ # @param [String] m message Hex string of 32 bytes.
59
117
  # @return [String] Hex string.
60
118
  def prf(k, m)
61
- 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
62
165
  end
63
166
 
64
167
  # Convert data as base w representation.
65
- # @param [String] data The data to be converted.
168
+ # @param [String] data The data to be converted. Hex string.
66
169
  # @param [Integer] out_len Output length.
67
170
  # @return [Array] An array of integer.
171
+ # @raise ArgumentError If +data+ is too short to produce +out_len+ digits.
68
172
  def base_w(data, out_len)
69
- x = hex_to_bin(data)
70
-
71
- basew = []
72
- in_idx = 0
73
- total = 0
74
- bits = 0
75
- lg_w = Math.log2(w).to_i
76
-
77
- out_len.times do
78
- if bits == 0
79
- break if in_idx >= x.bytesize
80
- total = x.getbyte(in_idx)
81
- in_idx += 1
82
- bits += 8
83
- end
84
-
85
- bits -= lg_w
86
- basew << ((total >> bits) & (w - 1))
87
- end
88
-
89
- basew
173
+ core.base_w(hex_to_bin(data), out_len)
90
174
  end
91
175
 
92
176
  # Compute checksum for +base_w+.
93
177
  # @param [Array] base_w
94
- # @return [String] Checksum binary string.
178
+ # @return [String] Checksum hex string.
95
179
  def compute_checksum(base_w)
96
- c_sum = 0
97
- len1.times do |i|
98
- c_sum = (c_sum + w - 1 - base_w[i])
99
- end
100
- c_sum = (c_sum << (8 - ((len2 * Math.log2(w).to_i) % 8)))
101
- len_2_bytes = ((len2 * Math.log2(w).to_i) / 8.0).ceil
102
- to_byte(c_sum, len_2_bytes)
180
+ bin_to_hex(core.checksum_bytes(base_w))
103
181
  end
104
182
 
105
183
  # WOTS+ Chaining Function.
184
+ # The chain address is taken from +addr+; +addr+ itself is not modified.
106
185
  # @see https://datatracker.ietf.org/doc/html/rfc8391#autoid-16
107
- # @param [String] x Input string.
186
+ # @param [String] x Input string. Hex string.
108
187
  # @param [Integer] start_idx Start index.
109
188
  # @param [Integer] steps Number of steps.
110
- # @param [String] seed Seed.
189
+ # @param [String] seed Seed. Hex string.
111
190
  # @param [WOTS::Address] addr Address.
112
- # @return [String] Result.
191
+ # @return [String] Hex string.
192
+ # @raise ArgumentError
113
193
  def chain(x, start_idx, steps, seed, addr)
114
- return x if steps == 0
115
- raise "Invalid range" if (start_idx + steps) > (w - 1)
116
-
117
- result = x.dup
118
-
119
- steps.times do |i|
120
- addr.hash_addr = (start_idx + i)
194
+ raise ArgumentError, 'x must be hex string.' unless hex_string?(x)
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)
121
197
 
122
- addr.key_and_mask = 0
123
- key = prf(seed, addr.to_payload)
124
-
125
- addr.key_and_mask = 1
126
- mask = prf(seed, addr.to_payload)
127
-
128
- masked = xor_bytes(result, mask)
129
- result = f(key, masked)
130
- end
131
-
132
- 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))
133
200
  end
134
201
 
135
202
  def ==(other)
@@ -140,8 +207,14 @@ module WOTS
140
207
  # Convert +value+ to +length+ size binary string.
141
208
  # @param [Integer] value
142
209
  # @param [Integer] length
143
- # @return [String]
210
+ # @return [String] Binary string.
211
+ # @raise ArgumentError If +value+ does not fit in +length+ bytes.
144
212
  def to_byte(value, length)
213
+ raise ArgumentError, 'value must be integer.' unless value.is_a?(Integer)
214
+ raise ArgumentError, 'length must be integer.' unless length.is_a?(Integer)
215
+ raise ArgumentError, 'value must not be negative.' if value.negative?
216
+ raise ArgumentError, "value does not fit in #{length} bytes." if value.bit_length > length * 8
217
+
145
218
  bytes = []
146
219
 
147
220
  length.times do
@@ -154,24 +227,12 @@ module WOTS
154
227
 
155
228
  private
156
229
 
157
- def xor_bytes(a, b)
158
- [a].pack('H*').unpack('C*').zip(
159
- [b].pack('H*').unpack('C*')
160
- ).map { |x, y| x ^ y }.pack("C*")
161
- end
162
-
230
+ # @param [Integer] prefix Domain separation prefix.
231
+ # @param [String] k Hex string.
232
+ # @param [String] m Hex string.
233
+ # @return [String] Hex string.
163
234
  def keyed_hash(prefix, k, m)
164
- payload = to_byte(prefix, n) + hex_to_bin(k) + hex_to_bin(m)
165
- case name
166
- when 'WOTSP-SHA2_256'
167
- Digest::SHA256.hexdigest(payload)
168
- when 'WOTSP-SHA2_512'
169
- Digest::SHA512.hexdigest(payload)
170
- when 'WOTSP-SHAKE_256'
171
- OpenSSL::Digest.new('shake256', payload).hexdigest
172
- else
173
- raise 'Unknown param'
174
- end
235
+ bin_to_hex(keyed_digest(prefix, hex_to_bin(k), hex_to_bin(m)))
175
236
  end
176
237
  end
177
- end
238
+ end
@@ -17,53 +17,65 @@ 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
- # @param [String] seed
30
+ # @param [String] seed The secret seed. Hex string of +param.n+ bytes.
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]
26
36
  # @raise ArgumentError
27
- def self.from_seed(param, seed)
37
+ def self.from_seed(param, seed, pub_seed, addr: Address.new)
28
38
  raise ArgumentError, "param must be WOTS::Param." unless param.is_a?(WOTS::Param)
29
- raise ArgumentError, "seed must be String." unless seed.is_a?(String)
39
+ raise ArgumentError, "seed must be hex string." unless hex_string?(seed)
30
40
  raise ArgumentError, "seed must be #{param.n} bytes." unless hex_to_bin(seed).bytesize == param.n
31
- 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
32
43
 
33
- keys = param.len.times.map do |i|
34
- param.prf(seed, param.to_byte(i, 32))
35
- 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)) }
36
46
 
37
47
  PrivateKey.new(param, keys)
38
48
  end
39
49
 
40
50
  # Generate signature.
41
- # @param [String] pub_seed The Public seed.
42
- # @param [String] message The message to be signed.
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.
55
+ # @param [String] pub_seed The Public seed. Hex string of +param.n+ bytes.
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.
43
58
  # @return [WOTS::Signature]
44
59
  # @raise ArgumentError
45
- def sign(pub_seed, message)
60
+ def sign(pub_seed, message, addr: Address.new)
46
61
  raise ArgumentError, 'pub_seed must be hex string.' unless hex_string?(pub_seed)
47
62
  raise ArgumentError, "pub_seed must be #{param.n} bytes." unless hex_to_bin(pub_seed).bytesize == param.n
48
- raise ArgumentError, "message must be string." unless message.is_a?(String)
63
+ raise ArgumentError, "message must be hex string." unless hex_string?(message)
49
64
  raise ArgumentError, "message must be #{param.n} bytes." unless hex_to_bin(message).bytesize == param.n
50
65
 
51
- addr = Address.new
52
-
53
- # Convert message to base w
54
- base_w = param.base_w(message, param.len1)
55
-
56
- # Compute checksum
57
- c_sum = param.compute_checksum(base_w)
58
-
59
- base_w = base_w + param.base_w(c_sum, param.len2)
60
-
61
- sigs = param.len.times.map do |i|
62
- addr.chain_addr = i
63
- param.chain(keys[i], 0, base_w[i], pub_seed, addr)
64
- 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) }
65
69
 
66
70
  Signature.new(param, sigs)
67
71
  end
72
+
73
+ # Redacted inspect.
74
+ # The default implementation prints every instance variable, which would
75
+ # write the whole private key into logs, exception reports and REPL output.
76
+ # @return [String]
77
+ def inspect
78
+ "#<WOTS::PrivateKey param=#{param.name} w=#{param.w} keys=[REDACTED]>"
79
+ end
68
80
  end
69
81
  end
@@ -16,56 +16,71 @@ 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
- # @param [String] pub_seed
25
- def self.from_private_key(private_key, pub_seed)
24
+ # @param [String] pub_seed Hex string of +param.n+ bytes.
25
+ # @param [WOTS::Address] addr The address passed to PrivateKey.from_seed.
26
+ # @raise ArgumentError
27
+ def self.from_private_key(private_key, pub_seed, addr: Address.new)
26
28
  raise ArgumentError, 'private_key must be WOTS::PrivateKey.' unless private_key.is_a?(WOTS::PrivateKey)
27
29
  param = private_key.param
28
30
  raise ArgumentError, 'pub_seed must be hex string.' unless hex_string?(pub_seed)
29
31
  raise ArgumentError, "pub_seed must be #{param.n} bytes." unless hex_to_bin(pub_seed).bytesize == param.n
30
- addr = WOTS::Address.new
31
- keys = param.len.times.map do |i|
32
- addr.chain_addr = i
33
- sk = private_key.keys[i]
34
- param.chain(sk, 0, param.w - 1, pub_seed, addr)
35
- end
36
- 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)
37
36
  end
38
37
 
39
38
  # Generate public key from +signature+.
40
39
  # @param [WOTS::Signature] signature
41
- # @param [String] pub_seed
42
- # @param [String] message
40
+ # @param [String] pub_seed Hex string of +param.n+ bytes.
41
+ # @param [String] message Hex string of +param.n+ bytes.
42
+ # @param [WOTS::Address] addr The address passed to PrivateKey.from_seed.
43
43
  # @return [WOTS::PublicKey]
44
44
  # @raise ArgumentError
45
- def self.from_signature(signature, pub_seed, message)
45
+ def self.from_signature(signature, pub_seed, message, addr: Address.new)
46
+ raise ArgumentError, 'signature must be WOTS::Signature.' unless signature.is_a?(WOTS::Signature)
46
47
  param = signature.param
47
48
  raise ArgumentError, 'pub_seed must be hex string.' unless hex_string?(pub_seed)
48
49
  raise ArgumentError, "pub_seed must be #{param.n} bytes." unless hex_to_bin(pub_seed).bytesize == param.n
49
- raise ArgumentError, "message must be string." unless message.is_a?(String)
50
+ raise ArgumentError, "message must be hex string." unless hex_string?(message)
50
51
  raise ArgumentError, "message must be #{param.n} bytes." unless hex_to_bin(message).bytesize == param.n
51
52
 
52
- base_w = param.base_w(message, param.len1)
53
- c_sum = param.compute_checksum(base_w)
54
- 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) }
55
56
 
56
- addr = WOTS::Address.new
57
+ PublicKey.new(param, keys)
58
+ end
57
59
 
58
- keys = param.len.times.map do |i|
59
- addr.chain_addr = i
60
- param.chain(signature.sigs[i], base_w[i], param.w - 1 - base_w[i], pub_seed, addr)
61
- 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
62
72
 
63
- PublicKey.new(param, keys)
73
+ PublicKey.from_signature(signature, pub_seed, message, addr: addr) == self
64
74
  end
65
75
 
66
76
  def ==(other)
67
77
  return false unless other.is_a?(PublicKey)
68
78
  param == other.param && keys == other.keys
69
79
  end
80
+ alias eql? ==
81
+
82
+ def hash
83
+ [self.class, param.name, param.w, keys].hash
84
+ end
70
85
  end
71
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
@@ -2,31 +2,45 @@ module WOTS
2
2
  module Util
3
3
 
4
4
  # Check whether +data+ is hex string or not.
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. A string in an
7
+ # encoding that is not ASCII compatible (e.g. UTF-16) is not a hex string either.
5
8
  # @param [String] data
6
9
  # @return [Boolean]
7
10
  # @raise [ArgumentError]
8
11
  def hex_string?(data)
9
12
  raise ArgumentError, 'data must be string' unless data.is_a?(String)
10
- data.match?(/\A[0-9a-fA-F]+\z/)
13
+ return false unless data.encoding.ascii_compatible?
14
+
15
+ data.length.even? && data.match?(/\A[0-9a-fA-F]+\z/)
11
16
  end
12
17
 
13
18
  # Convert hex string +data+ to binary.
14
- # @param [String] data
15
- # @return [String]
16
- # @raise [ArgumentError]
19
+ # @param [String] data Hex string.
20
+ # @return [String] Binary string.
21
+ # @raise [ArgumentError] If +data+ is not a hex string.
17
22
  def hex_to_bin(data)
18
- raise ArgumentError, 'data must be string' unless data.is_a?(String)
19
- hex_string?(data) ? [data].pack('H*') : data
23
+ raise ArgumentError, 'data must be hex string' unless hex_string?(data)
24
+ [data].pack('H*')
25
+ end
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
20
34
  end
21
35
 
22
36
  # Convert binary string +data+ to hex string.
23
- # @param [String] data
24
- # @return [String]
37
+ # @param [String] data Binary string.
38
+ # @return [String] Hex string.
25
39
  # @raise [ArgumentError]
26
40
  def bin_to_hex(data)
27
41
  raise ArgumentError, 'data must be string' unless data.is_a?(String)
28
- hex_string?(data) ? data : data.unpack1('H*')
42
+ data.unpack1('H*')
29
43
  end
30
44
 
31
45
  end
32
- end
46
+ end
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.1.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.1.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