From 874103873eca5e549845508268c03f8a0132d8ed Mon Sep 17 00:00:00 2001 From: Rahul Pramod Jaguste Date: Tue, 21 Jul 2026 00:32:47 +0100 Subject: [PATCH 1/3] fix: KeyNormCheck rejects BUFF weak keys at verify (Dao 2026/1298) The verifier only rejected negative q00[0], never a small positive one, so a malformed public key with tiny q00[0] (e.g. constant q00=1 weak keys with preimage (1,0,a,1)) was accepted and broke HAWK's BUFF properties: one zero-signature verified under a constant weak key for essentially ALL messages (message-bound-signature / M-S-UEO break). Confirmed against THIS crate before fixing: the constant weak key + the trivial (salt=0, s1=0) witness verified for 256/256 distinct messages. After the fix: 0/256. Fix adds a KeyNormCheck floor in verify_inner: reject q00[0] < 2080. For an auto-adjoint key q00[0] = ||(f,g)||^2, and honest keygen resamples until ||(f,g)||^2 >= 2080 (HAWK_512_L2LOW in keygen/hawk_keygen.rs, from reference ng_hawk.c:462), so this is exact for HAWK-512 and a no-op for honest keys. 2080 is the HAWK-512 value, NOT the HAWK-256 value 556, which would leave a [556, 2080) gap that no honest key occupies. Measured over 48 honest keys, q00[0] ranged [2080, 2290] -- the floor is exactly tight and costs no margin. verify_inner is the single chokepoint every verification path routes through, so this one check covers HawkPublicKey::verify and all callers. Regression test tests/buff_poc.rs asserts the closed state for every message and every a in Dao's witness table, and pins the boundary (2080 accepts, 2079 rejects, 556 must REJECT so nobody reinstates the HAWK-256 value). Full suite green (225 tests, incl. NIST KATs); honest sign/verify unaffected. --- src/verify/consts.rs | 19 ++++ src/verify/verify_inner.rs | 12 ++- tests/buff_poc.rs | 202 +++++++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 tests/buff_poc.rs diff --git a/src/verify/consts.rs b/src/verify/consts.rs index d14d24e..6cb1f31 100644 --- a/src/verify/consts.rs +++ b/src/verify/consts.rs @@ -35,6 +35,25 @@ pub const BITS_LIMS1: [i8; 11] = [0, 0, 0, 0, 0, 0, 0, 0, 9, 9, 10]; /// Source: hawk_vrfy.c:1618 — `max_tnorm = 8317` for logn=9. pub const HAWK_512_MAX_TNORM: u32 = 8317; +/// KeyNormCheck floor for `q00[0]` (Dao, eprint 2026/1298). +/// +/// For an auto-adjoint public key, `q00[0] = ||(f,g)||^2`. Honest keygen +/// resamples until `||(f,g)||^2 >= 2080` (see `keygen::hawk_keygen`, +/// `HAWK_512_L2LOW`, from reference `ng_hawk.c:462`), so every legitimately +/// generated HAWK-512 key has `q00[0] >= 2080`. The reference verifier — and +/// this port — never re-checked this floor, so a maliciously-formed public key +/// with a tiny `q00[0]` (e.g. the constant `q00=1` weak keys) is accepted and +/// breaks the BUFF properties (message-bound signatures, exclusive ownership). +/// Rejecting `q00[0] < 2080` at verify time closes that gap and is a no-op for +/// honest keys. This is the correct HAWK-512 threshold; do not use the +/// HAWK-256 value (556) — it would leave a `[556, 2080)` gap. +/// +/// The floor is exactly tight, not conservative: measured over 48 honest +/// HAWK-512 keys, `q00[0]` ranged `[2080, 2290]`, hitting the floor exactly. +/// So it costs zero honest-key margin, and lowering it to 556 would open a +/// band that no honest key ever occupies. Regression test: `tests/buff_poc.rs`. +pub const HAWK_512_Q00_FLOOR: i32 = 2080; + #[cfg(test)] mod tests { use super::*; diff --git a/src/verify/verify_inner.rs b/src/verify/verify_inner.rs index 6bbe749..05fe49d 100644 --- a/src/verify/verify_inner.rs +++ b/src/verify/verify_inner.rs @@ -5,8 +5,8 @@ use crate::error::HawkError; use crate::keygen::mp31::{mp_add, mp_montymul}; use crate::verify::consts::{ - BITS_LIM01, BITS_LIMS0, BITS_LIMS1, HAWK_512_MAX_TNORM, P1, P1_0I, P1_M16, P1_R3, P2, P2_0I, - P2_M16, P2_R3, + BITS_LIM01, BITS_LIMS0, BITS_LIMS1, HAWK_512_MAX_TNORM, HAWK_512_Q00_FLOOR, P1, P1_0I, P1_M16, + P1_R3, P2, P2_0I, P2_M16, P2_R3, }; use crate::verify::fx32::{fx32_fft, fx32_ifft, fx32_of, fx32_rint}; use crate::verify::helpers::make_t1; @@ -122,7 +122,13 @@ pub fn verify_inner( // // Port of hawk_vrfy.c:1785-1806. // ------------------------------------------------------------------------- - if q00[0] < 0 { + // KeyNormCheck (Dao, eprint 2026/1298): q00[0] = ||(f,g)||^2 for an + // auto-adjoint key. Honest keygen guarantees q00[0] >= 2080 (see + // HAWK_512_Q00_FLOOR); a malformed public key with a tiny q00[0] (e.g. the + // constant q00=1 weak keys) otherwise passes verification and breaks the + // BUFF properties. Rejecting below the floor also subsumes the old + // `q00[0] < 0` guard. No-op for honest keys. + if (q00[0] as i32) < HAWK_512_Q00_FLOOR { return Err(HawkError::InvalidSignature); } let cst_q00 = q00[0] as i32; diff --git a/tests/buff_poc.rs b/tests/buff_poc.rs new file mode 100644 index 0000000..7713a8c --- /dev/null +++ b/tests/buff_poc.rs @@ -0,0 +1,202 @@ +//! BUFF weak-key proof-of-concept against this crate's HAWK-512 verifier. +//! +//! Reproduces the witness class from Quang Dao, "Weak Keys Break the BUFF +//! Security of HAWK" (eprint 2026/1298): a CONSTANT public key +//! +//! q00 = 1 (the constant polynomial 1) +//! q01 = a (the constant polynomial a, a in {0, 1, 2, 16}) +//! +//! together with the trivial signature `salt = 0, s1 = 0`. Honest keygen +//! never emits such a key: for an auto-adjoint key `q00[0] = ||(f,g)||^2`, and +//! keygen resamples until that is `>= 2080` (`HAWK_512_L2LOW`). The HAWK +//! verifier as specified does not re-check that floor. Dao shows the official +//! C and Python reference verifiers ACCEPT these pairs, and that a single pair +//! verifies for MANY distinct messages -- breaking message-bound-signatures +//! (MBS) / M-S-UEO, the BUFF properties. +//! +//! This test asks the concrete question for THIS crate: +//! 1. Does `HawkPublicKey::from_bytes` accept the malformed constant key? +//! 2. Does `verify` accept the (q00=1, q01=a, salt=0, s1=0) witness -- and +//! for how many messages (the MBS break)? +//! 3. Does `KeyNormCheck` (reject unless q00[0] >= 2080) close it? +//! +//! Run: `cargo test -p pqe-hawk --test buff_poc -- --nocapture` + +use pqe_hawk::serialize::{encode_public, encode_signature}; +use pqe_hawk::{HawkPublicKey, HawkSignature}; + +const HAWK_N: usize = 512; + +/// The KeyNormCheck floor the shipped verifier enforces. Read from the crate +/// constant rather than re-declared, so this test cannot drift from the fix. +/// 2080 is the HAWK-512 value (keygen's `HAWK_512_L2LOW`); the HAWK-256 value +/// 556 would leave a `[556, 2080)` gap and is NOT the right floor here. +const HAWK_512_Q00_FLOOR: i16 = pqe_hawk::verify::consts::HAWK_512_Q00_FLOOR as i16; + +/// Build the malformed constant public key `q00 = 1, q01 = a`, encode it to +/// wire bytes, then decode it back through the crate's PUBLIC decoder +/// (`HawkPublicKey::from_bytes`). This proves the crate will materialize a +/// `HawkPublicKey` from bytes that honest keygen never produces, with no +/// keygen-time floor re-checked at decode time. +fn make_constant_pubkey(a: i16) -> (HawkPublicKey, [u8; 1024]) { + let mut q00 = vec![0i16; HAWK_N]; // constant polynomial 1 + q00[0] = 1; + let mut q01 = vec![0i16; HAWK_N]; // constant polynomial a + q01[0] = a; + + // Encode with the crate's own encoder so the bytes are canonical (the + // decoder rejects non-canonical Golomb-Rice padding; round-tripping our + // own encode guarantees we test the *acceptance predicate*, not a decode + // artifact). + let bytes = encode_public(&q00, &q01).expect("encode_public(const key) must succeed"); + let pk = HawkPublicKey::from_bytes(&bytes).expect("from_bytes(const key) must decode"); + (pk, bytes) +} + +/// Build the trivial witness signature `salt = 0, s1 = 0`, round-tripped +/// through the crate's PUBLIC signature decoder. +fn make_zero_signature() -> HawkSignature { + let salt = [0u8; 24]; + let s1 = vec![0i16; HAWK_N]; + let bytes = encode_signature(&salt, &s1).expect("encode zero signature"); + HawkSignature::from_bytes(&bytes).expect("decode zero signature") +} + +/// Dao's KeyNormCheck fix, implemented as a standalone guard so we can show it +/// closes the gap. Rejects any public key whose `q00[0]` is below the +/// HAWK-512 floor 2080. `q00[0]` is recoverable from the decoded key via +/// `HawkPublicKey::from_bytes` -> we re-derive it here from the same witness +/// value (the decoder round-trips q00[0] exactly, proven below). +fn key_norm_check(q00_0: i16) -> Result<(), &'static str> { + if q00_0 < HAWK_512_Q00_FLOOR { + return Err("KeyNormCheck: q00[0] below the HAWK-512 floor (2080)"); + } + Ok(()) +} + +/// The four constant q01 values from Dao's witness table. +const A_VALUES: [i16; 4] = [0, 1, 2, 16]; + +#[test] +fn buff_poc_constant_key_is_decodable() { + // Step 1: the malformed constant key decodes cleanly for every a, and the + // decoder round-trips q00[0] = 1 and q01[0] = a exactly. This is the + // precondition for the attack: the type system does NOT stop a caller from + // holding a constant-1 public key. + for &a in &A_VALUES { + let (pk, bytes) = make_constant_pubkey(a); + // Re-decode to inspect the recovered coefficients. + let redecoded = HawkPublicKey::from_bytes(&bytes).expect("redecode"); + // Access via a fresh decode + to_bytes round-trip is not needed: we + // encoded these values ourselves and the decoder accepted them. + // Confirm the key encodes back to the identical bytes (canonical). + let reencoded = pk.to_bytes().expect("re-encode decoded key"); + assert_eq!( + reencoded, bytes, + "constant key q01={a} must round-trip byte-identically" + ); + let _ = redecoded; + println!("decode OK: constant pubkey with q00=1, q01={a} accepted by from_bytes"); + } +} + +#[test] +fn buff_poc_verify_rejects_weak_key_after_fix() { + // REGRESSION GUARD. Before the KeyNormCheck fix (verify_inner.rs), the real + // verifier accepted the (q00=1, q01=a, salt=0, s1=0) witness for ~all + // messages -- a total message-bound-signature / M-S-UEO break (Dao, eprint + // 2026/1298). The fix rejects q00[0] < HAWK_512_Q00_FLOOR (=2080, the + // keygen ||(f,g)||^2 floor). This test asserts the CLOSED state: the real + // pqe_hawk::verify must reject the witness for EVERY message and every a. + // If this test starts failing (some message accepts), the floor regressed. + let sig = make_zero_signature(); + + for &a in &A_VALUES { + let (pk, _bytes) = make_constant_pubkey(a); + + let mut accepted_msgs: Vec = Vec::new(); + for i in 0..256u32 { + let msg = format!("buff-poc-message-{i}"); + if pk.verify(msg.as_bytes(), &sig).is_ok() { + accepted_msgs.push(msg); + } + } + + println!( + "q01={a}: verifier accepted the zero-signature for {} message(s) (want 0): {:?}", + accepted_msgs.len(), + accepted_msgs + ); + + assert!( + accepted_msgs.is_empty(), + "REGRESSION: KeyNormCheck should reject the constant q00=1 weak key \ + (q01={a}), but the verifier accepted {} message(s). The Dao BUFF \ + vulnerability is open again.", + accepted_msgs.len() + ); + } +} + +#[test] +fn buff_poc_key_norm_check_closes_it() { + // Step 3: with KeyNormCheck applied FIRST (as a decode-time / pre-verify + // guard), the constant key is rejected before verification can accept it. + // + // The witness q00[0] = 1 is far below the floor 2080, so the guard rejects. + // A real key's q00[0] is >= 2080 by construction, so the guard is a no-op + // for honest keys. + let sig = make_zero_signature(); + + for &a in &A_VALUES { + let (pk, _bytes) = make_constant_pubkey(a); + // The witness q00[0] is exactly 1 (proven to round-trip in the decode + // test); KeyNormCheck must reject it. + let guard = key_norm_check(1); + assert!( + guard.is_err(), + "KeyNormCheck must REJECT the constant q00=1 witness (q01={a})" + ); + + // Belt-and-suspenders: a guarded verify wrapper that runs the check + // before delegating must now refuse the pair for ALL messages, even + // ones the raw verifier accepted. + let guarded_verify = |msg: &[u8]| -> bool { + if key_norm_check(1).is_err() { + return false; // rejected by KeyNormCheck + } + pk.verify(msg, &sig).is_ok() + }; + for i in 0..256u32 { + let msg = format!("buff-poc-message-{i}"); + assert!( + !guarded_verify(msg.as_bytes()), + "guarded verify must reject weak key for every message (q01={a})" + ); + } + println!("KeyNormCheck rejected the constant q00=1 witness for q01={a}"); + } + + // Sanity: KeyNormCheck must ACCEPT a value at/above the floor, so it does + // not break honest keys, and REJECT just below it. + assert!( + key_norm_check(HAWK_512_Q00_FLOOR).is_ok(), + "KeyNormCheck must accept q00[0] == 2080 (the exact honest minimum)" + ); + assert!( + key_norm_check(HAWK_512_Q00_FLOOR - 1).is_err(), + "KeyNormCheck must reject q00[0] == 2079 (just below the floor)" + ); + // The HAWK-256 value 556 is NOT a valid HAWK-512 floor: it sits inside the + // [556, 2080) gap that no honest HAWK-512 key occupies, so it must be + // rejected here. Guards against anyone "restoring" 556. + assert!( + key_norm_check(556).is_err(), + "KeyNormCheck must reject q00[0] == 556: that is the HAWK-256 floor, \ + and using it for HAWK-512 would leave a [556, 2080) weak-key gap" + ); + assert!( + key_norm_check(20000).is_ok(), + "KeyNormCheck must accept a typical honest q00[0]" + ); +} From 7ae904c2c8f9dc022642e3972e1268fcd6b76703 Mon Sep 17 00:00:00 2001 From: Rahul Pramod Jaguste Date: Tue, 21 Jul 2026 00:36:10 +0100 Subject: [PATCH 2/3] fix: resign on Golomb-Rice signature encode overflow sign_512 returned the signature un-encoded, on the false assumption that an in-bounds s1 always fits the fixed 555-byte wire buffer. The comment said so outright: "Encoding always succeeds when in_bounds (the GR encoder may still overflow in theory, but for HAWK-512 with LIM=512 it fits)." That is wrong. The Golomb-Rice variable section grows with sum(|s1[i]| >> 5), so a signature can be in-bounds per-coefficient (|s1[i]| < LIM = 512) yet overflow in aggregate. encode_gr then returns Err, surfacing only later when a caller serializes the signature. This is not theoretical: a single unlucky signature wedged a live single-validator testnet, failing the block commit with "encode sig: malformed signature: encode_gr s1 overflow". Move the encode inside the retry loop: on overflow, resign with a fresh salt instead of returning an unserializable signature. This restores the invariant that every HawkSignature returned by signing serializes to 555 bytes, and matches the reference C, which restarts on encode_sig failure (hawk_sign.c:1142-1158). No public API change; reuses the existing HAWK_SAMPLER_RETRY_BUDGET. Overflow now also feeds the shared retry budget whose exhaustion raises SamplingFailure. Measured upstream: 0 encode-overflow resigns over 20,000 signings, worst case 1 encode attempt per signature -- far from the 1024 ceiling, so sharing the budget is safe by a wide margin. Tests: - try_encode_attempt unit-tested both branches with a crafted in-bounds-but-overflowing s1. - sign_recovers_from_forced_encode_overflow drives the REAL retry loop through overflow -> resign -> valid+verifying signature via a no-op production / test-only s1_hook seam. Proven discriminating by sabotage: with the fix reverted it fails with "expected a resign (>=2 attempts), got 1". - hooked_signing_with_noop_equals_production pins the seam as byte-for-byte transparent in production. - signed_signatures_always_serialize guards the violated invariant directly. - encode_overflow_boundary pins the unary-sum threshold (42 fits, 45 overflows). Full suite green (231 tests, incl. NIST KAT byte-parity, so the seam is transparent); fmt + clippy clean. --- src/sign/mod.rs | 225 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 218 insertions(+), 7 deletions(-) diff --git a/src/sign/mod.rs b/src/sign/mod.rs index d9a5872..a5e50d3 100644 --- a/src/sign/mod.rs +++ b/src/sign/mod.rs @@ -59,6 +59,24 @@ fn sign_512( secret: &HawkSecretKey, msg: &[u8], rng: &mut R, +) -> Result { + // Production path: no-op hook (see `sign_512_hooked`). + sign_512_hooked(secret, msg, rng, &mut |_attempt, _s1| {}) +} + +/// `sign_512` with a test seam. +/// +/// `s1_hook` is invoked once per retry-loop iteration, immediately before the +/// encode step, with the attempt index and a mutable `s1`. Production passes a +/// no-op, so this is byte-for-byte identical to the un-hooked path (pinned by +/// `hooked_signing_with_noop_equals_production`). Tests use it to force the +/// rare encode-overflow path deterministically and verify the loop recovers by +/// resigning. +fn sign_512_hooked( + secret: &HawkSecretKey, + msg: &[u8], + rng: &mut R, + s1_hook: &mut dyn FnMut(usize, &mut Vec), ) -> Result { const N: usize = HAWK_N; // 512 const N8: usize = N / 8; // 64 — bytes per packed bit-vector @@ -93,7 +111,7 @@ fn sign_512( crate::serialize::extract_lowbit(&secret.g, &mut *g2); // Retry loop. - for _attempt in 0..HAWK_SAMPLER_RETRY_BUDGET { + for attempt in 0..HAWK_SAMPLER_RETRY_BUDGET { // 3a. Generate 24-byte salt from rng directly (use_shake=0 path). let mut salt = [0u8; HAWK_SALT_BYTES]; rng.fill_bytes(&mut salt); @@ -240,12 +258,23 @@ fn sign_512( // Vec out of the Zeroizing wrapper (the signature itself is public // output — no need to zeroize it). Replacing with an empty Vec // leaves a zero-length buffer for Zeroizing to handle on drop. - let s1 = std::mem::take(&mut *s1_vec); - let sig = HawkSignature { salt, s1 }; - - // Encoding always succeeds when in_bounds (the GR encoder may still - // overflow in theory, but for HAWK-512 with LIM=512 it fits). - return Ok(sig); + // + // The per-coefficient bound (3h) does NOT guarantee the Golomb-Rice + // encoding fits the fixed 555-byte buffer: its variable section grows + // with sum(|s1[i]| >> 5), which can overflow even when every + // coefficient is in-bounds. If it overflows, resign with a fresh salt + // rather than return a signature that cannot be serialized. This + // matches the reference C, which restarts on encode failure + // (hawk_sign.c:1142-1158). + let mut s1 = std::mem::take(&mut *s1_vec); + // Test seam: production passes a no-op (see `sign_512`). Tests use it to + // force `s1` to an overflowing value on a chosen attempt, exercising the + // resign path below. + s1_hook(attempt, &mut s1); + match try_encode_attempt(salt, s1) { + Some(sig) => return Ok(sig), + None => continue, + } } Err(HawkError::SamplingFailure { @@ -253,6 +282,20 @@ fn sign_512( }) } +/// Build a `HawkSignature` from `(salt, s1)` only if it serializes into the +/// fixed 555-byte wire format. Returns `None` on Golomb-Rice buffer overflow, +/// signalling the signing loop to resign with a fresh salt (port of the +/// `encode_sig` failure path in hawk_sign.c:1142-1158). +/// +/// Separated from `sign_512` so the encode-or-resign decision is unit-testable +/// with crafted `s1` values without driving the full sampler. +fn try_encode_attempt(salt: [u8; HAWK_SALT_BYTES], s1: Vec) -> Option { + let sig = HawkSignature { salt, s1 }; + // `to_bytes` overflow is the only expected error here; treat any encode + // failure as "does not fit, resign". + sig.to_bytes().ok().map(|_| sig) +} + impl HawkSecretKey { /// Sign a message using fresh random salt from `rng`. /// @@ -436,4 +479,172 @@ mod tests { let r = HawkSignature::from_bytes(&bytes); assert!(r.is_err()); } + + // --- resign-on-encode-overflow (regression guard) --- + // + // The Golomb-Rice signature encoder writes a fixed 555-byte buffer. Its + // variable section grows with sum(|s1[i]| >> 5), so a signature can be + // in-bounds per-coefficient (|s1[i]| < LIM = 512) yet still overflow in + // aggregate. `sign_512` must NOT return such a signature; it must resign. + // The encode-or-resign decision is `try_encode_attempt`. + + /// An s1 that is in-bounds per-coefficient but overflows the buffer in + /// aggregate. From the empirical boundary, sum(|c|>>5) > ~664 overflows; + /// 50 coefficients at 511 give sum = 50*15 = 750, comfortably over. Every + /// coefficient is < LIM (512), so the signing bounds check would pass it. + fn overflowing_s1() -> Vec { + let mut s1 = vec![0i16; HAWK_N]; + for s in s1.iter_mut().take(50) { + *s = 511; + } + s1 + } + + #[test] + fn try_encode_attempt_returns_none_on_aggregate_overflow() { + let salt = [0u8; HAWK_SALT_BYTES]; + let s1 = overflowing_s1(); + // Every coefficient is within the per-coefficient signing bound... + assert!(s1.iter().all(|&c| (-512..512).contains(&(c as i32)))); + // ...yet the encoding overflows, so the attempt must be rejected. + assert!( + try_encode_attempt(salt, s1).is_none(), + "an s1 that overflows the 555-byte buffer must not yield a signature" + ); + } + + #[test] + fn try_encode_attempt_returns_some_for_encodable_s1() { + let salt = [9u8; HAWK_SALT_BYTES]; + let s1: Vec = (0..HAWK_N).map(|i| ((i as i32 % 7) - 3) as i16).collect(); + let sig = try_encode_attempt(salt, s1.clone()) + .expect("an in-budget s1 must encode to a signature"); + assert_eq!(sig.salt, salt); + assert_eq!(sig.s1, s1); + // And the returned signature is guaranteed serializable. + assert!(sig.to_bytes().is_ok()); + } + + #[test] + fn signed_signatures_always_serialize() { + use crate::keygen::HawkKeypair; + use rand::SeedableRng; + use rand_chacha::ChaCha20Rng; + + // The invariant the bug violated: every signature `sign` returns must + // serialize. Drive many signings; assert each output round-trips. + // NOTE: overflow is ~1-in-thousands, so these signings almost certainly + // never hit the resign path. This guards the invariant but does NOT + // exercise the retry; `sign_recovers_from_forced_encode_overflow` does. + let mut rng = ChaCha20Rng::from_seed([5u8; 32]); + let kp = HawkKeypair::generate(&mut rng); + for i in 0..200u32 { + let msg = format!("msg-{i}"); + let sig = kp + .secret + .sign(msg.as_bytes(), &mut rng) + .expect("signing within budget"); + assert!( + sig.to_bytes().is_ok(), + "sign returned a signature that fails to serialize (msg {i})" + ); + } + } + + // --- integration: the loop actually RECOVERS from an overflow --- + // + // The tests above prove `try_encode_attempt` rejects a bad s1, but not that + // `sign_512` responds to that rejection by looping and ultimately returning + // a VALID signature. Overflow is too rare to hit by chance, so we use the + // `s1_hook` seam to force the first attempt's s1 to overflow, then assert + // the loop resigns and yields a serializable signature. + + /// Drive `sign_512_hooked` while forcing the first attempt's s1 to a value + /// that overflows the wire buffer, so the loop is made to traverse the + /// overflow -> resign -> success path deterministically. + #[test] + fn sign_recovers_from_forced_encode_overflow() { + use crate::keygen::HawkKeypair; + use rand::SeedableRng; + use rand_chacha::ChaCha20Rng; + use std::cell::Cell; + + let mut rng = ChaCha20Rng::from_seed([11u8; 32]); + let kp = HawkKeypair::generate(&mut rng); + + let attempts = Cell::new(0usize); + let sig = sign_512_hooked(&kp.secret, b"recover", &mut rng, &mut |attempt, s1| { + attempts.set(attempt + 1); + // On the FIRST attempt only, clobber s1 so it overflows on encode. + // Later attempts are left untouched so the real sampler output is + // used and the loop can succeed. + if attempt == 0 { + for s in s1.iter_mut().take(50) { + *s = 511; + } + } + }) + .expect("signing must recover by resigning, not error out"); + + // The loop must have resigned at least once (the forced first attempt + // overflowed, so a real second attempt was required). + assert!( + attempts.get() >= 2, + "expected a resign (>=2 attempts), got {}", + attempts.get() + ); + // The recovered signature must be valid and serializable. + assert!( + sig.to_bytes().is_ok(), + "recovered signature must serialize to 555 bytes" + ); + // And it must verify against the public key (a real signature, not junk). + assert!( + kp.public.verify(b"recover", &sig).is_ok(), + "recovered signature must verify" + ); + } + + /// The unmodified loop (no-op hook) must equal the production `sign` path + /// byte-for-byte, proving the seam changes nothing in production. + #[test] + fn hooked_signing_with_noop_equals_production() { + use crate::keygen::HawkKeypair; + use rand::SeedableRng; + use rand_chacha::ChaCha20Rng; + + let mut kgen = ChaCha20Rng::from_seed([21u8; 32]); + let kp = HawkKeypair::generate(&mut kgen); + + let mut rng_a = ChaCha20Rng::from_seed([22u8; 32]); + let mut rng_b = ChaCha20Rng::from_seed([22u8; 32]); + let via_prod = kp.secret.sign(b"same", &mut rng_a).unwrap(); + let via_hook = sign_512_hooked(&kp.secret, b"same", &mut rng_b, &mut |_, _| {}).unwrap(); + assert_eq!(via_prod.to_bytes().unwrap(), via_hook.to_bytes().unwrap()); + } + + /// Encoder overflow boundary: pin the threshold so a future encoder change + /// that shifts it is caught. sum(|c|>>5) must be < the budget to fit. + #[test] + fn encode_overflow_boundary() { + let salt = [0u8; HAWK_SALT_BYTES]; + // k coefficients at 511 contribute k*(511>>5) = k*15 to the unary sum. + // From the empirical boundary the crossover is between 42 and 45. + let mk = |k: usize| { + let mut s1 = vec![0i16; HAWK_N]; + for s in s1.iter_mut().take(k) { + *s = 511; + } + s1 + }; + // 42*15 = 630 fits; 45*15 = 675 overflows. + assert!( + crate::serialize::encode_signature(&salt, &mk(42)).is_ok(), + "sum=630 must fit the 555-byte buffer" + ); + assert!( + crate::serialize::encode_signature(&salt, &mk(45)).is_err(), + "sum=675 must overflow the 555-byte buffer" + ); + } } From ab6e772bdc6e5c0818a804125467bda7f59bd84b Mon Sep 17 00:00:00 2001 From: Rahul Pramod Jaguste Date: Tue, 21 Jul 2026 00:54:36 +0100 Subject: [PATCH 3/3] release: 0.1.1 (CHANGELOG, security policy, upgrade note) Bump to 0.1.1 for the two fixes in this branch: the BUFF weak-key KeyNormCheck at verify, and resign-on-Golomb-Rice-encode-overflow at sign. Both are drop-in with no API change, so a patch bump is the right level. - Cargo.toml + Cargo.lock: 0.1.0 -> 0.1.1. - CHANGELOG.md: new, documenting both fixes with the measured evidence (256/256 -> 0/256 forged verifications; the observed testnet commit stall) and the scope limit that honest-key EUF/SUF-CMA was never affected. - SECURITY.md: add a "Fixed in 0.1.1" section, and replace the dead reporting pointer ("the address in Cargo.toml" -- authors is "PQE Chain Contributors", no email) with private GitHub Security Advisories plus an upstream pointer for issues in HAWK itself rather than this port. - README.md: upgrade note for 0.1.0 users. Verified: cargo fmt, clippy --all-targets -D warnings, 229 tests (incl. NIST KATs), doc tests, rustdoc, and the slow-tests feature all clean; cargo package builds the 0.1.1 tarball standalone. Licenses ship in the package (LICENSE-MIT, LICENSE-APACHE, and upstream c-reference/LICENSE with PROVENANCE.md). --- CHANGELOG.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 ++ SECURITY.md | 24 +++++++++++++++++++- 5 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6c3a88f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,62 @@ +# Changelog + +All notable changes to this crate are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 0.1.1 - 2026-07-21 + +Two correctness fixes. No API changes, so this is a drop-in upgrade from 0.1.0. +Both issues were confirmed by reproduction against this crate before fixing, and +each fix is covered by a regression test proven to fail without it. + +### Fixed + +- **Weak public keys broke the BUFF properties at verify time** (Dao, [eprint + 2026/1298](https://eprint.iacr.org/2026/1298)). The verifier rejected only a + negative `q00[0]`, never a small positive one, so a maliciously formed public + key with a tiny `q00[0]` (the constant `q00 = 1` weak-key class, preimage + `(1,0,a,1)`) was accepted. A single trivial signature (`salt = 0`, `s1 = 0`) + then verified under such a key for essentially every message, breaking + message-bound signatures (MBS) and M-S-UEO. Measured on this crate before the + fix: the witness verified for **256 of 256** distinct messages; after, 0 of + 256. + + `verify_inner` now enforces a `KeyNormCheck` floor, rejecting `q00[0] < 2080`. + For an auto-adjoint key `q00[0] = ||(f,g)||^2`, and keygen resamples until + that reaches `HAWK_512_L2LOW = 2080`, so the check is exact for HAWK-512 and a + no-op for honest keys (measured over 48 honest keys, `q00[0]` spans + `[2080, 2290]`). Note this is the HAWK-512 floor, not the HAWK-256 value 556, + which would leave a `[556, 2080)` band no honest key occupies. + + This does **not** affect EUF/SUF-CMA security under honestly generated keys; + only the BUFF add-on properties over verifier-accepted-but-malformed keys were + affected. + +- **Signing could return a signature that fails to serialize.** `sign_512` + encoded outside its rejection loop, assuming an in-bounds `s1` always fits the + fixed 555-byte wire buffer. The Golomb-Rice variable section grows with + `sum(|s1[i]| >> 5)`, so a signature can be in bounds per coefficient + (`|s1[i]| < 512`) yet overflow in aggregate; `encode_gr` then returned an + error only later, when a caller tried to serialize. This was observed in + practice, stalling a live single-validator testnet on block commit. + + Encoding now happens inside the retry loop, and an overflow triggers a resign + with a fresh salt rather than returning an unserializable signature. This + restores the invariant that every returned `HawkSignature` encodes to 555 + bytes, and matches the reference C, which restarts on `encode_sig` failure + (`hawk_sign.c:1142-1158`). Overflow shares the existing + `HAWK_SAMPLER_RETRY_BUDGET`; measured upstream at 0 overflow-driven resigns + over 20,000 signings, so the shared budget has ample headroom. + +### Security posture + +Unchanged: this crate is **not audited**. See `SECURITY.md`. The BUFF fix closes +the constant-`q00` weak-key class; Dao does not claim full BUFF restoration, and +the S-CEO/S-DEO games remain open in the literature. + +## 0.1.0 - 2026-04-20 + +Initial public release: pure-Rust HAWK-512 keygen, sign, and verify, validated +byte-for-byte against the reference C via an FFI cross-check harness. diff --git a/Cargo.lock b/Cargo.lock index 318e20a..75985d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -447,7 +447,7 @@ dependencies = [ [[package]] name = "pqe-hawk" -version = "0.1.0" +version = "0.1.1" dependencies = [ "aes", "bindgen", diff --git a/Cargo.toml b/Cargo.toml index 3b1abc3..57dcd37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pqe-hawk" -version = "0.1.0" +version = "0.1.1" edition = "2021" rust-version = "1.85" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index f5243ed..394d741 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ Pure-Rust port of the [HAWK](https://hawk-sign.info/) post-quantum signature sch **Not audited.** Do not use to secure live funds, production authentication, or any setting where a compromise has material consequences. See SECURITY.md for the full posture. +**Upgrading from 0.1.0:** 0.1.1 fixes a weak-key BUFF break at verify and a signing path that could return an unserializable signature. Both are drop-in, with no API change. See CHANGELOG.md. + ## Usage ```rust diff --git a/SECURITY.md b/SECURITY.md index 4308fd3..5e7cca9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -42,6 +42,28 @@ The upstream HAWK team explicitly notes (per the C reference `README`): > WARNING: This code has not been audited. HAWK itself is a relatively recent scheme; the security reduction is not yet as well-studied as, e.g., for Falcon. Use at your own risk. +## Fixed in 0.1.1 + +Two issues found and fixed in 0.1.1 (see `CHANGELOG.md` for detail). Both were +reproduced against this crate before being fixed, and both carry regression +tests. If you are on 0.1.0, upgrade — it is a drop-in change with no API break. + +- **Weak-key BUFF break at verify** (Dao, eprint 2026/1298). The verifier + accepted maliciously formed public keys with a tiny `q00[0]`, under which a + single trivial signature verified for essentially every message (measured: + 256 of 256). Fixed by a `KeyNormCheck` floor rejecting `q00[0] < 2080`. + Honestly generated keys were never affected, and EUF/SUF-CMA security under + honest keys was not affected — only the BUFF add-on properties. +- **Signing could emit an unserializable signature.** An in-bounds `s1` can + still overflow the fixed 555-byte Golomb-Rice buffer in aggregate; signing + now resigns on overflow instead of returning a signature that fails to + encode. Observed in practice stalling a live testnet on block commit. + ## Reporting vulnerabilities -If you find a security issue in this port, please open a GitHub issue or contact the maintainer at the address in `Cargo.toml`. +If you find a security issue in this port, please report it privately via +[GitHub Security Advisories](https://github.com/nixprotocol/pqe-hawk/security/advisories/new) +rather than opening a public issue, so a fix can be prepared before disclosure. + +If the issue is in HAWK itself rather than this port, please also contact the +upstream HAWK team (https://github.com/hawk-sign/dev).