Skip to content

Commit 09903f8

Browse files
committed
review: cover derive_key with the official vectors, fix a backwards timing doc
Addresses the CodeRabbit pass on #268. Taking the findings that hold and saying which do not, rather than applying all nine. FIXED — correctness: - **`derive_key` had zero vector coverage.** The parser read `input_len`, `hash`, and `keyed_hash` and skipped past the `derive_key` field already present in the file. It is the one public mode built as TWO passes (DERIVE_KEY_CONTEXT over the context string, whose output keys a DERIVE_KEY_MATERIAL pass over the material), so a swapped or collapsed transcription would have been invisible while hash/keyed_hash stayed green. Now asserted for all 35 cases at both the 32-byte and extended XOF lengths. It passed on the first run — the transcription was right; it just wasn't proven. Control-tested: swapping the two flags fails ONLY the new assertion. - **`Hash::eq`'s documentation described the opposite of the code.** The doc-comment said `read_volatile` (the code uses `core::hint::black_box`) and the not-transcribed list flatly asserted "`PartialEq` here is a normal short-circuiting byte-array compare" — stale since the constant-time fold landed. On a MAC-comparison path a backwards timing claim is worse than no claim: an auditor would have believed this file leaks match-prefix length when it does not. Also now states that `black_box` is a best-effort barrier, not a guarantee; the data-independent loop is the actual property. FIXED — hazards: - `run.sh` copied the bench to a fixed `examples/blake3_ab.rs` and had the EXIT trap `rm` it unconditionally: a pre-existing file there would be clobbered and then deleted. Now refuses to overwrite, and only removes the directory it created. - `shiftor_rot_u64x8` is `pub` and took arbitrary `u32` into `a >> n` — a debug panic and a release wrapping-shift for `n >= 64`. Normalized at the PUBLIC wrapper and deliberately NOT in the measured inner body, which would have changed the codegen the oracle exists to observe. - Rotate test extended past 64 (65/127/128/191) against std's `u64::rotate_*`, pinning the mod-64 contract rather than only the width case. - Test-local context const renamed `VECTORS_CONTEXT`; as `DERIVE_KEY_CONTEXT` it shadowed the module-scope u32 flag of that name. - Redundant `.into()` in `derive_key` (`clippy::useless_conversion`). - The `pub mod blake3` incident history was an OUTER doc comment, so rustdoc would publish it as module documentation. Now a plain `//` comment. Three more stale doc claims, all the same class already corrected elsewhere in this PR — an assertion outliving the change that falsified it: - EPIPHANIES said "an error there means no cycle and the work is unblocked" one paragraph after explaining the error is ambiguous. Read literally it marks blocked work unblocked on the strength of a typo. Now a four-step procedure with the error explicitly inconclusive. - crypto-lane-status prescribed an explicit shift-or for AVX2/NEON/wasm, contradicting the measurement directly above it (LLVM folds the two forms byte-identically) and misdescribing what shipped. - blake3-in-tree-measured still credited the swap with removing the C build, already removed by `features = ["pure"]` in #264. NOT taken, with reasons: - Doctest examples on every public API. Worth doing, but it is a separate change across a surface this PR is not otherwise touching, and the vectors already smoke-test the public path harder than an example would. - Caching the compressed block in `OutputReader`. A real inefficiency for small repeated `fill` calls, but unmeasured, and the module is explicitly not on a hot path pending the swap ruling. - Deduplicating the rotate body across backends. Every other lane type in this crate is duplicated per backend by design; collapsing one method would make it the exception, not the rule. - Secret-scanner allowlist for the vectors file. Correct that the hex is published test data, not credentials, but no scanner config lives in this repo to add it to. Full lib suite green: 2200 passed, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfbkUCBbtZhy3yjSfCDHp
1 parent 6346c85 commit 09903f8

8 files changed

Lines changed: 153 additions & 35 deletions

File tree

‎.claude/board/EPIPHANIES.md‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,23 @@ caught it on #268. Treating `[[patch.unused]]` as a cycle report teaches the
6565
next reader to misdiagnose an ordinary stale patch — and undermines the very
6666
check this entry prescribes.
6767

68-
The check stands, on its own evidence: run
69-
`cargo tree -p <our-root> -i <X>` **before** planning the work. An error
70-
there means no cycle and the work is unblocked; a hit means the edge must be
71-
cut first.
68+
The check stands, but ONLY in its positive form — and the wording here was
69+
itself an instance of the bug it warns about, corrected on #268:
7270

7371
Consequence: **before planning any "make X consume our crate" work, run
74-
`cargo tree -p <our-root> -i <X>`.** An error there means no cycle and the
75-
work is unblocked; a hit means the edge must be cut first.
72+
`cargo tree -p <our-root> -i <X>`.**
73+
74+
1. First confirm `<X>` is a real package in the selected graph (a typo, or a
75+
package absent from that graph, produces the byte-identical error).
76+
2. A tree that resolves and shows the root package = the edge must be cut
77+
first.
78+
3. A tree that resolves and does NOT show it = unblocked.
79+
4. **A package-ID error is inconclusive — never "no cycle".**
80+
81+
An earlier draft of this very entry said "an error there means no cycle and
82+
the work is unblocked", one paragraph after explaining that the error is
83+
ambiguous. Read literally it would mark blocked work as unblocked on the
84+
strength of a typo.
7685

7786

7887
## 2026-07-29 — BLAKE3 needs a method surface, not intrinsics (measured)

‎.claude/knowledge/blake3-ab-bench/run.sh‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,23 @@
1010
set -eu
1111
HERE="$(cd "$(dirname "$0")" && pwd)"
1212
REPO="$(cd "$HERE/../../.." && pwd)"
13-
mkdir -p "$REPO/examples"
14-
trap 'rm -f "$REPO/examples/blake3_ab.rs"; rmdir "$REPO/examples" 2>/dev/null || true' EXIT
15-
cp "$HERE/blake3_ab.rs" "$REPO/examples/blake3_ab.rs"
13+
# The bench needs its source under $REPO/examples/ for cargo to see it, and
14+
# removes it afterwards. Both halves must refuse to touch anything they did not
15+
# create: a fixed destination plus an unconditional `rm` in the EXIT trap would
16+
# clobber a pre-existing examples/blake3_ab.rs and then delete it.
17+
EXAMPLE_DIR="$REPO/examples"
18+
EXAMPLE="$EXAMPLE_DIR/blake3_ab.rs"
19+
created_example_dir=0
20+
if [ -e "$EXAMPLE" ] || [ -L "$EXAMPLE" ]; then
21+
echo "refusing to overwrite $EXAMPLE" >&2
22+
exit 1
23+
fi
24+
if [ ! -d "$EXAMPLE_DIR" ]; then
25+
mkdir "$EXAMPLE_DIR"
26+
created_example_dir=1
27+
fi
28+
# Only remove the directory if this script created it.
29+
trap 'rm -f "$EXAMPLE"; [ "$created_example_dir" -eq 0 ] || rmdir "$EXAMPLE_DIR" 2>/dev/null || true' EXIT
30+
cp "$HERE/blake3_ab.rs" "$EXAMPLE"
1631
cd "$REPO"
1732
cargo run --release --quiet --example blake3_ab

‎.claude/knowledge/blake3-in-tree-measured.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,15 @@ So the honest shape is:
114114

115115
## What this means for the swap
116116

117-
**The cycle-cut is available now and is correct.** It removes a cargo cycle,
118-
the C build question, and 2,910 lines of second-surface `core::arch`.
117+
**The cycle-cut is available now and is correct.** It removes a cargo cycle
118+
and 2,910 lines of second-surface `core::arch`.
119+
120+
It does **not** remove a C build — `Cargo.toml:213` already sets
121+
`default-features = false, features = ["pure"]`, which removed all C/ASM
122+
compilation back in #264. An earlier revision of this line credited the swap
123+
with that too; overstating the benefit matters here specifically, because
124+
what it is being weighed against is transcribing a cryptographic
125+
implementation.
119126

120127
**It is not free**, and the previous framing ("removes things, needs no
121128
benchmark to justify") was true about what it *removes* and silent about what

‎.claude/knowledge/crypto-lane-status.md‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,21 @@ failing row, since a probe the compiler cannot distinguish from its own
116116
control measures nothing.
117117

118118
**So the u64 ARX lane is the crate's first intrinsic override that meets the
119-
entry criterion** (a probe proving the generic form fails). AVX-512:
120-
`_mm512_rorv_epi64` / `VPROLVQ`, one instruction. AVX2 / NEON / wasm: write
121-
the `vpsllq`/`vpsrlq`-shaped shift-or explicitly, since LLVM will not.
119+
entry criterion** (a probe proving the generic form fails).
120+
121+
What SHIPPED, per backend (#268), which is not uniform:
122+
123+
- **AVX-512:** `_mm512_rolv_epi64` / `_mm512_rorv_epi64` — `VPROLVQ`/`VPRORVQ`,
124+
one instruction. This is the earned override.
125+
- **AVX2 / scalar / nightly:** per-lane loops. NEON and wasm re-export the
126+
scalar `U64x8` and are covered by that arm.
127+
128+
An earlier draft of this paragraph instructed AVX2/NEON/wasm to "write the
129+
`vpsllq`/`vpsrlq`-shaped shift-or explicitly, since LLVM will not." That
130+
prescription contradicted the fourth confirmation directly above it — LLVM
131+
folded the explicit shift-or into the rotate form, byte-identically, so
132+
writing it out is not known to change anything. **It is an unmeasured future
133+
experiment, not the prescribed implementation**, and it is not what shipped.
122134

123135
Contrast with the u32 lane, where hand-writing intrinsics *lost* to the
124136
optimizer. Same crate, same week, opposite answers — which is the argument

‎.claude/knowledge/simd-codegen-oracle/probes.rs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,8 +520,22 @@ fn shiftor_rotr_u64x8(v: U64x8, n: u32) -> U64x8 {
520520
}
521521

522522
/// Explicit shift-or u64 rotate, runtime-variable amount, 8 lanes.
523+
///
524+
/// Normalizes `n` to `0..64` and short-circuits zero, so this public entry
525+
/// point agrees with `u64::rotate_right` at **every** `u32` input rather than
526+
/// only on `1..=63`. Without it, `n >= 64` reaches `a >> n` — a panic in
527+
/// debug, a wrapping shift in release, and a rotate in neither.
528+
///
529+
/// The normalization lives HERE and deliberately NOT in `shiftor_rotr_u64x8`:
530+
/// that inner body is the thing being measured, and adding a branch plus a
531+
/// modulo inside it would change the very codegen the oracle exists to
532+
/// observe. Guard at the boundary; measure the bare form.
523533
#[inline(never)]
524534
pub fn shiftor_rot_u64x8(v: U64x8, n: u32) -> U64x8 {
535+
let n = n % 64;
536+
if n == 0 {
537+
return v;
538+
}
525539
shiftor_rotr_u64x8(v, n)
526540
}
527541

‎src/hpc/blake3.rs‎

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -375,8 +375,14 @@ impl PartialEq for Hash {
375375
/// `==` leaks match-prefix length through timing. Upstream reaches for
376376
/// the `constant_time_eq` crate; this crate takes no new dependencies, so
377377
/// the fold is written here: accumulate the XOR of every byte pair and
378-
/// test once at the end, with `read_volatile` on the accumulator to stop
379-
/// the optimizer reintroducing an early exit.
378+
/// test once at the end, with `core::hint::black_box` on the accumulator
379+
/// to stop the optimizer reintroducing an early exit.
380+
///
381+
/// `black_box` is a **best-effort optimizer barrier, not a guarantee** —
382+
/// its documentation is explicit that it provides no formal contract. The
383+
/// data-independent loop above is the real property; the barrier only
384+
/// discourages LLVM from undoing it. A caller needing an audited
385+
/// guarantee should use a dedicated constant-time crate.
380386
///
381387
/// No call site in this crate currently compares two `Hash` values —
382388
/// `seal.rs` compares the truncated `MerkleRoot` instead — so this is a
@@ -387,8 +393,8 @@ impl PartialEq for Hash {
387393
for i in 0..OUT_LEN {
388394
diff |= self.0[i] ^ other.0[i];
389395
}
390-
// SAFETY-free volatile read of a local: prevents the compiler from
391-
// proving an early return is equivalent.
396+
// Optimizer barrier (best-effort): discourages LLVM from proving an
397+
// early return equivalent and reintroducing the short circuit.
392398
core::hint::black_box(diff) == 0
393399
}
394400
}
@@ -608,7 +614,7 @@ pub fn keyed_hash(key: &[u8; KEY_LEN], input: &[u8]) -> Hash {
608614
pub fn derive_key(context: &str, key_material: &[u8]) -> [u8; OUT_LEN] {
609615
let mut hasher = Hasher::new_derive_key(context);
610616
hasher.update(key_material);
611-
(*hasher.finalize().as_bytes()).into()
617+
*hasher.finalize().as_bytes()
612618
}
613619

614620
// =======================================================================
@@ -624,13 +630,16 @@ pub fn derive_key(context: &str, key_material: &[u8]) -> [u8; OUT_LEN] {
624630
// - `update_rayon` and anything behind the `rayon` feature.
625631
// - `zeroize` support (feature-gated in the original; secrets are not a
626632
// concern for ndarray's usage of this module).
627-
// - Constant-time equality (`constant_time_eq`): the original `Hash::eq` is
628-
// constant-time as a defense-in-depth measure for MAC comparisons. Adding
629-
// that would mean hand-rolling a constant-time byte compare with no
630-
// external crate; out of scope for what ndarray needs (a plain fingerprint
631-
// equality check), so `PartialEq` here is a normal short-circuiting
632-
// byte-array compare. Flagged explicitly since it is a behavioral
633-
// difference from upstream `blake3::Hash`, not just a missing convenience.
633+
// - The `constant_time_eq` CRATE — the dependency, NOT the behaviour. The
634+
// constant-time compare itself IS implemented: see `impl PartialEq for
635+
// Hash`, which XOR-folds all 32 bytes and tests once, so `Hash::eq` keeps
636+
// upstream's timing property without adding the dependency.
637+
//
638+
// An earlier revision of this list claimed the opposite — "`PartialEq` here
639+
// is a normal short-circuiting byte-array compare" — left stale when the
640+
// fold landed. Documenting a timing property backwards is worse than
641+
// omitting it: someone auditing a MAC comparison would have believed this
642+
// file leaks match-prefix length when it does not.
634643
// - Hex encoding/decoding (`to_hex`/`from_hex`) and `Display`/`FromStr`: not
635644
// in the required API list.
636645
// =======================================================================
@@ -648,8 +657,17 @@ mod tests {
648657
input_len: usize,
649658
hash_hex: String,
650659
keyed_hash_hex: String,
660+
derive_key_hex: String,
651661
}
652662

663+
/// The context string the official vectors' `derive_key` outputs were
664+
/// generated with (the `context_string` field of `test_vectors.json`).
665+
///
666+
/// Deliberately NOT named `DERIVE_KEY_CONTEXT` — that is a `u32` domain
667+
/// flag at module scope, and reusing the name here would shadow it inside
668+
/// this module.
669+
const VECTORS_CONTEXT: &str = "BLAKE3 2019-12-27 16:29:52 test vectors context";
670+
653671
/// Hand-rolled extraction of the fields we need from the official BLAKE3
654672
/// test_vectors.json, without pulling in serde. The file's `cases` array
655673
/// is a flat sequence of objects each with exactly the fields
@@ -683,10 +701,19 @@ mod tests {
683701
let keyed_hash_hex = extract_quoted(rest);
684702
rest = &rest[keyed_hash_hex.len() + 2..];
685703

704+
let dk_idx = rest
705+
.find("\"derive_key\":")
706+
.expect("missing derive_key field")
707+
+ "\"derive_key\":".len();
708+
rest = &rest[dk_idx..];
709+
let derive_key_hex = extract_quoted(rest);
710+
rest = &rest[derive_key_hex.len() + 2..];
711+
686712
cases.push(Case {
687713
input_len,
688714
hash_hex,
689715
keyed_hash_hex,
716+
derive_key_hex,
690717
});
691718
}
692719
cases
@@ -717,7 +744,7 @@ mod tests {
717744
}
718745

719746
#[test]
720-
fn official_test_vectors_hash_and_keyed_hash() {
747+
fn official_test_vectors_all_three_modes() {
721748
let cases = parse_cases(VECTORS_JSON);
722749
assert!(!cases.is_empty(), "no cases parsed");
723750
let mut checked = 0usize;
@@ -763,6 +790,31 @@ mod tests {
763790
case.input_len
764791
);
765792

793+
// --- derive_key, 32-byte default output ---
794+
//
795+
// The third public mode, and the one whose flags are easiest to
796+
// get wrong while the other two still pass: it is a TWO-pass
797+
// construction (DERIVE_KEY_CONTEXT over the context string,
798+
// whose output becomes the key words for a DERIVE_KEY_MATERIAL
799+
// pass over the material). A single-pass transcription, or one
800+
// that swapped the two flags, would be invisible to every
801+
// assertion above.
802+
let expected_dk = hex_decode(&case.derive_key_hex);
803+
let got_dk = derive_key(VECTORS_CONTEXT, &input);
804+
assert_eq!(got_dk[..], expected_dk[..32], "derive_key() mismatch at input_len={}", case.input_len);
805+
806+
// --- derive_key, extended output via finalize_xof ---
807+
let mut dhasher = Hasher::new_derive_key(VECTORS_CONTEXT);
808+
dhasher.update(&input);
809+
let mut dxof = dhasher.finalize_xof();
810+
let mut dextended = std::vec![0u8; expected_dk.len()];
811+
dxof.fill(&mut dextended);
812+
assert_eq!(
813+
dextended, expected_dk,
814+
"derive_key finalize_xof extended output mismatch at input_len={}",
815+
case.input_len
816+
);
817+
766818
checked += 1;
767819
}
768820
// Sanity: make sure we actually walked the whole official vector file.

‎src/hpc/mod.rs‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,17 @@ pub mod fingerprint;
5454
#[allow(missing_docs)]
5555
pub mod plane;
5656

57-
/// In-tree BLAKE3 — see `.claude/knowledge/blake3-in-tree-measured.md`.
58-
///
59-
/// Fully documented, so deliberately NOT under the `#[allow(missing_docs)]`
60-
/// that guards `plane`. An earlier revision declared it on the line directly
61-
/// after that attribute, which silently re-targeted the attribute onto this
62-
/// module and left `plane` unguarded — seven CI jobs failed on `missing_docs`
63-
/// errors in a file the change never touched.
57+
// In-tree BLAKE3 — see `.claude/knowledge/blake3-in-tree-measured.md`.
58+
//
59+
// A plain `//` comment, not `///`: this is repo incident history, and an outer
60+
// doc comment here would be concatenated into the published rustdoc for the
61+
// module.
62+
//
63+
// The module is fully documented, so it is deliberately NOT under the
64+
// `#[allow(missing_docs)]` that guards `plane`. An earlier revision declared
65+
// it on the line directly after that attribute, which silently re-targeted the
66+
// attribute onto this module and left `plane` unguarded — seven CI jobs failed
67+
// on `missing_docs` errors in a file the change never touched.
6468
pub mod blake3;
6569
#[allow(missing_docs)]
6670
pub mod seal;

‎src/simd.rs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -768,7 +768,12 @@ mod tests {
768768

769769
// BLAKE2b uses 32/24/16/63; 0 and 63 are the edges, and 64 must wrap
770770
// to the identity rather than shifting by the full width (UB on u64).
771-
for n in [0u32, 1, 7, 16, 24, 31, 32, 63, 64] {
771+
// 65/127/128/191 pin the documented mod-64 contract: an implementation
772+
// that only special-cased the width would pass at 64 and fail here.
773+
// The per-lane oracle is std's own `u64::rotate_left`, which is
774+
// specified to take its count mod 64, so this asserts agreement with
775+
// the language rather than with our own restatement of the rule.
776+
for n in [0u32, 1, 7, 16, 24, 31, 32, 63, 64, 65, 127, 128, 191] {
772777
let l = a.rotate_left(n).to_array();
773778
let r = a.rotate_right(n).to_array();
774779
for i in 0..8 {

0 commit comments

Comments
 (0)