diff --git a/.github/actions/forge-build/action.yml b/.github/actions/forge-build/action.yml index 5ed4396..deb335b 100644 --- a/.github/actions/forge-build/action.yml +++ b/.github/actions/forge-build/action.yml @@ -1,10 +1,11 @@ -# Install Foundry, restore the forge artifact cache, and build the contracts. +# Install Foundry, vendor the Honk verifiers, restore the forge artifact cache, +# and build the contracts. # -# Four jobs need `forge build` before they can do anything else: solidity runs -# the tests over it, handles-drift shells to `forge fmt`, rust vendors the -# artifacts into the crate, and ts generates bindings from them. Repeating the -# install + cache + build stanza in each job is how the copies drift, so the -# sequence lives here once. +# Every job that needs `forge build` — solidity runs the tests over it, rust +# vendors the artifacts into the crate, ts generates bindings from them, and +# the publish dry-run and both publish jobs do the last two for real — runs +# this composite. Repeating the install + vendor + cache + build stanza in each +# job is how the copies drift, so the sequence lives here once. # # The cache key is the SAME in every job that uses this action — it is derived # only from inputs.foundry-version and the checked-out sources — so whichever @@ -30,6 +31,16 @@ runs: with: version: ${{ inputs.foundry-version }} + # The Honk verifiers are not in the checkout: they are gitignored, and + # scripts/vendor-circuit-verifiers.sh downloads them from the pinned + # libid-circuits release, refusing any tarball whose sha256 is not the + # literal in solidity/contracts/circuits/circuits.json. So every build, + # test, dry-run and publish compiles a download the pin has just checked. + # Before the cache step, so the vendored sources are in the key's hash. + - name: Vendor circuit verifiers + shell: bash + run: ./scripts/vendor-circuit-verifiers.sh + # `solidity/lib/**` hashes the checked-out submodule trees, so bumping a # submodule pointer changes the key. The foundry version is in the key too: # artifacts embed solc metadata, and a stale `out/` from another compiler diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 454cdf8..3c871f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,11 +7,13 @@ name: CI # cache key, and whichever run warms the cache first makes the build a fast # no-op everywhere else. # -# Neither generated tree is committed (rust/contracts/artifacts and -# ts/packages/contracts/src/abis are gitignored), so every job that compiles -# the crate or the package regenerates it first — publishing included. +# Nothing generated is committed: the vendored Honk verifiers, +# rust/contracts/artifacts and ts/packages/contracts/src/abis are all +# gitignored. The forge-build composite vendors the verifiers before it +# builds, and every job that compiles the crate or the package regenerates +# its tree first — publishing included. # -# Job ids are stable on purpose (solidity, handles-drift, rust, ts, dco): +# Job ids are stable on purpose (solidity, generated-tables, rust, ts, dco): # the release/publish jobs at the bottom of this file hang their `needs:` off # these names. # @@ -93,7 +95,9 @@ jobs: # Generated files match their source. handles.json generates the platform # constants and the handle vector table for every language; a drift between # the source and a committed output leaves every per-language test green - # while the languages disagree with each other. + # while the languages disagree with each other. The Honk verifiers are not + # checked here: nothing of theirs is committed but the pin, and the + # forge-build composite holds every download to it. # --------------------------------------------------------------------------- generated-tables: name: Generated tables diff --git a/.gitignore b/.gitignore index d9fb3a5..584324f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,5 +15,13 @@ rust/contracts/artifacts/ ts/packages/contracts/src/abis/ ts/packages/contracts/src/calls/ +# Vendored from the pinned libid-circuits release, never committed: the Honk +# verifiers are that repository's release asset, and circuits.json beside them +# (the version and each tarball's sha256, which IS committed) is the pin every +# download is checked against. CI's forge-build action vendors them before +# every build, test, dry-run and publish. Locally, before `forge build`: +# scripts/vendor-circuit-verifiers.sh # -> solidity/contracts/circuits/*HonkVerifier.sol +solidity/contracts/circuits/*HonkVerifier.sol + # Python bytecode from the generator scripts. __pycache__/ diff --git a/README.md b/README.md index 7144008..0d6ce0f 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ solidity/ # Foundry project root contracts/ ceremony/ # NotaryService, CeremonyProofVerifier, Platform Verifiers, # GoogleJwtRoots + circuits/ # the UltraHonk verifiers the Platform Verifiers pin: + # circuits.json pins a libid-circuits release, the + # Solidity is vendored from it and not committed identity/ # IdentityNames, handle normalization factory/ # LibidFactory: deterministic CREATE3 deployment WTIA9.sol # wrapped TIA @@ -23,6 +26,7 @@ rust/contracts/ # libid-contracts crate: alloy bindings + embedded artifact ts/packages/contracts/ # @libid/contracts: viem ABIs, call builders, identity helpers scripts/ vendor-artifacts.sh + vendor-circuit-verifiers.sh regen-identity-handles.py ``` @@ -30,14 +34,18 @@ scripts/ ```sh git submodule update --init --recursive +scripts/vendor-circuit-verifiers.sh # -> solidity/contracts/circuits/*HonkVerifier.sol cd solidity forge build forge test ``` -`forge build` is the input to the two generated trees, neither of which is -committed. Generate them once after cloning, and again after any change to a -contract they cover: +Nothing generated is committed. The Honk verifiers are downloaded from the +pinned [libid-circuits](https://github.com/libid-org/libid-circuits) release +(see [Circuit verifiers](#circuit-verifiers)), so a fresh clone vendors them +before its first `forge build`, which needs curl, jq, tar and forge. `forge +build` is in turn the input to the two generated trees. Generate them once +after cloning, and again after any change to a contract they cover: ```sh scripts/vendor-artifacts.sh # -> rust/contracts/artifacts (the crate embeds @@ -47,9 +55,10 @@ pnpm -C ts codegen # -> ts/packages/contracts/src/abis (tsc reads # these, so `pnpm -C ts build` needs them) ``` -CI runs both in every job that compiles the crate or the package, and again in -the publish jobs — the published crate and npm package carry the generated -output even though git does not. +CI runs all three before every build, test, dry-run and publish: the +forge-build action vendors the verifiers before it builds, and every job that +compiles the crate or the package regenerates its tree — the published crate +and npm package carry the generated output even though git does not. ## Handle vectors @@ -66,6 +75,31 @@ This generates `solidity/contracts/identity/HandleVectors.sol`, `ts/packages/contracts/src/identity/handleVectors.ts`; CI's handle-tables job fails when any of them drifts from `handles.json`. +## Circuit verifiers + +The ceremony circuits' UltraHonk verifiers are not written here. `bb` derives +each from its circuit's verification key, and +[libid-circuits](https://github.com/libid-org/libid-circuits) runs `bb` and +ships the Solidity in its release tarballs. `scripts/vendor-circuit-verifiers.sh` +downloads it into `solidity/contracts/circuits/`, formatted, where `forge +build` compiles it and the crate embeds it, so no consumer runs `bb`. The +files are gitignored: they are another repository's release asset, and the +pin says which bytes they must be. + +`solidity/contracts/circuits/circuits.json` is the pin — the release version +and each tarball's sha256, committed here and checked against every download. +To move it, download the new release's tarballs, take their digests with +`shasum -a 256`, write the version and the digests into `circuits.json`, then: + +```sh +scripts/vendor-circuit-verifiers.sh # rewrite the verifiers from the pin +``` + +CI's forge-build action runs the same script before every build, test, +dry-run and publish, refusing any tarball whose digest is not the pin's; a +release cannot ship a verifier that is not what the pinned circuits release +shipped. + ## Releasing The Rust crate ([`libid-contracts`](https://crates.io/crates/libid-contracts)) diff --git a/rust/contracts/README.md b/rust/contracts/README.md index 9687ba2..43fcbf1 100644 --- a/rust/contracts/README.md +++ b/rust/contracts/README.md @@ -3,17 +3,19 @@ Typed [alloy](https://github.com/alloy-rs/alloy) bindings, embedded forge artifacts, and deploy/upgrade helpers for the libid identity stack: the ceremony verification path (`NotaryService`, `CeremonyProofVerifier`, the -three launch Platform Verifiers it routes to, and `GoogleJwtRoots`, the -signing keys the `google/v1` verifier trusts), the naming system -(`IdentityNames`), and the deterministic deployment factory (`LibidFactory`). +three launch Platform Verifiers it routes to, the two UltraHonk verifiers +they pin, and `GoogleJwtRoots`, the signing keys the `google/v1` verifier +trusts), the naming system (`IdentityNames`), and the deterministic +deployment factory (`LibidFactory`). The compiled artifacts are vendored into the crate, so a consumer can deploy or upgrade the whole stack against a live network with **zero filesystem dependencies at runtime**. They are generated, not committed: `scripts/vendor-artifacts.sh` produces `artifacts/` from `solidity/` and CI -runs it before every build, test and publish. Working in this repo, run it -once after cloning — the crate embeds the directory with `include_dir!`, so -until it exists `cargo build` fails at macro expansion. Signing stays on the +runs it before every build, test and publish. Working in this repo, run +`scripts/vendor-circuit-verifiers.sh` and then it once after cloning — the +Honk verifiers are vendored too, and the crate embeds the directory with +`include_dir!`, so until it exists `cargo build` fails at macro expansion. Signing stays on the consumer's side: every helper is generic over an alloy `Provider` you have already wired with a wallet. @@ -74,20 +76,25 @@ async fn main() -> Result<(), Box> { } ``` -## Example: deploy a Platform Verifier +## Example: deploy a Platform Verifier on its circuit's Honk verifier A Platform Verifier pins the bb-generated UltraHonk verifier for its circuit by address and by code hash, holds a Notary Service only if its profile notarizes anything, and caps its parameters. `platform_verifier::Initializer` knows those rules: it reads the code hash off the chain, refuses what the -contract would refuse, and builds the exact `initialize` call. The Honk -verifier comes from a `libid-circuits` release and is deployed beforehand. +contract would refuse, and builds the exact `initialize` call. + +The Honk verifier is vendored here too, from the pinned `libid-circuits` +release. `circuits::deploy_honk_verifier` deploys the two libraries it links +(`RelationsLib`, `ZKTranscriptLib`), links them in and deploys the verifier — +three transactions — and returns the address the initializer pins. ```rust,no_run use alloy::{primitives::Address, providers::ProviderBuilder}; use libid_contracts::{ bindings::ceremony::{CeremonyProofVerifier, XPlatformVerifier}, - platform_verifier::{deploy_platform_verifier, Initializer, TlsNotaryRoots}, + circuits::deploy_honk_verifier, + platform_verifier::{deploy_platform_verifier, Initializer, PlatformVerifier, TlsNotaryRoots}, Artifacts, }; @@ -97,8 +104,12 @@ async fn main() -> Result<(), Box> { .wallet(/* your signer */ todo!()) .connect_http("https://rpc.example.org".parse()?); let artifacts = Artifacts::embedded(); - let (owner, notary, honk_verifier, proof_verifier): (Address, Address, Address, Address) = - todo!(); + let (owner, notary, proof_verifier): (Address, Address, Address) = todo!(); + + // The circuit `x/v1` proves under is `bearer-link`; `PlatformVerifier::circuit` + // says so, and this deploys its verifier with the libraries linked. + let honk_verifier = + deploy_honk_verifier(&provider, &artifacts, PlatformVerifier::X.circuit(), None).await?; // `x/v1`: two notarized sessions, so a Notary Service is required. // `Initializer::Google` takes `GoogleRoots` instead — no Notary Service @@ -141,9 +152,10 @@ Other entry points: canonical cross-network factory where missing and deploy protocol proxies through it at name-derived CREATE3 addresses. - `deploy::load_linked_bytecode` (or `Artifacts::linked_bytecode`) — deploys - and links external libraries before returning the creation bytecode. Nothing - covered today links one; the UltraHonk verifiers the ceremony circuits bring - will. + and links external libraries before returning the creation bytecode; what + `circuits::deploy_honk_verifier` goes through. +- `circuits::version` — the `libid-circuits` release the vendored verifiers + came from, for a consumer that names a deployment after its artifact. - `platform_verifier::codehash_at` — the code hash `setTrustRoots` wants when a Platform Verifier is rotated onto a new circuit release. - `Artifacts::method_identifiers` — selector extraction from the vendored diff --git a/rust/contracts/src/artifacts.rs b/rust/contracts/src/artifacts.rs index 264813d..c44a8fd 100644 --- a/rust/contracts/src/artifacts.rs +++ b/rust/contracts/src/artifacts.rs @@ -39,11 +39,18 @@ pub const COVERED: &[(&str, &str)] = &[ ("CeremonyProofVerifier", "CeremonyProofVerifier"), ("ERC1967Proxy", "ERC1967Proxy"), ("GoogleJwtRoots", "GoogleJwtRoots"), - // ceremony: the launch Platform Verifiers (one per profile; the UltraHonk - // verifier each pins comes from the circuits release, not from here) + // ceremony: the launch Platform Verifiers (one per profile) ("XPlatformVerifier", "XPlatformVerifier"), ("GitHubPlatformVerifier", "GitHubPlatformVerifier"), ("GooglePlatformVerifier", "GooglePlatformVerifier"), + // circuits: the UltraHonk verifiers the Platform Verifiers pin, vendored + // from the libid-circuits release, each with the two libraries it links + ("BearerLinkHonkVerifier", "BearerLinkHonkVerifier"), + ("BearerLinkHonkVerifier", "RelationsLib"), + ("BearerLinkHonkVerifier", "ZKTranscriptLib"), + ("OidcGoogleHonkVerifier", "OidcGoogleHonkVerifier"), + ("OidcGoogleHonkVerifier", "RelationsLib"), + ("OidcGoogleHonkVerifier", "ZKTranscriptLib"), // identity ("IdentityNames", "IdentityNames"), // ens (deployed once per network, not CREATE3-canonical) @@ -82,17 +89,22 @@ impl Artifacts { /// The raw artifact JSON for `out/.sol/.json`. pub fn raw(&self, file: &str, contract: &str) -> Result { - let rel = format!("{file}.sol/{contract}.json"); + self.read_json(&format!("{file}.sol/{contract}.json")) + } + + /// Any JSON file at `rel` inside the source: an artifact, or the + /// `circuits.json` pin the vendor script copies in beside them. + pub(crate) fn read_json(&self, rel: &str) -> Result { let contents = match &self.source { Source::Embedded => EMBEDDED - .get_file(&rel) + .get_file(rel) .and_then(|f| f.contents_utf8()) .map(str::to_owned) .ok_or_else(|| Error::Artifact { detail: format!("no embedded artifact {rel}"), })?, Source::Dir(dir) => { - let path = dir.join(&rel); + let path = dir.join(rel); std::fs::read_to_string(&path).map_err(|e| Error::Artifact { detail: format!("failed to read artifact {}: {e}", path.display()), })? @@ -130,11 +142,12 @@ impl Artifacts { /// Creation bytecode with every external library it references deployed /// (recursively) through `provider` and linked in. Mirrors what forge does - /// automatically. Nothing covered today links a library; the UltraHonk - /// verifiers the ceremony circuits bring link `ZKTranscriptLib`, and this - /// is the path they will deploy through. For artifacts with no link - /// references this behaves like [`Self::bytecode_named`] (no transaction - /// is sent). + /// automatically. The two UltraHonk verifiers are what links a library + /// today — `RelationsLib` and `ZKTranscriptLib`, vendored beside each — + /// and [`deploy_honk_verifier`](crate::circuits::deploy_honk_verifier) + /// is the one call that takes them through here and deploys the result. + /// For artifacts with no link references this behaves like + /// [`Self::bytecode_named`] (no transaction is sent). /// /// `sender` opts into explicit nonce management (see /// [`deploy_contract_from`](crate::deploy::deploy_contract_from)). @@ -207,8 +220,8 @@ mod tests { use super::*; /// Every covered contract's creation bytecode is present and non-empty. - /// Only the hex is checked here so a future artifact with link - /// placeholders still passes; linking is the anvil tests' business. + /// Only the hex is checked here so the artifacts with link placeholders + /// (the Honk verifiers) pass too; linking is the anvil tests' business. #[test] fn every_covered_contract_has_bytecode() { let artifacts = Artifacts::embedded(); @@ -223,23 +236,50 @@ mod tests { } } - /// Contracts without link references decode straight to bytes — which is - /// every covered contract today, so this doubles as the check that none of - /// them silently grew a library dependency the vendor script must follow. + /// Contracts without link references decode straight to bytes, and the + /// ones with them are exactly the two Honk verifiers — so this doubles + /// as the check that nothing else silently grew a library dependency, + /// and that every library a linked contract names is covered under its + /// own file, where the vendor script and the linker look for it. #[test] - fn unlinked_contracts_decode() { + fn unlinked_contracts_decode_and_linked_ones_are_the_honk_verifiers() { let artifacts = Artifacts::embedded(); + let mut linked = Vec::new(); for &(file, contract) in COVERED { - if artifacts - .link_references(file, contract) - .unwrap() - .is_empty() - { + let refs = artifacts.link_references(file, contract).unwrap(); + if refs.is_empty() { let bytecode = artifacts .bytecode_named(file, contract) .unwrap_or_else(|e| panic!("{file}.sol:{contract}: {e}")); assert!(!bytecode.is_empty()); + continue; + } + linked.push((file, contract)); + let err = artifacts.bytecode_named(file, contract).unwrap_err(); + assert!( + err.to_string().contains("unresolved link references"), + "{file}.sol:{contract}: {err}" + ); + for (path, libs) in &refs { + let stem = std::path::Path::new(path) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap(); + for library in libs.as_object().unwrap().keys() { + assert!( + COVERED.contains(&(stem, library.as_str())), + "{file}.sol:{contract} links {stem}.sol:{library}, which is not covered" + ); + } } } + linked.sort_unstable(); + assert_eq!( + linked, + [ + ("BearerLinkHonkVerifier", "BearerLinkHonkVerifier"), + ("OidcGoogleHonkVerifier", "OidcGoogleHonkVerifier"), + ] + ); } } diff --git a/rust/contracts/src/bindings/circuits.rs b/rust/contracts/src/bindings/circuits.rs new file mode 100644 index 0000000..4f3afd8 --- /dev/null +++ b/rust/contracts/src/bindings/circuits.rs @@ -0,0 +1,64 @@ +//! Bindings for the bb-generated UltraHonk verifiers in +//! `solidity/contracts/circuits`: the one call a Platform Verifier makes of +//! them, and the error that says which circuit a deployed one answers for. + +/// Bindings for a bb-generated UltraHonk verifier — `BearerLinkHonkVerifier` +/// and `OidcGoogleHonkVerifier`, one interface for both. +/// +/// `verify` is the whole surface a Platform Verifier uses +/// (`IHonkVerifier` in `PlatformVerifierBase.sol`). The error is the one +/// thing a deployed verifier says about itself: it embeds its verification +/// key as code constants and exposes no getter, so `logN` from a +/// wrong-length proof is how a test tells a real verifier over the right +/// circuit from a contract that merely has code. +#[allow(unused_attributes)] +mod honk_verifier_inner { + use alloy::sol; + + sol! { + #[sol(rpc)] + interface HonkVerifier { + function verify(bytes calldata proof, bytes32[] calldata publicInputs) + external + view + returns (bool); + + /// Raised by `verify` before anything else when the proof is + /// not the length the circuit's `logN` implies. + error ProofLengthWrongWithLogN(uint256 logN, uint256 actualLength, uint256 expectedLength); + } + } +} + +pub use honk_verifier_inner::HonkVerifier; + +#[cfg(test)] +mod tests { + use alloy::sol_types::SolCall; + + use super::*; + use crate::{ + circuits::Circuit, + Artifacts, + }; + + /// `verify` is what a Platform Verifier calls; a vendored verifier + /// without that selector would be pinned and revert at the first + /// user's proof. + #[test] + fn every_circuit_verifier_answers_verify() { + let artifacts = Artifacts::embedded(); + for circuit in Circuit::ALL { + let methods = artifacts.method_identifiers(circuit.contract()).unwrap(); + let found = methods + .get(HonkVerifier::verifyCall::SIGNATURE) + .unwrap_or_else(|| panic!("{} has no verify", circuit.contract())); + assert_eq!( + *found, + alloy::hex::encode(HonkVerifier::verifyCall::SELECTOR), + "{}.verify", + circuit.contract() + ); + } + } +} diff --git a/rust/contracts/src/bindings/mod.rs b/rust/contracts/src/bindings/mod.rs index e6d8cef..244f290 100644 --- a/rust/contracts/src/bindings/mod.rs +++ b/rust/contracts/src/bindings/mod.rs @@ -2,6 +2,7 @@ //! sources in `solidity/contracts`. One module per contract directory. pub mod ceremony; +pub mod circuits; pub mod ens; pub mod factory; pub mod identity; diff --git a/rust/contracts/src/circuits.rs b/rust/contracts/src/circuits.rs new file mode 100644 index 0000000..3444e88 --- /dev/null +++ b/rust/contracts/src/circuits.rs @@ -0,0 +1,223 @@ +//! The ceremony circuits' UltraHonk verifiers: which circuit each platform +//! proves under, and a deploy that links the libraries a bb verifier needs. +//! +//! A Honk verifier is not written in this repository. bb derives it from a +//! circuit's verification key, `libid-circuits` runs bb and ships the +//! Solidity in its release tarballs, and `scripts/vendor-circuit-verifiers.sh` +//! downloads the pinned release — checked against digests committed in +//! `solidity/contracts/circuits/circuits.json` — formats it and writes it +//! under `solidity/contracts/circuits`, gitignored and vendored again before +//! every build. From there it is a contract like any other: `forge build` +//! compiles it and `scripts/vendor-artifacts.sh` embeds it, so a consumer +//! deploys it from [`Artifacts::embedded`] with no `bb`. +//! +//! bb emits `RelationsLib` and `ZKTranscriptLib` as external libraries, so a +//! verifier's creation code carries a placeholder per call site until each +//! library is deployed and its address linked in. [`deploy_honk_verifier`] +//! does all of that and returns the verifier's address, which is what a +//! [`platform_verifier::Initializer`](crate::platform_verifier::Initializer) +//! pins — by address and by the code hash it reads off the chain. + +use alloy::{ + primitives::Address, + providers::Provider, +}; + +use crate::{ + artifacts::Artifacts, + deploy::deploy_contract_from, + error::{ + Error, + Result, + }, +}; + +/// One ceremony circuit, and so one vendored Honk verifier. +/// +/// Two, not three: `oidc-google` proves the Google ID Token, and +/// `bearer-link` ties a token exchange to an identity for X and GitHub +/// alike, because their statements are the same. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Circuit { + /// The token-exchange circuit, shared by the `x/v1` and `github/v1` + /// profiles. + BearerLink, + /// The Google OIDC circuit, for `google/v1`. + OidcGoogle, +} + +impl Circuit { + /// Every circuit the launch platforms verify under. + pub const ALL: [Self; 2] = [Self::BearerLink, Self::OidcGoogle]; + + /// The circuit's directory in the `libid-circuits` release, which is + /// also its tarball's name and its key in `circuits.json`. + pub const fn name(self) -> &'static str { + match self { + Self::BearerLink => "bearer-link", + Self::OidcGoogle => "oidc-google", + } + } + + /// The vendored contract, which is also its `.sol` file and its entry + /// in [`COVERED`](crate::artifacts::COVERED). bb names every verifier + /// `HonkVerifier`; `libid-circuits` renames each so two can share a + /// project and one artifact path names one circuit. + pub const fn contract(self) -> &'static str { + match self { + Self::BearerLink => "BearerLinkHonkVerifier", + Self::OidcGoogle => "OidcGoogleHonkVerifier", + } + } +} + +/// The external libraries every bb verifier links, vendored beside it under +/// its own `.sol` file (the shape `linkReferences` names them in). +pub const LIBRARIES: [&str; 2] = ["RelationsLib", "ZKTranscriptLib"]; + +/// The `libid-circuits` release the vendored verifiers came from, read from +/// the pin `scripts/vendor-artifacts.sh` copies in as `circuits.json`. +/// +/// A Honk verifier IS its verification key, so a consumer that names a +/// deployment after its artifact — a CREATE3 name, say — wants this in the +/// name: a new circuits release is a different contract and must land at a +/// different address rather than silently replace the old one. +/// +/// Errors for an [`Artifacts::from_dir`] over a raw forge `out/`, which +/// carries no pin. +pub fn version(artifacts: &Artifacts) -> Result { + let pin: serde_json::Value = artifacts.read_json("circuits.json")?; + pin["version"] + .as_str() + .map(str::to_owned) + .ok_or_else(|| Error::Artifact { + detail: "circuits.json has no version".into(), + }) +} + +/// Deploy `circuit`'s Honk verifier with its libraries linked, and return +/// its address. +/// +/// Each library is deployed first and its address substituted into the +/// verifier's creation code, then the verifier itself; deploying the +/// placeholder would produce a contract that reverts on every proof. Three +/// transactions per circuit. The libraries are not shared between the two +/// circuits: each verifier's file carries its own copy, and forge links a +/// verifier only against the libraries of its own file. +/// +/// The address is what a Platform Verifier initializer takes as +/// `honk_verifier`; the code hash it pins beside it is read off the chain by +/// [`Initializer::call`](crate::platform_verifier::Initializer::call). +/// +/// `sender` opts into explicit nonce management (see +/// [`deploy_contract_from`]). +pub async fn deploy_honk_verifier( + provider: &P, + artifacts: &Artifacts, + circuit: Circuit, + sender: Option
, +) -> Result
{ + let contract = circuit.contract(); + let bytecode = artifacts + .linked_bytecode(provider, contract, contract, sender) + .await?; + deploy_contract_from( + provider, + bytecode, + &format!("{contract} ({} circuit)", circuit.name()), + sender, + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::artifacts::COVERED; + + /// Every verifier and every library it links is one the crate vendors, + /// under the verifier's own file — a library missing from the artifacts + /// could only deploy with its placeholder left in. + #[test] + fn every_verifier_and_its_libraries_are_covered() { + let artifacts = Artifacts::embedded(); + for circuit in Circuit::ALL { + let contract = circuit.contract(); + assert!( + COVERED.contains(&(contract, contract)), + "{contract} is not in COVERED" + ); + for library in LIBRARIES { + assert!( + COVERED.contains(&(contract, library)), + "{contract}.sol:{library} is not in COVERED" + ); + } + + // The artifact links exactly LIBRARIES, and nothing under another + // file: a verifier that linked a library the list does not name + // would deploy with a placeholder left in. + let refs = artifacts.link_references(contract, contract).unwrap(); + let mut linked: Vec<(String, String)> = refs + .iter() + .flat_map(|(path, libs)| { + let stem = std::path::Path::new(path) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_else(|| panic!("{contract}: bad library path {path}")) + .to_owned(); + libs.as_object() + .into_iter() + .flatten() + .map(move |(name, _)| (stem.clone(), name.clone())) + }) + .collect(); + linked.sort(); + let mut expected: Vec<(String, String)> = LIBRARIES + .iter() + .map(|lib| (contract.to_owned(), (*lib).to_owned())) + .collect(); + expected.sort(); + assert_eq!(linked, expected, "{contract} links something else"); + } + } + + /// The two circuits are distinct artifacts: a shared one would wire + /// both platforms to one verification key. + #[test] + fn the_two_circuits_are_different_artifacts() { + let artifacts = Artifacts::embedded(); + let [bearer, oidc] = Circuit::ALL; + assert_ne!(bearer.contract(), oidc.contract()); + assert_ne!( + artifacts + .bytecode_hex(bearer.contract(), bearer.contract()) + .unwrap(), + artifacts + .bytecode_hex(oidc.contract(), oidc.contract()) + .unwrap() + ); + } + + /// The enum and the pin name the same circuits under the same + /// contracts; the pin is what the vendor script follows, the enum what + /// a consumer deploys by. + #[test] + fn the_enum_matches_the_pin() { + let artifacts = Artifacts::embedded(); + let pin: serde_json::Value = artifacts.read_json("circuits.json").unwrap(); + let circuits = pin["circuits"].as_object().expect("circuits object"); + assert_eq!(circuits.len(), Circuit::ALL.len()); + for circuit in Circuit::ALL { + let entry = &circuits[circuit.name()]; + assert_eq!(entry["contract"].as_str(), Some(circuit.contract())); + let digest = entry["sha256"].as_str().expect("sha256 string"); + assert_eq!(digest.len(), 64, "{}: not a sha256", circuit.name()); + } + let version = version(&artifacts).unwrap(); + assert!( + version.split('.').count() == 3, + "circuits.json version '{version}' is not major.minor.patch" + ); + } +} diff --git a/rust/contracts/src/deploy.rs b/rust/contracts/src/deploy.rs index 5e05a26..d5ca5be 100644 --- a/rust/contracts/src/deploy.rs +++ b/rust/contracts/src/deploy.rs @@ -230,9 +230,10 @@ pub async fn upgrade_uups( /// artifacts with no `linkReferences` this behaves like /// [`Artifacts::bytecode_named`] and sends nothing. /// -/// Kept although nothing covered today links a library: the bb-generated -/// UltraHonk verifiers link `ZKTranscriptLib`, and the ones the ceremony -/// circuits release will deploy through this path when they land. +/// The bb-generated UltraHonk verifiers are what goes through here: each +/// links `RelationsLib` and `ZKTranscriptLib`, and +/// [`deploy_honk_verifier`](crate::circuits::deploy_honk_verifier) is the +/// call that links and deploys one. pub async fn load_linked_bytecode( provider: &P, artifacts: &Artifacts, diff --git a/rust/contracts/src/lib.rs b/rust/contracts/src/lib.rs index 4503d15..fcee4c7 100644 --- a/rust/contracts/src/lib.rs +++ b/rust/contracts/src/lib.rs @@ -7,8 +7,9 @@ //! consumer talks to: the ceremony verification path (`NotaryService`, //! `CeremonyProofVerifier`, the three launch Platform Verifiers it routes //! to, and `GoogleJwtRoots`, the Google signing keys the `google/v1` -//! verifier trusts), the naming system (`IdentityNames`), and the -//! deterministic factory. Kept in lockstep with the Solidity sources in +//! verifier trusts), the naming system (`IdentityNames`), the +//! deterministic factory, and the UltraHonk verifiers the Platform +//! Verifiers pin. Kept in lockstep with the Solidity sources in //! `solidity/contracts`. //! - [`artifacts`] — the compiled creation bytecode, link references, and //! method identifiers of every deployable contract, embedded at compile time @@ -26,12 +27,17 @@ //! contract serves which platform, and an `initialize` call built with the //! Honk verifier's code hash read off chain and the rules //! `PlatformVerifierBase` enforces checked first. +//! - [`circuits`] — the ceremony circuits' UltraHonk verifiers, vendored +//! from the pinned `libid-circuits` release: which circuit a platform +//! proves under, and a deploy that links the libraries a bb verifier +//! needs. //! //! Signing is the consumer's concern: every helper takes a provider you have //! already wired with a wallet. pub mod artifacts; pub mod bindings; +pub mod circuits; pub mod deploy; mod error; pub mod factory; diff --git a/rust/contracts/src/platform_verifier.rs b/rust/contracts/src/platform_verifier.rs index 701b698..5e113d5 100644 --- a/rust/contracts/src/platform_verifier.rs +++ b/rust/contracts/src/platform_verifier.rs @@ -13,10 +13,13 @@ //! [`deploy_platform_verifier`] puts the implementation behind a fresh //! ERC1967 proxy with it. //! -//! The Honk verifier itself is not this crate's to deploy: it is -//! bb-generated from a `libid-circuits` release verification key, and a -//! Platform Verifier pins whichever one governance selected, by address AND -//! by code hash. +//! The Honk verifier a Platform Verifier pins is vendored here too +//! ([`circuits`](crate::circuits)): bb-generated in `libid-circuits` from +//! the circuit's verification key, deployed with its libraries linked by +//! [`deploy_honk_verifier`](crate::circuits::deploy_honk_verifier). Which +//! circuit a platform proves under is [`PlatformVerifier::circuit`]; the +//! contract pins whichever address governance names, by address AND by +//! code hash. use alloy::{ primitives::{ @@ -34,6 +37,7 @@ use crate::{ GooglePlatformVerifier, TlsNotaryPlatformVerifier, }, + circuits::Circuit, deploy::deploy_behind_proxy, error::{ Error, @@ -92,6 +96,15 @@ impl PlatformVerifier { keccak256(self.platform().as_bytes()) } + /// The ceremony circuit this platform's proofs are made under, and so + /// which vendored Honk verifier its `honk_verifier` should be. + pub const fn circuit(self) -> Circuit { + match self { + Self::X | Self::GitHub => Circuit::BearerLink, + Self::Google => Circuit::OidcGoogle, + } + } + /// Whether the profile notarizes any session, and so whether its /// verifier holds a Notary Service. `CeremonyProfile.attestationCount` /// is two for the TLSNotary profiles and zero for Google; the @@ -394,6 +407,18 @@ mod tests { assert_eq!(PlatformVerifier::ALL.len(), libid_profiles::LAUNCH.len()); } + /// Every circuit has a platform proving under it: a vendored verifier + /// no platform pins would be dead weight in every consumer's binary. + #[test] + fn every_circuit_serves_a_platform() { + for circuit in Circuit::ALL { + assert!( + PlatformVerifier::ALL.iter().any(|v| v.circuit() == circuit), + "{circuit:?} serves no platform" + ); + } + } + /// Every verifier's contract is one the crate vendors. #[test] fn every_verifier_is_covered() { diff --git a/rust/contracts/tests/anvil.rs b/rust/contracts/tests/anvil.rs index bcc1100..7715003 100644 --- a/rust/contracts/tests/anvil.rs +++ b/rust/contracts/tests/anvil.rs @@ -439,13 +439,14 @@ async fn deploys_the_ens_resolver_with_its_constructor_arguments() { /// (e) The three Platform Verifiers through `deploy_platform_verifier`, on /// the collaborators they pin: the Notary Service (the two TLSNotary ones), -/// the JWT root list (Google), and a Honk verifier — stood in for by any -/// contract with code, because `initialize` pins a code hash and never -/// calls `verify`. Each comes back initialized as the views say, registers -/// with the Proof Verifier, and the ceilings the crate restates are the -/// contract's. Then the rules: an initializer the wrapper refuses is one -/// the contract refuses too, and a Honk verifier with no code is caught -/// before any transaction. +/// the JWT root list (Google), and the real Honk verifier for each one's +/// circuit, deployed with its libraries linked. The code hash the +/// initializer computes is the one the chain reports for that verifier, +/// and the one the contract records. Each comes back initialized as the +/// views say, registers with the Proof Verifier, and the ceilings the crate +/// restates are the contract's. Then the rules: an initializer the wrapper +/// refuses is one the contract refuses too, and a Honk verifier with no +/// code is caught before any transaction. #[tokio::test] async fn deploys_and_initializes_every_platform_verifier() { use alloy::{ @@ -458,6 +459,10 @@ async fn deploys_and_initializes_every_platform_verifier() { GooglePlatformVerifier, TlsNotaryPlatformVerifier, }, + circuits::{ + deploy_honk_verifier, + Circuit, + }, platform_verifier::{ codehash_at, deploy_platform_verifier, @@ -511,27 +516,41 @@ async fn deploys_and_initializes_every_platform_verifier() { ) .await .unwrap(); - let honk = deploy_contract( - &provider, - artifacts.bytecode("WTIA9").unwrap(), - "stand-in Honk verifier", - ) - .await - .unwrap(); + // The real verifiers, one per circuit, libraries linked. The hash a + // Platform Verifier pins is read off the chain, never computed from the + // vendored bytes: it is what the chain holds for the artifact. + let bearer_link = + deploy_honk_verifier(&provider, &artifacts, Circuit::BearerLink, None) + .await + .unwrap(); + let oidc_google = + deploy_honk_verifier(&provider, &artifacts, Circuit::OidcGoogle, None) + .await + .unwrap(); + let honk_at = |circuit: Circuit| match circuit { + Circuit::BearerLink => bearer_link, + Circuit::OidcGoogle => oidc_google, + }; + let honk = bearer_link; let honk_codehash = codehash_at(&provider, honk).await.unwrap(); assert_ne!(honk_codehash, keccak256([])); + assert_ne!( + honk_codehash, + codehash_at(&provider, oidc_google).await.unwrap(), + "one artifact for two circuits" + ); let tls = TlsNotaryRoots { owner: deployer, notary_service: notary_proxy, - honk_verifier: honk, + honk_verifier: bearer_link, proof_lifetime: libid_profiles::PROOF_LIFETIME_SECONDS_X, max_future_attestation_skew: libid_profiles::MAX_FUTURE_ATTESTATION_SKEW_SECONDS, future_observation_allowance: 300, }; let google = GoogleRoots { owner: deployer, - honk_verifier: honk, + honk_verifier: oidc_google, future_observation_allowance: 7200, jwt_roots: roots_proxy, }; @@ -543,6 +562,21 @@ async fn deploys_and_initializes_every_platform_verifier() { Initializer::Google(google), ] { let verifier = init.verifier(); + // The initializer pins its circuit's verifier, and computes the hash + // the chain reports for it — `EXTCODEHASH`, `keccak256` of the + // runtime code — which is what `initialize` then checks. + let honk = honk_at(verifier.circuit()); + assert_eq!( + init.honk_verifier(), + honk, + "{verifier:?} pins the wrong circuit" + ); + let honk_codehash = keccak256(provider.get_code_at(honk).await.unwrap()); + assert_eq!( + init.call(&provider).await.unwrap().honk_verifier_codehash(), + honk_codehash, + "{verifier:?}: the initializer computed a hash the chain does not hold" + ); let proxy = deploy_platform_verifier(&provider, &artifacts, &init, None) .await .unwrap_or_else(|e| panic!("{verifier:?}: {e}")); @@ -706,3 +740,73 @@ async fn deploys_and_initializes_every_platform_verifier() { "{err}" ); } + +/// (f) The two Honk verifiers through `deploy_honk_verifier`: each lands +/// with its libraries linked, under EIP-170, and answers for its OWN +/// circuit. A bb verifier has no getter for its verification key; the one +/// thing it says about itself is the `logN` a wrong-length proof comes back +/// with. That separates a real verifier from a contract that merely has +/// code, and the two circuits from each other — the check that would catch +/// a release whose tarballs were swapped, or a vendor run that wrote one +/// circuit's verifier under the other's name. +#[tokio::test] +async fn deploys_the_linked_honk_verifiers_over_their_own_circuits() { + use alloy::{ + primitives::Bytes, + sol_types::SolError, + }; + use libid_contracts::{ + bindings::circuits::HonkVerifier, + circuits::{ + deploy_honk_verifier, + version, + Circuit, + }, + platform_verifier::codehash_at, + }; + + let provider = test_provider(); + let artifacts = Artifacts::embedded(); + assert!(!version(&artifacts).unwrap().is_empty()); + + let mut log_n = Vec::new(); + for circuit in Circuit::ALL { + let address = deploy_honk_verifier(&provider, &artifacts, circuit, None) + .await + .unwrap_or_else(|e| panic!("{circuit:?}: {e}")); + let code = provider.get_code_at(address).await.unwrap(); + assert!(!code.is_empty(), "{circuit:?} has no code at {address:#x}"); + // anvil runs the default limit, so deploying at all is the EIP-170 + // proof; the number is asserted so a release that grows past it + // says so here rather than in a failed deploy. + assert!( + code.len() <= 24_576, + "{circuit:?} is {} bytes, over EIP-170", + code.len() + ); + assert_eq!( + codehash_at(&provider, address).await.unwrap(), + keccak256(&code) + ); + + let err = HonkVerifier::new(address, &provider) + .verify(Bytes::new(), Vec::new()) + .call() + .await + .expect_err("an empty proof is the wrong length"); + let data = err + .as_revert_data() + .expect("the verifier reverted with data"); + let decoded = HonkVerifier::ProofLengthWrongWithLogN::abi_decode(&data) + .expect("only a Honk verifier raises ProofLengthWrongWithLogN"); + assert!( + decoded.logN > U256::ZERO, + "{circuit:?} reports no circuit size" + ); + log_n.push(decoded.logN); + } + assert_ne!( + log_n[0], log_n[1], + "both platforms would verify under one circuit" + ); +} diff --git a/scripts/vendor-artifacts.sh b/scripts/vendor-artifacts.sh index c5bf008..3a76e4c 100755 --- a/scripts/vendor-artifacts.sh +++ b/scripts/vendor-artifacts.sh @@ -6,8 +6,10 @@ # rust/contracts/artifacts/.sol/.json, pruned to the fields the # crate reads: bytecode.object, bytecode.linkReferences, methodIdentifiers. # Libraries referenced through linkReferences are followed transitively and -# vendored too (none of the covered contracts links one today; the Honk -# verifiers the ceremony circuits will bring do). +# vendored too: the two Honk verifiers link RelationsLib and ZKTranscriptLib, +# and both are listed below as well so the list and the crate's COVERED agree +# line for line. The circuits pin rides along as circuits.json, so the crate +# can say which libid-circuits release its verifiers came from. # # The result is NOT committed: rust/contracts/artifacts is gitignored and # regenerated on demand. Run this before any cargo command in rust/ — the @@ -24,6 +26,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" OUT="$REPO_ROOT/solidity/out" DEST="$REPO_ROOT/rust/contracts/artifacts" +CIRCUITS_PIN="$REPO_ROOT/solidity/contracts/circuits/circuits.json" # ":" — the artifact lives at out/.sol/.json. # Keep in sync with the covered-contract list in rust/contracts/src/artifacts.rs. @@ -33,11 +36,20 @@ ARTIFACTS=( "CeremonyProofVerifier:CeremonyProofVerifier" "ERC1967Proxy:ERC1967Proxy" "GoogleJwtRoots:GoogleJwtRoots" - # ceremony: the launch Platform Verifiers (one per profile; the UltraHonk - # verifier each pins comes from the circuits release, not from here) + # ceremony: the launch Platform Verifiers (one per profile) "XPlatformVerifier:XPlatformVerifier" "GitHubPlatformVerifier:GitHubPlatformVerifier" "GooglePlatformVerifier:GooglePlatformVerifier" + # circuits: the UltraHonk verifiers the Platform Verifiers pin, vendored + # from the libid-circuits release by scripts/vendor-circuit-verifiers.sh, + # each with the two libraries it links (bb emits them as external + # libraries, so they are deployed contracts the verifier is linked to) + "BearerLinkHonkVerifier:BearerLinkHonkVerifier" + "BearerLinkHonkVerifier:RelationsLib" + "BearerLinkHonkVerifier:ZKTranscriptLib" + "OidcGoogleHonkVerifier:OidcGoogleHonkVerifier" + "OidcGoogleHonkVerifier:RelationsLib" + "OidcGoogleHonkVerifier:ZKTranscriptLib" # identity "IdentityNames:IdentityNames" # ens (deployed once per network, not CREATE3-canonical; embedded so a @@ -55,6 +67,13 @@ fi command -v jq >/dev/null || { echo "jq is required" >&2; exit 1; } +# The Honk verifiers are vendored, not committed, and a build without them +# fails in the test that imports them; name the missing step instead. +while read -r contract; do + [[ -f "$REPO_ROOT/solidity/contracts/circuits/$contract.sol" ]] || + { echo "no $contract.sol under solidity/contracts/circuits; run scripts/vendor-circuit-verifiers.sh first" >&2; exit 1; } +done < <(jq -r '.circuits[].contract' "$CIRCUITS_PIN") + echo "==> forge build" (cd "$REPO_ROOT/solidity" && forge build) @@ -106,6 +125,8 @@ while [[ ${#queue[@]} -gt 0 ]]; do | "\($p):\(.)"' "$src") done +cp "$CIRCUITS_PIN" "$STAGE/circuits.json" + rm -rf "$DEST" mkdir -p "$(dirname "$DEST")" cp -R "$STAGE" "$DEST" diff --git a/scripts/vendor-circuit-verifiers.sh b/scripts/vendor-circuit-verifiers.sh new file mode 100755 index 0000000..9d65dbf --- /dev/null +++ b/scripts/vendor-circuit-verifiers.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# Vendor the ceremony circuits' UltraHonk verifiers from the pinned +# libid-circuits release. +# +# A Honk verifier is not written here: bb derives it from a circuit's +# verification key, and libid-circuits runs bb and ships the result in each +# circuit's release tarball beside the vk. This script downloads those +# tarballs, checks them, formats the Solidity under solidity/foundry.toml and +# writes it to solidity/contracts/circuits/.sol: +# +# bearer-link -> BearerLinkHonkVerifier.sol (the x and github profiles) +# oidc-google -> OidcGoogleHonkVerifier.sol (the google profile) +# +# THE PIN is solidity/contracts/circuits/circuits.json: the release version +# and, per circuit, the contract name and the tarball's sha256. The digests +# are committed literals, so a download is checked against what this +# repository says, never against a manifest that came down with it. The +# release's manifest.json is fetched too, but only to be held to the pin — +# it must name the same version and the same tarball digests — and then to +# check every file inside a tarball the pin has already vouched for. +# +# What ships is bb's output plus exactly two rewrites libid-circuits makes +# (`assembly ("memory-safe")` on every assembly block, for via_ir, and the +# rename off bb's fixed `HonkVerifier`); `forge fmt` is deliberately left to +# the consumer, because libid-circuits carries no Foundry toolchain. So the +# written file is fmt(shipped) plus the banner below. +# +# The sources are NOT committed: they are gitignored like the forge +# artifacts and the npm ABIs, because they are another repository's release +# asset and the pin already says which bytes they must be. CI's forge-build +# action runs this script before every `forge build` — tests, dry-runs and +# publishes included — and a clone runs it once before its first build. So +# every build starts from a download the pin has just checked, and there is +# no committed copy for a hand edit or a stale vendor to live in. +# +# Moving the pin: download the new release's tarballs, read their sha256 +# with `shasum -a 256` (compare against the release page, not against a +# manifest fetched by a script), write the version and the digests into +# circuits.json, run this script, commit circuits.json. +# +# Usage: +# scripts/vendor-circuit-verifiers.sh # write the verifiers from the pin +# +# Requires curl, jq, tar, forge and shasum or sha256sum. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SOLIDITY="$REPO_ROOT/solidity" +DEST_REL="contracts/circuits" +DEST="$SOLIDITY/$DEST_REL" +PIN="$DEST/circuits.json" +RELEASES="https://github.com/libid-org/libid-circuits/releases/download" + +if [[ $# -gt 0 ]]; then + echo "unknown argument: $1" >&2 + exit 2 +fi + +for tool in curl jq tar forge; do + command -v "$tool" >/dev/null || { echo "$tool is required" >&2; exit 1; } +done +sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi +} + +[[ -f "$PIN" ]] || { echo "no pin at $PIN" >&2; exit 1; } +VERSION="$(jq -r '.version' "$PIN")" +[[ -n "$VERSION" && "$VERSION" != "null" ]] || { echo "no version in $PIN" >&2; exit 1; } +TAG="v$VERSION" +echo "==> libid-circuits $TAG" + +WORK="$(mktemp -d)" +STAGE="$(mktemp -d)" +trap 'rm -rf "$WORK" "$STAGE"' EXIT + +# The release's own manifest: held to the pin, then used for the per-file +# check inside each tarball. +curl -fsSL -o "$WORK/manifest.json" "$RELEASES/$TAG/manifest.json" +released="$(jq -r '.version' "$WORK/manifest.json")" +[[ "$released" == "$VERSION" ]] || + { echo "the $TAG manifest declares version '$released', the pin says $VERSION" >&2; exit 1; } + +while IFS=$'\t' read -r circuit contract want; do + tarball="libid-circuits-$VERSION-$circuit.tar.gz" + curl -fsSL -o "$WORK/$tarball" "$RELEASES/$TAG/$tarball" + + # The integrity check: the committed digest, nothing downloaded. + got="$(sha256 "$WORK/$tarball")" + [[ "$got" == "$want" ]] || + { echo "$tarball: sha256 $got, the pin says $want" >&2; exit 1; } + declared="$(jq -r --arg t "$tarball" '.tarballs[$t].sha256' "$WORK/manifest.json")" + [[ "$declared" == "$want" ]] || + { echo "$tarball: the $TAG manifest declares sha256 '$declared', the pin says $want" >&2; exit 1; } + + mkdir -p "$WORK/$circuit" + tar xzf "$WORK/$tarball" -C "$WORK/$circuit" + # Every file the manifest lists is in the tarball at the digest it + # names; the tarball is trusted, so this catches a release that was + # assembled wrong, not an attacker. + while IFS=$'\t' read -r name file_want; do + [[ -f "$WORK/$circuit/$name" ]] || + { echo "$tarball: the manifest lists $name, the tarball lacks it" >&2; exit 1; } + file_got="$(sha256 "$WORK/$circuit/$name")" + [[ "$file_got" == "$file_want" ]] || + { echo "$tarball: $name sha256 $file_got, the manifest says $file_want" >&2; exit 1; } + done < <(jq -r --arg t "$tarball" '.tarballs[$t].files | to_entries[] | "\(.key)\t\(.value)"' "$WORK/manifest.json") + + src="$WORK/$circuit/$contract.sol" + [[ -f "$src" ]] || { echo "$tarball: no $contract.sol inside" >&2; exit 1; } + # The interchange format, as libid-circuits' scripts/gen-verifier.sh + # promises it: one concrete contract under the pinned name, every + # assembly block annotated. A file that breaks either would compile to + # something the crate looks up under the wrong name, or not at all. + concrete="$(grep -c '^contract .* is BaseZKHonkVerifier' "$src" || true)" + [[ "$concrete" == 1 ]] && grep -q "^contract $contract is BaseZKHonkVerifier" "$src" || + { echo "$contract.sol: expected exactly one 'contract $contract is BaseZKHonkVerifier', found $concrete" >&2; exit 1; } + if grep -qE 'assembly[[:space:]]*\{' "$src"; then + echo "$contract.sol: an assembly block is not annotated memory-safe" >&2 + exit 1 + fi + + # The banner goes after bb's license header, before the first pragma. + # Then forge fmt under this project's foundry.toml, which is the one + # step libid-circuits leaves to the consumer. + awk -v banner="// Vendored from libid-circuits $TAG ($tarball) by scripts/vendor-circuit-verifiers.sh. Do not edit.\n// The pin is $DEST_REL/circuits.json; \`forge fmt\` is the only change to what shipped." ' + !done && /^pragma / { print banner; done = 1 } + { print } + ' "$src" | (cd "$SOLIDITY" && forge fmt --raw -) > "$STAGE/$contract.sol" + echo "==> $circuit -> $DEST_REL/$contract.sol" +done < <(jq -r '.circuits | to_entries[] | "\(.key)\t\(.value.contract)\t\(.value.sha256)"' "$PIN") + +cp "$STAGE"/*.sol "$DEST/" diff --git a/solidity/contracts/circuits/circuits.json b/solidity/contracts/circuits/circuits.json new file mode 100644 index 0000000..b27b2a5 --- /dev/null +++ b/solidity/contracts/circuits/circuits.json @@ -0,0 +1,13 @@ +{ + "version": "0.4.0", + "circuits": { + "bearer-link": { + "contract": "BearerLinkHonkVerifier", + "sha256": "1fe9789337c2b1ce5250fbde11f3ff3f9969972fe60dce12ba2b40f455a894c2" + }, + "oidc-google": { + "contract": "OidcGoogleHonkVerifier", + "sha256": "633b48d340c23933e0d6fcfa0267902466d87db656a8bda62d1b5ee3213c9a42" + } + } +} diff --git a/solidity/contracts/circuits/test/HonkVerifiers.t.sol b/solidity/contracts/circuits/test/HonkVerifiers.t.sol new file mode 100644 index 0000000..7d3afc1 --- /dev/null +++ b/solidity/contracts/circuits/test/HonkVerifiers.t.sol @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {BearerLinkHonkVerifier} from "../BearerLinkHonkVerifier.sol"; +import {OidcGoogleHonkVerifier} from "../OidcGoogleHonkVerifier.sol"; +import {GooglePlatformVerifier, IGoogleJwtRoots} from "../../ceremony/GooglePlatformVerifier.sol"; +import {INotaryService} from "../../ceremony/INotaryService.sol"; +import {IHonkVerifier} from "../../ceremony/PlatformVerifierBase.sol"; +import {XPlatformVerifier} from "../../ceremony/XPlatformVerifier.sol"; + +/// @notice The vendored Honk verifiers are what the Platform Verifiers pin. +/// +/// @dev A bb verifier embeds its verification key as code and exposes no +/// getter, so nothing here can ask it WHICH circuit it answers for. What +/// it does say is the `logN` a wrong-length proof comes back with, and +/// the two circuits differ in it: that is the check that would catch a +/// release whose tarballs were swapped, or a vendor run that wrote one +/// circuit's verifier under the other's name. +contract HonkVerifiersTest is Test { + /// `Errors.ProofLengthWrongWithLogN` from the generated sources, which + /// only a Honk verifier raises. + error ProofLengthWrongWithLogN(uint256 logN, uint256 actualLength, uint256 expectedLength); + + address constant OWNER = address(0xA11CE); + uint64 constant LIFETIME = 3600; + uint64 constant SKEW = 300; + + IHonkVerifier bearerLink; + IHonkVerifier oidcGoogle; + + function setUp() public { + bearerLink = IHonkVerifier(address(new BearerLinkHonkVerifier())); + oidcGoogle = IHonkVerifier(address(new OidcGoogleHonkVerifier())); + } + + /// The `logN` a verifier reports for its own circuit, read by handing + /// it a proof of the wrong length. + function _logN(IHonkVerifier verifier) private view returns (uint256) { + try verifier.verify("", new bytes32[](0)) returns (bool) { + revert("an empty proof verified"); + } catch (bytes memory reason) { + // The first four bytes of revert data ARE the selector; the rest + // is decoded below. + // forge-lint: disable-next-line(unsafe-typecast) + assertEq(bytes4(reason), ProofLengthWrongWithLogN.selector, "not a Honk verifier"); + bytes memory args = new bytes(reason.length - 4); + for (uint256 i = 0; i < args.length; ++i) { + args[i] = reason[i + 4]; + } + (uint256 logN,,) = abi.decode(args, (uint256, uint256, uint256)); + return logN; + } + } + + /// EIP-170: forge deploys under the default limit, and the sizes are + /// asserted rather than merely survived so a release that grows past it + /// names the number. + function test_verifiersFitUnderTheCodeSizeLimit() public view { + assertLe(address(bearerLink).code.length, 24_576, "bearer-link over EIP-170"); + assertLe(address(oidcGoogle).code.length, 24_576, "oidc-google over EIP-170"); + } + + function test_eachVerifierAnswersForItsOwnCircuit() public view { + uint256 bearer = _logN(bearerLink); + uint256 oidc = _logN(oidcGoogle); + assertGt(bearer, 0, "bearer-link reports no circuit size"); + assertGt(oidc, 0, "oidc-google reports no circuit size"); + assertNotEq(bearer, oidc, "both platforms would verify under one circuit"); + } + + /// A Platform Verifier pins its circuit's verifier by address and by the + /// code hash the chain reports for it — the real artifact, not a stub. + function test_platformVerifiersPinTheRealArtifacts() public { + XPlatformVerifier xImpl = new XPlatformVerifier(); + XPlatformVerifier x = XPlatformVerifier( + address( + new ERC1967Proxy( + address(xImpl), + abi.encodeCall( + XPlatformVerifier.initialize, + ( + OWNER, + INotaryService(address(0x0707)), + bearerLink, + address(bearerLink).codehash, + LIFETIME, + SKEW, + SKEW + ) + ) + ) + ) + ); + assertEq(address(x.honkVerifier()), address(bearerLink)); + assertEq(x.honkVerifierCodehash(), address(bearerLink).codehash); + + GooglePlatformVerifier gImpl = new GooglePlatformVerifier(); + GooglePlatformVerifier g = GooglePlatformVerifier( + address( + new ERC1967Proxy( + address(gImpl), + abi.encodeCall( + GooglePlatformVerifier.initialize, + ( + OWNER, + INotaryService(address(0)), + oidcGoogle, + address(oidcGoogle).codehash, + SKEW, + IGoogleJwtRoots(address(0x2007)) + ) + ) + ) + ) + ); + assertEq(address(g.honkVerifier()), address(oidcGoogle)); + assertEq(g.honkVerifierCodehash(), address(oidcGoogle).codehash); + assertNotEq(x.honkVerifierCodehash(), g.honkVerifierCodehash(), "one artifact for two circuits"); + } +} diff --git a/solidity/foundry.toml b/solidity/foundry.toml index ca2a8d8..0d3e71c 100644 --- a/solidity/foundry.toml +++ b/solidity/foundry.toml @@ -48,6 +48,12 @@ fs_permissions = [ # written carries a `// forge-lint: disable-...` comment with its reason # instead, so the lint stays on for the next one. [lint] +# The vendored Honk verifiers are bb's output, not ours: the notes they draw +# (naming, unchecked casts) would be fixed in Aztec's generator, and the +# files are not committed: `scripts/vendor-circuit-verifiers.sh` writes them +# before every build, so a fix made here does not outlive the next run. They +# still compile under `-D` like everything else; only the linter skips them. +ignore = ["contracts/circuits/*HonkVerifier.sol"] # Foundry's default stops at `low`, which leaves the style lints off: naming, # named struct fields, unused and unaliased imports. They are on. `gas` and # `code-size` stay off because their advice -- assembly keccak, a custom error