Skip to content

Commit 070905f

Browse files
Axiom flagship hardening — P1 soundness holes (WIP) (#46)
## Summary First hardening pass toward making **Axiom.jl** the estate Julia flagship and measuring-stick. Opened as a **draft** to accumulate the holes-first fixes from a full ground-truthed audit (soundness → doc reconciliation → estate tooling). This commit closes two confirmed **P1 soundness holes**. ### G02 — vacuous `NoNaN` static "proof" (critical) `checker.jl::has_safe_operations()` returned a bare `true`, so every `NoNaN` request on the static path was reported `:proven` regardless of the model's operations. A sound static `NoNaN` proof also requires a finite-input guarantee that cannot be established statically, so `NoNaN` now routes to empirical checking and `has_safe_operations` makes no false claim. ### G01 — falsely-labelled certificate "signature" (critical; relabel) `certificates.jl` emitted `algorithm: "SHA256-HMAC"` over an **unkeyed** `SHA-256` digest — forgeable and misleadingly presented as authenticated. Relabelled honestly: `algorithm: SHA-256`, `kind: content-digest`, `authenticated: false`, plus an explanatory note; docstrings corrected. Authenticating **hybrid Ed448+Dilithium5** signatures (matching the estate Trustfile and the tested `opsm` PQ crypto) are the tracked follow-up. ### Regression guard Added `test/verification/soundness_tests.jl` to the main suite so neither hole can silently reopen. ## Verification (ran the tools; no self-grading) - `Pkg.test()` — **651 / 651 pass** (642 prior + 9 new soundness assertions) - `test/ci/certificate_integrity.jl` — **6 / 6 pass** ## Scope / tracked follow-ups Remaining Axiom P1: real hybrid signing (G01 auth layer), proof-import theorem-presence check (G03), `@prove` honest relabel (G04), Idris2 ABI ↔ zig-export reconciliation (G05). Broader flagship work: doc reconciliation, Aqua/JET/Documenter, RegistryCI, panic-attack static-analysis gate, RSR completeness. Registry and julianiser critical fixes land on their own branches. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01UPFC9YQ7g9gc3VnRox42Q1 --- _Generated by [Claude Code](https://claude.ai/code/session_01UPFC9YQ7g9gc3VnRox42Q1)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 382e172 commit 070905f

4 files changed

Lines changed: 92 additions & 12 deletions

File tree

src/verification/certificates.jl

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
# SPDX-License-Identifier: MPL-2.0
22
# Axiom.jl Verification Certificates
33
#
4-
# Cryptographically signed certificates proving model properties.
4+
# Tamper-evidence certificates for model properties. The certificate carries a
5+
# SHA-256 content digest over its public fields — this detects accidental or
6+
# naive tampering but is NOT a keyed/authenticating signature (anyone can
7+
# recompute the digest). Authenticating hybrid Ed448+Dilithium5 signatures (per
8+
# the estate Trustfile) are tracked in ROADMAP.adoc.
59

610
using SHA
711

@@ -117,7 +121,12 @@ function compute_data_hash(data)
117121
end
118122

119123
"""
120-
Sign a certificate (simplified - production would use proper PKI).
124+
Attach a tamper-evidence digest to a certificate.
125+
126+
NOTE: this computes an unkeyed SHA-256 content digest, NOT a keyed or asymmetric
127+
signature — it detects naive tampering but does not authenticate authorship (a
128+
forger can recompute the digest). Authenticating hybrid Ed448+Dilithium5
129+
signatures are planned; see ROADMAP.adoc.
121130
"""
122131
function sign_certificate(cert::Certificate)
123132
# Concatenate certificate fields
@@ -209,7 +218,13 @@ function _save_certificate_json(cert::Certificate, path::String)
209218
),
210219
"signature" => Dict(
211220
"value" => cert.signature,
212-
"algorithm" => "SHA256-HMAC"
221+
"algorithm" => "SHA-256",
222+
"kind" => "content-digest",
223+
"authenticated" => false,
224+
"note" => "Unkeyed SHA-256 digest over public certificate fields: " *
225+
"detects naive tampering but does NOT authenticate authorship " *
226+
"(a forger can recompute it). Authenticating hybrid " *
227+
"Ed448+Dilithium5 signatures are planned; see ROADMAP.adoc."
213228
)
214229
)
215230

src/verification/checker.jl

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -276,12 +276,12 @@ function try_static_verify(prop::Property, model)
276276
end
277277
end
278278

279-
if prop isa NoNaN
280-
# Check for NaN-producing operations
281-
if has_safe_operations(model)
282-
return :proven
283-
end
284-
end
279+
# NoNaN is intentionally NOT statically proven here. A sound static NoNaN
280+
# proof needs BOTH structural operation-safety (see `has_safe_operations`)
281+
# AND a finite-input guarantee, and inputs cannot be constrained statically.
282+
# Returning :proven from structure alone was a soundness hole (every model
283+
# was vacuously "proven" NaN-safe), so NoNaN routes to empirical checking.
284+
# Planned input-range analysis (see ROADMAP.adoc) would enable a sound proof.
285285

286286
:unknown
287287
end
@@ -316,11 +316,18 @@ function has_bounded_output(model, low, high)
316316
end
317317

318318
"""
319-
Check if model uses only NaN-safe operations.
319+
Whether `model` uses only NaN/Inf-safe operations. This is a NECESSARY but not
320+
SUFFICIENT condition for NaN-freedom (finite inputs are also required and cannot
321+
be established statically), so it must never be used on its own to claim a
322+
NoNaN proof — see `try_static_verify`.
323+
324+
A real structural graph analysis is planned (see ROADMAP.adoc). Until it lands
325+
this makes NO safety claim and returns `false`, so NoNaN is checked empirically
326+
rather than via a vacuous static "proof". (Previously this returned `true`
327+
unconditionally — a soundness hole that reported every model as NaN-safe.)
320328
"""
321329
function has_safe_operations(model)
322-
# Simplified check - real implementation would analyze graph
323-
true
330+
false
324331
end
325332

326333
"""

test/runtests.jl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,9 @@ with open(args.output, "w", encoding="utf-8") as f:
751751
include("verification/serialization_tests.jl")
752752
include("verification/proof_export_tests.jl")
753753

754+
# Soundness regression tests (P1 holes must never silently reopen)
755+
include("verification/soundness_tests.jl")
756+
754757
# CRG Grade C tests
755758
include("e2e_test.jl")
756759
include("property_test.jl")
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# Soundness regression tests — guard against reintroducing the P1 soundness holes:
3+
# G02: vacuous NoNaN static "proof" (has_safe_operations == true)
4+
# G01: certificate falsely labelling an unkeyed digest as "SHA256-HMAC"
5+
# These run inside the main suite so the holes cannot silently reopen.
6+
7+
using Test
8+
using Axiom
9+
using JSON
10+
11+
@testset "Soundness (P1 holes)" begin
12+
model = Sequential(Dense(10, 5, relu), Dense(5, 3), Softmax())
13+
14+
@testset "NoNaN is not a vacuous static proof (G02)" begin
15+
# Structure alone cannot soundly prove NoNaN (a finite-input guarantee is
16+
# also required), so the static path must return :unknown, never :proven.
17+
@test Axiom.try_static_verify(NoNaN(), model) === :unknown
18+
# The unconditional `true` is gone; the honest predicate makes no claim.
19+
@test Axiom.has_safe_operations(model) === false
20+
# Regression: genuinely-sound static proofs still work.
21+
@test Axiom.try_static_verify(ValidProbabilities(), model) === :proven
22+
# NoNaN still verifies empirically on well-behaved data (fallback path).
23+
x = Tensor(randn(Float32, 4, 10))
24+
res = verify(model; properties = [NoNaN()], data = [(x, nothing)])
25+
@test res.passed
26+
end
27+
28+
@testset "Certificate does not falsely claim a keyed signature (G01)" begin
29+
x = Tensor(randn(Float32, 2, 10))
30+
result = verify(model; properties = [ValidProbabilities(), FiniteOutput()], data = [(x, nothing)])
31+
cert = generate_certificate(model, result; model_name = "soundness-ci")
32+
33+
path = tempname() * ".json"
34+
save_certificate(cert, path; format = :json)
35+
try
36+
content = read(path, String)
37+
# The false "SHA256-HMAC" label (unkeyed digest sold as HMAC) is gone.
38+
@test !occursin("SHA256-HMAC", content)
39+
parsed = JSON.parsefile(path)
40+
@test parsed["signature"]["authenticated"] == false
41+
@test parsed["signature"]["kind"] == "content-digest"
42+
finally
43+
rm(path; force = true)
44+
end
45+
46+
# Tamper-evidence: an altered digest is rejected. (A forger who recomputes
47+
# the digest still passes — the documented limit of a content digest;
48+
# authenticating Ed448+Dilithium5 signatures are tracked in ROADMAP.adoc.)
49+
@test verify_certificate(cert)
50+
forged = Axiom.Certificate(cert.model_hash, cert.model_name, cert.properties,
51+
cert.verification_mode, cert.test_data_hash, cert.proof_type,
52+
cert.created_at, cert.axiom_version, cert.verifier_id, "deadbeef")
53+
@test !verify_certificate(forged)
54+
end
55+
end

0 commit comments

Comments
 (0)