Skip to content

Fix/security hardening - #1

Merged
azuchi merged 8 commits into
mainfrom
fix/security-hardening
Aug 11, 2026
Merged

Fix/security hardening#1
azuchi merged 8 commits into
mainfrom
fix/security-hardening

Conversation

@azuchi

@azuchi azuchi commented Aug 11, 2026

Copy link
Copy Markdown
Owner

No description provided.

azuchi and others added 8 commits August 11, 2026 21:56
`Param#compute_checksum` shifts the checksum left by
`8 - ((len2 * lg_w) % 8)` and encodes it with `to_byte`. When
`(len2 * lg_w) % 8 == 0` the shift is a full byte and the result no
longer fits in `len_2_bytes`, and `to_byte` dropped the high bits
without any error.

That breaks the monotonicity the scheme relies on: increasing a message
digit no longer necessarily decreases the encoded checksum. With w=256
an attacker can take a valid signature, advance one message byte by
`(c_sum & 0xFF) + 1` and advance the checksum digit from
`c_sum & 0xFF` to 255, producing a valid signature on a different
message without the private key. w=3 and w=5 overflow the same way.

`Param#initialize` only checked that w was an Integer, and the test
suite shipped w=256 vectors, so the broken parameter sets looked
supported. Restrict w to {4, 16} as RFC 8391 defines, and validate the
parameter set name and its required n while we are here - previously a
mismatched n failed later and less clearly, in `PrivateKey.new`.

Also make `to_byte` raise instead of truncating, so a future parameter
set cannot reintroduce the same class of bug silently, and extract
`lg_w` now that three methods compute it.

The w=16 and w=4 test vectors are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`hex_to_bin` decoded its argument when it looked like hex and returned
it untouched otherwise, and `hex_string?` accepted odd-length strings.
Two consequences:

- `["abc"].pack('H*')` and `["abc0"].pack('H*')` are both "\xAB\xC0", so
  a 63 character message passed the "must be 32 bytes" check and
  produced exactly the same signature as its 64 character counterpart.
  The same held for key and signature elements.
- A hex string and the raw bytes it encodes were signed identically, so
  callers that treat the two as distinct messages could not rely on a
  signature identifying one of them.

Require even-length hex everywhere and raise on anything else. Since
that removes the implicit conversion the internals were relying on,
make the hex/binary boundary explicit instead: every documented
argument and return value is now a hex string, and binary lives only
inside a single method. `compute_checksum` returns hex, `xor_bytes`
takes and returns hex (and checks that both sides are the same length),
and the two `prf` call sites that pass an address payload or a byte
index encode it with `bin_to_hex` first.

This is a breaking change for callers that passed raw binary or
odd-length hex; they now get an ArgumentError.

The w=16 and w=4 test vectors are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three places accepted input they could not actually handle:

- `base_w` broke out of its loop when the input ran out, silently
  returning fewer digits than requested. Callers index the result up to
  `len`, so a short return surfaced later as a NoMethodError on nil
  rather than as a rejected argument. Check the length up front.

- `chain` returned early for zero steps before validating the range, so
  an out-of-range `start_idx` went unnoticed, and it raised a bare
  RuntimeError rather than ArgumentError for the range it did check.

- `PublicKey.from_signature` called `signature.param` without checking
  the type, unlike every other entry point in the gem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Record what the preceding commits enforce: inputs are even-length hex,
the seed is secret key material that needs a CSPRNG, and w is limited
to {4, 16} because other values let an attacker forge signatures.

Note that private key material is not zeroized, and fix a typo in the
existing warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The default `inspect` prints every instance variable, so inspecting a
PrivateKey emitted all `len` secret key elements - about 4.7 KB for the
SHA2-256 parameter set. That reaches logs through `p`, `pp`, a REPL, or
an exception reporter that dumps local variables, and WOTS+ is a
one-time scheme: a leaked private key lets anyone sign anything.

Print the parameter set and a placeholder instead. `pp` uses a custom
`inspect` when one is defined, and the default `to_s` never included
the instance variables, so both are covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Address#to_payload` packs each field into a fixed width big endian
integer with 'NQ>NNNNN', and `pack` truncates instead of raising. So
`ots_addr` 2**32 + 5 and 5 produced the same payload, as did
`chain_addr` -1 and 0xFFFFFFFF, and `tree_addr` 2**64 and 0.

The payload is the PRF input that separates one chain from another, so
two addresses colliding means two different chains derive the same key
and bitmask. WOTS+ itself never sets a field near its bound, but
Address is public and a caller building XMSS on top of this gem sets
layer_addr, tree_addr and ots_addr itself.

Reject anything that would not survive the round trip through `pack`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`RubyVM::YJIT.enable` was added in Ruby 3.3, and the CI matrix still
includes 3.2, so guard the call and let older rubies run without it.

Putting this in spec_helper rather than in the Rakefile covers
`bundle exec rspec` as well as `rake`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@azuchi
azuchi merged commit 08d4b6a into main Aug 11, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant