Skip to content

JSON artifacts carry no format, version, or field tag #472

Description

@BornPsych

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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions