Problem
read_json (provekit/common/src/file/io/json.rs:35) is a bare serde_json::from_reader. The binary path checks a 21-byte header (magic, format, major/minor, hash config) in provekit/common/src/file/io/bin.rs before it decodes anything. The JSON path checks nothing, and write_json writes nothing to check. This holds for all four FileFormat types: Prover (.pkp), Verifier (.pkv), NoirProofScheme (.nps), ProvekitProof<P> (.np).
Two things get through.
A version downgrade is not caught at all. ProvekitProof's struct shape is the same in 1.x and 2.0; only the bytes inside narg_string changed, and that field is serde_hex, so any length decodes. A 1.x JSON proof deserializes cleanly into a 2.0 ProvekitProof.
A cross-field proof is caught, but only by accident. Field elements hex-encode to different widths (Fr 32 B, Field64 8 B, Field64_3 24 B), so a mismatched element fails to decode. On a circuit with no public inputs there is nothing to compare and the proof goes through.
Neither is a soundness bug. The domain separator serializes the whole WhirR1CSScheme and the verifier builds it from its own .pkv, so a mismatch diverges in Fiat-Shamir replay and fails. It just fails after the full verification run, with an error that points somewhere else.
Ideal fix
Put the format, version, and field id in the JSON, and check them in read before decoding, so a JSON artifact fails the same way a binary one does.
What makes this awkward is that the bare JSON encoding is also the wire format for two consumers that never call file::read. The verifier-server /verify endpoint takes VerifyRequest.np as a serde_json::Value and passes it to serde_json::from_value::<ProvekitProof<Bn254Field>>. provekit-wasm's proveBytes/verifyBytes use serde_json::to_vec/from_slice, and proveJs/verifyJs go through serde_wasm_bindgen. So the tag has to be agreed across the file layer, the HTTP API and the npm package.
Two routes:
- Build the envelope in
json.rs from the T::FORMAT and T::VERSION consts that read and write already have in scope. No struct changes and no risk to the postcard layout. But file::io is cfg-gated off wasm, so the wasm consumers would never see it, and file-JSON would drift from API-JSON.
- Hand-write
Serialize/Deserialize and branch on is_human_readable(). Postcard hardcodes it to false, so the binary layout stays byte-identical. But serde_wasm_bindgen reports true, so the envelope would land in the JsValue handed to JS callers as well. is_human_readable() means "not postcard", not "JSON file".
Why this is not in #470
#470 is approved and scoped to the Rust proving path. Tagging JSON is a breaking schema change to an HTTP API and an npm package, and each needs its own migration. #470 adds a warn! on the JSON read path in the meantime, so nothing trusts an untagged artifact silently.
Files expected to change
provekit/common/src/file/io/json.rs: write and check the envelope.
provekit/common/src/file/io/mod.rs: thread T::FORMAT and T::VERSION into the JSON arms. Worth fixing at the same time: read_hash_config's JSON arm plucks a top-level hash_config key, has zero callers, and is wrong for Prover and NoirProofScheme (externally-tagged enums, so no top-level key) and for ProvekitProof (no such field).
provekit/common/src/whir_r1cs.rs: take the field id from ProofField::FIELD_ID, as np_format already does for binary.
tooling/verifier-server/src/models.rs: /verify request schema and decode_noir_proof.
tooling/provekit-wasm/src/{prover,verifier}.rs: proveBytes, verifyBytes, proveJs, verifyJs.
- Docs, plus migration notes for the HTTP and npm consumers.
Problem
read_json(provekit/common/src/file/io/json.rs:35) is a bareserde_json::from_reader. The binary path checks a 21-byte header (magic, format, major/minor, hash config) inprovekit/common/src/file/io/bin.rsbefore it decodes anything. The JSON path checks nothing, andwrite_jsonwrites nothing to check. This holds for all fourFileFormattypes:Prover(.pkp),Verifier(.pkv),NoirProofScheme(.nps),ProvekitProof<P>(.np).Two things get through.
A version downgrade is not caught at all.
ProvekitProof's struct shape is the same in 1.x and 2.0; only the bytes insidenarg_stringchanged, and that field isserde_hex, so any length decodes. A 1.x JSON proof deserializes cleanly into a 2.0ProvekitProof.A cross-field proof is caught, but only by accident. Field elements hex-encode to different widths (
Fr32 B,Field648 B,Field64_324 B), so a mismatched element fails to decode. On a circuit with no public inputs there is nothing to compare and the proof goes through.Neither is a soundness bug. The domain separator serializes the whole
WhirR1CSSchemeand the verifier builds it from its own.pkv, so a mismatch diverges in Fiat-Shamir replay and fails. It just fails after the full verification run, with an error that points somewhere else.Ideal fix
Put the format, version, and field id in the JSON, and check them in
readbefore decoding, so a JSON artifact fails the same way a binary one does.What makes this awkward is that the bare JSON encoding is also the wire format for two consumers that never call
file::read. The verifier-server/verifyendpoint takesVerifyRequest.npas aserde_json::Valueand passes it toserde_json::from_value::<ProvekitProof<Bn254Field>>. provekit-wasm'sproveBytes/verifyBytesuseserde_json::to_vec/from_slice, andproveJs/verifyJsgo throughserde_wasm_bindgen. So the tag has to be agreed across the file layer, the HTTP API and the npm package.Two routes:
json.rsfrom theT::FORMATandT::VERSIONconsts thatreadandwritealready have in scope. No struct changes and no risk to the postcard layout. Butfile::iois cfg-gated off wasm, so the wasm consumers would never see it, and file-JSON would drift from API-JSON.Serialize/Deserializeand branch onis_human_readable(). Postcard hardcodes it tofalse, so the binary layout stays byte-identical. Butserde_wasm_bindgenreportstrue, so the envelope would land in theJsValuehanded to JS callers as well.is_human_readable()means "not postcard", not "JSON file".Why this is not in #470
#470 is approved and scoped to the Rust proving path. Tagging JSON is a breaking schema change to an HTTP API and an npm package, and each needs its own migration. #470 adds a
warn!on the JSON read path in the meantime, so nothing trusts an untagged artifact silently.Files expected to change
provekit/common/src/file/io/json.rs: write and check the envelope.provekit/common/src/file/io/mod.rs: threadT::FORMATandT::VERSIONinto the JSON arms. Worth fixing at the same time:read_hash_config's JSON arm plucks a top-levelhash_configkey, has zero callers, and is wrong forProverandNoirProofScheme(externally-tagged enums, so no top-level key) and forProvekitProof(no such field).provekit/common/src/whir_r1cs.rs: take the field id fromProofField::FIELD_ID, asnp_formatalready does for binary.tooling/verifier-server/src/models.rs:/verifyrequest schema anddecode_noir_proof.tooling/provekit-wasm/src/{prover,verifier}.rs:proveBytes,verifyBytes,proveJs,verifyJs.