Skip to content

Commit cf82ad6

Browse files
Axiom flagship (3) — real hybrid Ed448+Dilithium5 certificate signing (G01) (#48)
## Summary Closes the **authenticating‑signature half of G01** — the certificate can now carry a *real* cryptographic signature, not just a forgeable content digest. This is **opt‑in**: the default `generate_certificate`/`save_certificate` path is byte‑for‑byte unchanged (`authenticated=false`, SHA‑256 content digest), so nothing downstream breaks. Uses **only vetted primitives** (no hand‑rolled crypto), matching the estate Trustfile scheme and the `pqcrypto` family `opsm` already ships. ### What's added - **`crypto/` — new `axiom_crypto` Rust cdylib.** Ed448 via **OpenSSL libcrypto**; Dilithium5 (ML‑DSA‑87 / FIPS 204) via **`pqcrypto-dilithium`**. Clean C ABI (caller‑allocates + returned length), minimal `unsafe` each with a `// SAFETY:` comment, 7 Rust round‑trip/tamper/wrong‑key tests. - **`src/verification/signing.jl`** — `Libdl` loader + `generate_hybrid_keypair` / `hybrid_sign` / `hybrid_verify`. Verification is **AND** — both Ed448 *and* Dilithium5 must verify (an attacker must break both to forge). Includes a **runtime ABI self‑check** that caught a real Dilithium5 signature‑length mismatch (4627, not the 4595 first assumed) during development. - **`certificates.jl`** — opt‑in `sign_certificate_hybrid` / `verify_certificate_hybrid` / `save_certificate_hybrid`. Signs the **SHA3‑512** digest (estate hashing standard) of the canonical content, which **includes the properties list** so a claimed property can't be added/removed without invalidating the signature. - **`test/verification/hybrid_signing_tests.jl`** — real round‑trip + **THE KEY ASSERTION**: a forger who knows the exact content and recomputes the SHA3‑512 digest but lacks the private keys **cannot** produce a signature that verifies against the victim's public keys (plus a self‑verify sanity check proving it's authentication, not a crypto malfunction). - `Justfile` `build-crypto`/`test-crypto`; `ROADMAP.adoc` documents the shipped capability and the **private‑key custody story** (HSM/offline signer; public keys only in certificates; rotation/revocation explicitly flagged as not‑yet‑implemented). ### Key‑management stance **No private key material is committed anywhere** (verified). Certificates embed only public keys; signing keys are generated out‑of‑band. ## Verification (independent re‑run; crypto reviewed by hand) - `cd crypto && cargo test --release` → **7/7 pass**. - Built the cdylib, then `Pkg.test()` → **697 / 697 pass** (670 + 27 new), hybrid path actually executing (not skipped). - Read `_hybrid_verify_digest` — confirmed **AND** semantics and real `ccall` into the cdylib (no mock). - Confirmed the forged‑certificate rejection assertion is genuine and passing; confirmed no key files staged/untracked. ## Tracked follow‑ups SPHINCS+ fallback tier; key rotation/revocation; the estate‑wide Ed448‑vs‑Ed25519 reconciliation (`opsm` currently uses Ed25519 — flagged for owner); wiring `build-crypto` into CI as part of the tier‑2 tooling wave. 🤖 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 9acb0ef commit cf82ad6

12 files changed

Lines changed: 1786 additions & 8 deletions

File tree

Justfile

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,15 @@ build-backends: build-zig
4747
run-zig: build-zig
4848
AXIOM_ZIG_LIB=zig/zig-out/lib/libaxiom_zig.so julia --project=. -e 'using Axiom; println("Backend: ", typeof(current_backend()))'
4949

50+
# Build the hybrid Ed448+Dilithium5 certificate-signing crypto shim (cdylib)
51+
build-crypto:
52+
cd crypto && cargo build --release
53+
@echo "Crypto shim built at crypto/target/release/libaxiom_crypto.so (or .dylib/.dll)"
54+
55+
# Run the crypto shim's own Rust test suite (round-trip + tamper/wrong-key rejection)
56+
test-crypto:
57+
cd crypto && cargo test --release
58+
5059
# Check code quality
5160
lint:
5261
@echo "Checking editorconfig..."

ROADMAP.adoc

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ Must progress snapshot (2026-02-16):
4646
* [x] Added in-tree gRPC unary protobuf binary-wire support (`application/grpc`) with JSON bridge fallback (`application/grpc+json`).
4747
* [x] Added direct `.pt/.pth/.ckpt` import bridge and expanded ONNX export coverage (Dense/Conv/Norm/Pool + activations).
4848
* [x] Added consolidated readiness gate script for local/CI release checks: `scripts/readiness-check.sh`.
49+
* [x] Added REAL authenticating hybrid Ed448+Dilithium5 (ML-DSA-87) certificate
50+
signing (G01, opt-in): Rust `cdylib` shim in `crypto/` (`pqcrypto-dilithium`
51+
for Dilithium5, system libcrypto via the `openssl` crate for Ed448 — no
52+
hand-rolled cryptography) exposed to Julia via `Libdl`/`ccall` in
53+
`src/verification/signing.jl`; `sign_certificate_hybrid` /
54+
`verify_certificate_hybrid` / `save_certificate_hybrid` in
55+
`src/verification/certificates.jl`; content is hashed with SHA3-512 (estate
56+
hashing standard) before signing. The *default* `generate_certificate` /
57+
`save_certificate` path is unchanged (still an unkeyed SHA-256 content
58+
digest, `authenticated=false`) — hybrid signing is strictly additive and
59+
opt-in. See `crypto/README.md` for the C ABI and `test/verification/hybrid_signing_tests.jl`
60+
for the forged-signature-rejection proof.
4961

5062
=== Should
5163

@@ -87,6 +99,41 @@ Readiness verification (2026-02-17): `scripts/readiness-check.sh` => Passed: 29,
8799

88100
See link:docs/wiki/Roadmap-Commitments.md[docs/wiki/Roadmap-Commitments.md] for staged targets and acceptance criteria.
89101

102+
== Certificate Signing: Private Key Custody
103+
104+
The hybrid Ed448+Dilithium5 signing capability (`crypto/`,
105+
`src/verification/signing.jl`) authenticates certificate *content*; it does
106+
not, by itself, solve *key custody*. That is an operational concern, tracked
107+
here explicitly rather than left implicit:
108+
109+
* **Private keys are never generated by, stored in, or committed to this
110+
repository.** `generate_hybrid_keypair()` exists only for tests/examples
111+
and produces keys held purely in-memory for the lifetime of that process.
112+
* **Real signing keys are provisioned out-of-band.** The intended custody
113+
model is an HSM (hardware security module) or an offline/air-gapped signing
114+
workstation: the private Ed448 and Dilithium5 keys are generated there,
115+
never leave that boundary, and every real certificate signature is produced
116+
by sending the certificate's canonical content (or its SHA3-512 digest) to
117+
that boundary and receiving back a `HybridSignature` — the private keys
118+
themselves are never loaded into the same process that runs model
119+
training/verification.
120+
* **Only public keys are ever embedded in a certificate.** `save_certificate_hybrid`
121+
and `hybrid_signature_to_dict` only ever serialize the public Ed448 and
122+
Dilithium5 keys (hex-encoded) alongside the signature values, so that
123+
verification is self-contained without needing to distribute or trust a
124+
separate keyring file.
125+
* **Key rotation / revocation is not yet implemented** — there is currently no
126+
certificate-transparency-style log or revocation list for hybrid signing
127+
public keys; a verifier that trusts a given public key trusts every
128+
certificate signed by it until a rotation policy is added. This is an
129+
explicit gap, not an oversight, and is a natural follow-on once an HSM/offline
130+
signer is actually provisioned in a deployment.
131+
* **`.gitignore` enforces this at the file level** as defence in depth
132+
(`*.pem`, `*.key`, `secrets/` are ignored both at the repo root and inside
133+
`crypto/`), but the real control is procedural: nobody should ever generate
134+
a real signing key inside a working copy of this repository in the first
135+
place.
136+
90137
== Definition of Done for "Production Ready"
91138

92139
. Clean `instantiate/build/precompile/test` on supported Julia versions

crypto/.gitignore

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# Rust build output — never commit compiled artifacts or private key material.
3+
target/
4+
Cargo.lock.bak
5+
6+
# Defence-in-depth: this crate must never see a private key on disk during
7+
# normal use (keys are generated in-memory by tests, or out-of-band by an
8+
# HSM/offline process for real signing), but block common accidental paths
9+
# just in case.
10+
*.pem
11+
*.key
12+
secrets/

crypto/Cargo.lock

Lines changed: 239 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crypto/Cargo.toml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
[package]
3+
name = "axiom_crypto"
4+
version = "0.1.0"
5+
authors = ["Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>"]
6+
edition = "2021"
7+
description = "Hybrid Ed448 + Dilithium5 (ML-DSA-87) certificate signing for Axiom.jl (estate Trustfile scheme)"
8+
license = "MPL-2.0"
9+
publish = false
10+
11+
[lib]
12+
name = "axiom_crypto"
13+
path = "src/lib.rs"
14+
crate-type = ["cdylib"]
15+
16+
[dependencies]
17+
openssl = "0.10"
18+
pqcrypto-dilithium = "0.5"
19+
pqcrypto-traits = "0.3"
20+
21+
[profile.release]
22+
# Keep panics as aborts out of the FFI boundary story simple: every exported
23+
# fn is written to catch errors internally and return a status code instead
24+
# of unwinding across the C ABI, but `panic = "abort"` is a defence in depth
25+
# backstop in case a bug slips through (unwinding across FFI is UB).
26+
panic = "abort"

0 commit comments

Comments
 (0)