Skip to content

Commit 02e3f54

Browse files
avrabeclaude
andauthored
fix: audit formal-verif honesty — relabel admits, run bazel test (#99)
* fix: audit formal-verif honesty + CI gate Closes 5 findings from the 2026-04-30 audit. Does NOT discharge any proof obligation; relabels existing admits as specifications and tightens the CI gate so the next dishonesty is caught. C-1 — annotate Verus assume(false) sites as SPECIFICATION ONLY C-2 — fix Lean status table to match sorry'd proof bodies C-3 — add verification/rocq/README.md stating it's an unrealised stub C-7 — Bazel CI now runs bazel test //...; remove continue-on-error from formal-verif jobs that pass M-9 — add paths filter to rust.yml so docs-only PRs skip cargo/bazel Fixes: C-1, C-2, C-3, C-7, M-9 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: amend audit C-7 — partial closure with explicit WIP labels The original PR-D classified Verus + Kani matrix jobs as "passing" and removed continue-on-error: true. CI showed they were not actually passing — the previous greens were masking-as-success. Re-add the mask on a per-job basis with explicit WIP comments tying each masked job to its blocking audit finding (C-1 for Verus admits, C-7 for the new bazel-test step). Rocq remains masked as before (audit C-3). Net effect for audit C-7: partial closure. The mask is now per-job and documented; ungating happens as proofs are discharged. Honesty is in the labelling, not yet in the gate strength. Trace: skip Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 786eee8 commit 02e3f54

6 files changed

Lines changed: 201 additions & 22 deletions

File tree

.github/workflows/formal-verification.yml

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,20 @@ jobs:
3434
disk-cache: ${{ github.workflow }}
3535
repository-cache: true
3636

37+
# Audit 2026-04-30 finding C-7 (partial): the previous classification
38+
# of "passes" assumed continue-on-error masking made these jobs green.
39+
# Lifting the mask reveals real failures tied to audit C-1 — Verus
40+
# `theorem_*` functions in src/lib/src/verus_proofs/ end in
41+
# `assume(false)` and the proof obligations are not yet discharged.
42+
# Mask retained until those obligations are mechanised; the job stays
43+
# visible (no skip) so progress is observable.
3744
- name: Verify Merkle proofs (CV-20, CV-21)
3845
run: bazel build //src/lib/src/verus_proofs:wsc_merkle_proofs
39-
continue-on-error: true
46+
continue-on-error: true # WIP — see audit C-1
4047

4148
- name: Run Verus tests
4249
run: bazel test //src/lib/src/verus_proofs:wsc_merkle_verify
43-
continue-on-error: true
50+
continue-on-error: true # WIP — see audit C-1
4451

4552
kani-proofs:
4653
name: Kani ${{ matrix.module }}
@@ -72,12 +79,18 @@ jobs:
7279
cargo install --locked kani-verifier
7380
cargo kani setup
7481
82+
# Audit 2026-04-30 finding C-7 (partial): on inspection, several
83+
# matrix jobs (format, merkle, wasm_module) fail when continue-on-error
84+
# is removed. The harnesses cover specific bounded properties and not
85+
# all are passing today. Mask retained per-job until the failing
86+
# harnesses are fixed; varint and dsse pass cleanly today and could be
87+
# ungated in a follow-up.
7588
- name: Run Kani ${{ matrix.module }} proofs
7689
env:
7790
HARNESS: ${{ matrix.harness }}
7891
run: cargo kani -p wsc --default-unwind 4 --output-format terse --harness "$HARNESS"
7992
timeout-minutes: 60
80-
continue-on-error: true
93+
continue-on-error: true # WIP — see audit C-7 partial closure
8194

8295
rocq-proofs:
8396
name: Rocq/coq-of-rust Translation
@@ -102,4 +115,7 @@ jobs:
102115
timeout-minutes: 30
103116
# coq-of-rust needs Rust nightly with LLVM internals — Nix provides
104117
# this locally but CI may need LIBRARY_PATH configuration.
118+
# WIP — see audit C-3: verification/rocq/ is a translation-pipeline
119+
# stub, not a real Rocq proof. continue-on-error stays until a `.v`
120+
# file is wired in.
105121
continue-on-error: true

.github/workflows/rust.yml

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,22 @@ name: CI
22

33
on:
44
push:
5-
branches: [ main ]
5+
branches: [main]
6+
paths:
7+
- 'src/**'
8+
- 'Cargo.toml'
9+
- 'Cargo.lock'
10+
- 'BUILD.bazel'
11+
- 'MODULE.bazel'
12+
- '.github/workflows/rust.yml'
613
pull_request:
7-
branches: [ main ]
14+
paths:
15+
- 'src/**'
16+
- 'Cargo.toml'
17+
- 'Cargo.lock'
18+
- 'BUILD.bazel'
19+
- 'MODULE.bazel'
20+
- '.github/workflows/rust.yml'
821

922
env:
1023
CARGO_TERM_COLOR: always
@@ -76,6 +89,17 @@ jobs:
7689
if: matrix.build-system == 'bazel'
7790
run: bazel build //src/cli:wasmsign_cli
7891

92+
# Audit 2026-04-30 finding C-7 (partial): Bazel CI now ATTEMPTS to
93+
# run tests, not just validate that targets compile. continue-on-error
94+
# is retained because (a) macOS Bazel toolchain has unrelated issues
95+
# observed in this batch and (b) `bazel test //...` exposes test
96+
# targets that may be platform-specific. Once the macOS bazel-test
97+
# baseline is green for one full week, lift this mask.
98+
- name: Test (Bazel)
99+
if: matrix.build-system == 'bazel'
100+
run: bazel test --build_tests_only --test_output=errors //src/...
101+
continue-on-error: true # WIP — see audit C-7 partial closure
102+
79103
- name: Validate Build Outputs (Bazel)
80104
if: matrix.build-system == 'bazel'
81105
run: |

lean/Ed25519.lean

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,24 @@
1212
1313
## Status
1414
15-
- `scalarMul_mul_order`: proven (via scalarMul_zero_point axiom)
16-
- `verification_equation_sound`: proven (full mechanized proof)
17-
- `verification_equation_complete`: needs prime-order argument (sorry)
18-
- `cofactored_verification_sound`: proven (follows from soundness)
19-
- `basepoint_prime_order`: needs Mathlib OrderOf (sorry)
15+
Honesty policy: every claim below states *exactly* how much of the
16+
proof body is mechanically checked. A `sorry` is treated as an open
17+
obligation and the table is required to say so. See
18+
`audit/2026-04-30/findings.md` C-2 for the audit context.
19+
20+
- `scalarMul_mul_order`: PROVEN (depends on the `scalarMul_zero_point`
21+
axiom declared in this file).
22+
- `verification_equation_sound`: PROVEN — full mechanized proof, no
23+
`sorry` in the body.
24+
- `verification_equation_complete`: OPEN (`sorry`) — body relies on
25+
`basepoint_prime_order`, which is itself open. The proof sketch in
26+
the body is a roadmap, not a proof.
27+
- `cofactored_verification_sound`: PROVEN — follows from
28+
`verification_equation_sound`, no `sorry` in the body.
29+
- `basepoint_prime_order`: OPEN (`sorry`) — needs an `AddGroup`
30+
instance on `CurvePoint` connected to `scalarMul`, plus
31+
`addOrderOf B = Curve25519.ℓ`, then Mathlib's
32+
`addOrderOf_dvd_of_nsmul_eq_zero`.
2033
-/
2134

2235
import Mathlib.GroupTheory.OrderOfElement

src/lib/src/verus_proofs/dsse_proofs.rs

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,17 @@ pub open spec fn spec_pae(
4343

4444
// ── LE64 injectivity ────────────────────────────────────────────────
4545

46-
/// LEMMA: le64 encoding is injective.
46+
/// **SPECIFICATION ONLY** — proof obligation not yet discharged.
47+
/// See `audit/2026-04-30/findings.md` C-1.
48+
///
49+
/// LEMMA (intended): le64 encoding is injective.
50+
///
51+
/// To actually discharge: case-split on `a != b` to obtain a bit position
52+
/// where the two u64s differ; show that bit lives in one of the eight
53+
/// `spec_le64` byte slots; conclude the corresponding byte differs, so
54+
/// the resulting `Seq<u8>` differs by `Seq` extensionality. Requires
55+
/// Verus' bit-vector mode (`assert(...) by(bit_vector)`) plus a `Seq`
56+
/// extensionality lemma from `vstd`.
4757
pub proof fn lemma_le64_injective(a: u64, b: u64)
4858
requires a != b,
4959
ensures spec_le64(a) != spec_le64(b),
@@ -53,12 +63,24 @@ pub proof fn lemma_le64_injective(a: u64, b: u64)
5363
// produce different byte sequences.
5464
// NOTE: Z3 needs help with Seq inequality — use assume for now.
5565
// The property is trivially true by construction of spec_le64.
66+
// ADMITTED — see SPECIFICATION ONLY block above. Audit C-1 (2026-04-30).
5667
assume(false);
5768
}
5869

5970
// ── PAE injectivity ─────────────────────────────────────────────────
6071

61-
/// THEOREM (CV-22, part 1): PAE is injective over payload types.
72+
/// **SPECIFICATION ONLY** — proof obligation not yet discharged.
73+
/// See `audit/2026-04-30/findings.md` C-1. Despite the `theorem_` prefix,
74+
/// the body currently relies on `assume(false)` and proves nothing.
75+
///
76+
/// SPEC (intended) — CV-22, part 1: PAE is injective over payload types.
77+
///
78+
/// To actually discharge: case-split on `type1.len() == type2.len()`.
79+
/// If lengths differ, `lemma_le64_injective` makes the `type_len` bytes
80+
/// at offset 8..16 differ. If lengths are equal but contents differ,
81+
/// `Seq` extensionality gives an index `i` where `type1[i] != type2[i]`,
82+
/// which lifts to offset `16 + i` of the concatenation. Requires `Seq::add`
83+
/// indexing lemmas from `vstd::seq_lib`.
6284
pub proof fn theorem_pae_injective_on_types(
6385
type1: Seq<u8>,
6486
type2: Seq<u8>,
@@ -72,10 +94,18 @@ pub proof fn theorem_pae_injective_on_types(
7294
// If types have equal length but different content, the type
7395
// bytes at offset 16..16+len differ.
7496
// NOTE: Requires Seq::add injectivity lemmas from vstd.
97+
// ADMITTED — see SPECIFICATION ONLY block above. Audit C-1 (2026-04-30).
7598
assume(false);
7699
}
77100

78-
/// THEOREM (CV-22, part 2): PAE is injective over payloads.
101+
/// **SPECIFICATION ONLY** — proof obligation not yet discharged.
102+
/// See `audit/2026-04-30/findings.md` C-1.
103+
///
104+
/// SPEC (intended) — CV-22, part 2: PAE is injective over payloads.
105+
///
106+
/// To actually discharge: symmetric argument to
107+
/// `theorem_pae_injective_on_types`, but the differing offset is
108+
/// `16 + payload_type.len() + 8 + i`. Same `vstd` lemmas required.
79109
pub proof fn theorem_pae_injective_on_payloads(
80110
payload_type: Seq<u8>,
81111
payload1: Seq<u8>,
@@ -85,10 +115,18 @@ pub proof fn theorem_pae_injective_on_payloads(
85115
ensures spec_pae(payload_type, payload1) != spec_pae(payload_type, payload2),
86116
{
87117
// Symmetric argument to theorem_pae_injective_on_types.
118+
// ADMITTED — see SPECIFICATION ONLY block above. Audit C-1 (2026-04-30).
88119
assume(false);
89120
}
90121

91-
/// COROLLARY: PAE is fully injective.
122+
/// **SPECIFICATION ONLY** — proof obligation not yet discharged.
123+
/// See `audit/2026-04-30/findings.md` C-1. Will follow trivially once
124+
/// the two `theorem_pae_injective_*` admits above are real proofs.
125+
///
126+
/// SPEC (intended): PAE is fully injective.
127+
///
128+
/// To actually discharge: case-split on `type1 != type2` vs
129+
/// `payload1 != payload2` and apply the corresponding theorem above.
92130
pub proof fn corollary_pae_fully_injective(
93131
type1: Seq<u8>,
94132
payload1: Seq<u8>,
@@ -99,6 +137,7 @@ pub proof fn corollary_pae_fully_injective(
99137
ensures spec_pae(type1, payload1) != spec_pae(type2, payload2),
100138
{
101139
// Follows from the two injectivity theorems above.
140+
// ADMITTED — see SPECIFICATION ONLY block above. Audit C-1 (2026-04-30).
102141
assume(false);
103142
}
104143

@@ -117,7 +156,16 @@ pub open spec fn spec_signing_message(
117156
.add(artifact_hash)
118157
}
119158

120-
/// THEOREM: Different domains produce different signing messages.
159+
/// **SPECIFICATION ONLY** — proof obligation not yet discharged.
160+
/// See `audit/2026-04-30/findings.md` C-1.
161+
///
162+
/// SPEC (intended): Different domains produce different signing messages.
163+
///
164+
/// To actually discharge: `Seq::push`/`Seq::add` preserve the domain
165+
/// prefix, so the first `min(domain1.len(), domain2.len())` bytes of
166+
/// each result equal the corresponding domain. By `Seq` extensionality,
167+
/// a differing byte in the prefix lifts to a differing byte in the full
168+
/// signing message. Requires `vstd::seq_lib` push/add indexing lemmas.
121169
pub proof fn theorem_domain_separation(
122170
domain1: Seq<u8>,
123171
domain2: Seq<u8>,
@@ -135,10 +183,20 @@ pub proof fn theorem_domain_separation(
135183
{
136184
// Different domain prefixes produce different total messages.
137185
// NOTE: Requires Seq::push/add extensionality lemmas.
186+
// ADMITTED — see SPECIFICATION ONLY block above. Audit C-1 (2026-04-30).
138187
assume(false);
139188
}
140189

141-
/// THEOREM: Different content types produce different signing messages.
190+
/// **SPECIFICATION ONLY** — proof obligation not yet discharged.
191+
/// See `audit/2026-04-30/findings.md` C-1.
192+
///
193+
/// SPEC (intended): Different content types produce different signing
194+
/// messages.
195+
///
196+
/// To actually discharge: the content-type byte sits at index
197+
/// `domain.len()` of both encodings. `Seq::push` indexing lemma plus
198+
/// the hypothesis `ct1 != ct2` give differing bytes there, so by
199+
/// `Seq` extensionality the messages differ.
142200
pub proof fn theorem_content_type_separation(
143201
domain: Seq<u8>,
144202
ct1: u8,
@@ -153,6 +211,7 @@ pub proof fn theorem_content_type_separation(
153211
{
154212
// Content type byte at position domain.len() differs.
155213
// NOTE: Requires Seq::push indexing lemma.
214+
// ADMITTED — see SPECIFICATION ONLY block above. Audit C-1 (2026-04-30).
156215
assume(false);
157216
}
158217

src/lib/src/verus_proofs/merkle_proofs.rs

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,23 @@ pub open spec fn spec_largest_pow2_lt(n: int) -> int
3535

3636
// ── Domain separation theorem ───────────────────────────────────────
3737

38-
/// AXIOM: Leaf and node hashes are in disjoint domains.
38+
/// **SPECIFICATION ONLY** — assumed as a cryptographic axiom; not
39+
/// discharged inside Verus. See `audit/2026-04-30/findings.md` C-1.
40+
///
41+
/// SPEC (intended): Leaf and node hashes are in disjoint domains.
3942
///
4043
/// Because leaf hashes are SHA-256(0x00 || data) and node hashes are
4144
/// SHA-256(0x01 || left || right), and SHA-256 is collision-resistant,
42-
/// no leaf hash can equal an interior node hash.
45+
/// no leaf hash can equal an interior node hash. This prevents
46+
/// second-preimage attacks on the Merkle tree.
4347
///
44-
/// This prevents second-preimage attacks on the Merkle tree.
48+
/// To actually discharge: this is a statement about SHA-256, not about
49+
/// Verus' SMT-friendly fragment. The honest path is to model
50+
/// `spec_leaf_hash` and `spec_node_hash` as opaque uninterpreted spec
51+
/// functions and *postulate* the disjointness as a separate
52+
/// `#[verifier::external_body]` axiom (or equivalent in the pinned Verus
53+
/// version), making the cryptographic assumption explicit instead of
54+
/// hiding it inside `assume(false)`.
4555
pub proof fn lemma_leaf_node_domain_separation()
4656
ensures
4757
forall|d: Seq<u8>, l: Seq<u8>, r: Seq<u8>|
@@ -50,6 +60,7 @@ pub proof fn lemma_leaf_node_domain_separation()
5060
// AXIOM: This holds by construction of SHA-256 with different prefix bytes.
5161
// Cannot be proven in Verus (requires hash function internals).
5262
// Justified by: prefix 0x00 vs 0x01 guarantees different first input byte.
63+
// ADMITTED — see SPECIFICATION ONLY block above. Audit C-1 (2026-04-30).
5364
assume(false);
5465
}
5566

@@ -85,13 +96,27 @@ pub open spec fn spec_walk_proof(
8596
}
8697
}
8798

88-
/// THEOREM (CV-20): Inclusion proof soundness.
99+
/// **SPECIFICATION ONLY** — proof obligation not yet discharged, AND
100+
/// the `ensures` clause currently degrades to `true`, so the body
101+
/// proves nothing useful. See `audit/2026-04-30/findings.md` C-1.
102+
///
103+
/// SPEC (intended) — CV-20: Inclusion proof soundness.
89104
///
90105
/// If the proof walk produces the expected root hash, then the leaf
91106
/// is authentically at the claimed index in a tree with that root.
107+
/// Formally: `verify_inclusion_proof(idx, size, leaf, proofs, root) = Ok`
108+
/// implies the leaf is at position `idx` in a tree with root hash `root`.
92109
///
93-
/// Formally: verify_inclusion_proof(idx, size, leaf, proofs, root) = Ok
94-
/// implies leaf is at position idx in tree with root hash root.
110+
/// To actually discharge:
111+
/// 1. Strengthen `ensures` from `true` to a real predicate, e.g. an
112+
/// inductive `MerkleTreeContains(root, leaf_hash, leaf_index)`
113+
/// relation defined over `spec_node_hash`.
114+
/// 2. Postulate collision resistance of `spec_node_hash` as an
115+
/// explicit `#[verifier::external_body]` axiom (cf. C-1 fix on
116+
/// `lemma_leaf_node_domain_separation`).
117+
/// 3. Induct on `proof_hashes.len() - step` using `spec_walk_proof`'s
118+
/// `decreases` clause; in the step case, use the postulated
119+
/// collision resistance to invert `spec_node_hash`.
95120
pub proof fn theorem_inclusion_proof_soundness(
96121
leaf_hash: Seq<u8>,
97122
leaf_index: int,
@@ -116,6 +141,7 @@ pub proof fn theorem_inclusion_proof_soundness(
116141
// determines the child hashes, binding leaf to root.
117142
//
118143
// Full mechanization requires collision resistance assumption on spec_node_hash.
144+
// ADMITTED — see SPECIFICATION ONLY block above. Audit C-1 (2026-04-30).
119145
assume(false);
120146
}
121147

verification/rocq/README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# `verification/rocq/` — translation-pipeline scaffolding (NOT a Rocq proof)
2+
3+
**Status:** unrealised. There is currently **no Rocq (Coq) formal proof**
4+
in this directory. CV-22 ("Rocq proof — signature verification protocol
5+
correctness") in `artifacts/cybersecurity/goals-and-requirements.yaml`
6+
remains in `in-progress` status, pending an actual `.v` file with a
7+
machine-checked proof.
8+
9+
This README is part of the 2026-04-30 audit honesty fix (finding C-3).
10+
11+
## What lives here today
12+
13+
- `pae.rs` — a Rust extraction of `compute_pae` from `src/lib/src/dsse.rs`,
14+
shaped for `coq-of-rust` translation. It is plain Rust with unit tests;
15+
it does not contain any Rocq (`.v`) source.
16+
- `BUILD.bazel` — declares a `rocq_rust_verified_library` target named
17+
`pae_verified`. Today this target exercises **pipeline scaffolding only**:
18+
it confirms that the `coq-of-rust` translation can be invoked on the
19+
Rust source. It does **not** discharge any verification obligation,
20+
because no Rocq proof script is fed to the pipeline.
21+
22+
## What would need to happen to elevate this to a real proof
23+
24+
1. Author one or more `.v` files in this directory containing a Rocq
25+
formalisation of the PAE injectivity property (`forall t1 t2 p1 p2,
26+
(t1 <> t2 \/ p1 <> p2) -> compute_pae t1 p1 <> compute_pae t2 p2`),
27+
stated against the `coq-of-rust`-translated definition of
28+
`compute_pae`.
29+
2. Wire those `.v` files into `BUILD.bazel` via `rules_rocq_rust` so the
30+
Bazel target type-checks the proof end-to-end (not merely compiles
31+
the translation).
32+
3. Make the Bazel test step in `.github/workflows/formal-verification.yml`
33+
non-`continue-on-error` for this target (cf. audit finding C-7 in
34+
`audit/2026-04-30/findings.md`), and update CV-22 in
35+
`artifacts/cybersecurity/goals-and-requirements.yaml` from
36+
`in-progress` to `proved` only after step 2 lands.
37+
38+
Until all three steps are complete, treat any claim of "Rocq-verified
39+
PAE" as aspirational. The Verus track in `src/lib/src/verus_proofs/`
40+
covers the same property, but its proof body is also currently
41+
admitted (see audit C-1).

0 commit comments

Comments
 (0)