diff --git a/rust/Cargo.lock b/rust/Cargo.lock index a582613..d230deb 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2562,6 +2562,7 @@ version = "0.10.0" dependencies = [ "alloy", "include_dir", + "libid-profiles", "serde", "serde_json", "thiserror", diff --git a/rust/contracts/Cargo.toml b/rust/contracts/Cargo.toml index 689c53d..ea24cd0 100644 --- a/rust/contracts/Cargo.toml +++ b/rust/contracts/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" rust-version = "1.94.1" license = "MIT OR Apache-2.0" repository = "https://github.com/libid-org/libid-contracts" -description = "Typed alloy bindings, embedded forge artifacts, and deploy/upgrade helpers for the libid identity stack (Notary Service, ceremony proof verifier, Google JWT roots, identity names, deterministic factory)." +description = "Typed alloy bindings, embedded forge artifacts, and deploy/upgrade helpers for the libid identity stack (Notary Service, ceremony proof verifier, Platform Verifiers, Google JWT roots, identity names, deterministic factory)." keywords = ["ethereum", "alloy", "contracts", "deployment"] categories = ["cryptography::cryptocurrencies"] # The vendored artifacts are part of the crate: consumers deploy from the @@ -30,4 +30,8 @@ tokio = { version = "1", features = ["time"] } [dev-dependencies] alloy = { version = "1", features = ["node-bindings", "signer-local"] } +# Tests only, path only: the crate restates which profiles notarize so it +# publishes without a dependency on the sibling crate, and this is what pins +# the restatement to the generated table. +libid-profiles = { path = "../profiles" } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/rust/contracts/README.md b/rust/contracts/README.md index 895c189..9687ba2 100644 --- a/rust/contracts/README.md +++ b/rust/contracts/README.md @@ -2,10 +2,10 @@ 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`, and -`GoogleJwtRoots`, the signing keys the `google/v1` verifier trusts), the -naming system (`IdentityNames`), and the deterministic deployment factory -(`LibidFactory`). +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`). 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 @@ -74,6 +74,65 @@ async fn main() -> Result<(), Box> { } ``` +## Example: deploy a Platform 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. + +```rust,no_run +use alloy::{primitives::Address, providers::ProviderBuilder}; +use libid_contracts::{ + bindings::ceremony::{CeremonyProofVerifier, XPlatformVerifier}, + platform_verifier::{deploy_platform_verifier, Initializer, TlsNotaryRoots}, + Artifacts, +}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let provider = ProviderBuilder::new() + .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!(); + + // `x/v1`: two notarized sessions, so a Notary Service is required. + // `Initializer::Google` takes `GoogleRoots` instead — no Notary Service + // (the profile notarizes nothing) and the JWT root list in its place. + let verifier = deploy_platform_verifier( + &provider, + &artifacts, + &Initializer::X(TlsNotaryRoots { + owner, + notary_service: notary, + honk_verifier, + proof_lifetime: 3600, + max_future_attestation_skew: 300, + future_observation_allowance: 300, + }), + None, + ) + .await?; + + // Register it for the platform's launch slot. + let platform_id = XPlatformVerifier::new(verifier, &provider).platformId().call().await?; + CeremonyProofVerifier::new(proof_verifier, &provider) + .setVerifier(platform_id, 1, verifier) + .send() + .await? + .get_receipt() + .await?; + Ok(()) +} +``` + +For a factory (CREATE3) deploy, `Initializer::call` returns the typed +`initialize` call; `abi_encode` it into the proxy's init data. + Other entry points: - `deploy::upgrade_uups` — deploy a fresh implementation and @@ -85,6 +144,8 @@ Other entry points: and links external libraries before returning the creation bytecode. Nothing covered today links one; the UltraHonk verifiers the ceremony circuits bring will. +- `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 `methodIdentifiers`. diff --git a/rust/contracts/src/artifacts.rs b/rust/contracts/src/artifacts.rs index 51feca5..264813d 100644 --- a/rust/contracts/src/artifacts.rs +++ b/rust/contracts/src/artifacts.rs @@ -39,6 +39,11 @@ 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) + ("XPlatformVerifier", "XPlatformVerifier"), + ("GitHubPlatformVerifier", "GitHubPlatformVerifier"), + ("GooglePlatformVerifier", "GooglePlatformVerifier"), // identity ("IdentityNames", "IdentityNames"), // ens (deployed once per network, not CREATE3-canonical) diff --git a/rust/contracts/src/bindings/ceremony.rs b/rust/contracts/src/bindings/ceremony.rs index b9a4209..b25d9a7 100644 --- a/rust/contracts/src/bindings/ceremony.rs +++ b/rust/contracts/src/bindings/ceremony.rs @@ -1,7 +1,16 @@ //! Bindings for the ceremony verification path (`solidity/contracts/ceremony/`): //! the Notary Service every notarized session is authenticated through, the //! Proof Verifier that routes a claim to the Platform Verifier registered for -//! its version, and the Google JWT root list the `google/v1` verifier reads. +//! its version, the three launch Platform Verifiers it routes to, and the +//! Google JWT root list the `google/v1` verifier reads. +//! +//! `verify` is on none of the Platform Verifier interfaces, for the reason it +//! is on neither `NotaryService` nor `CeremonyProofVerifier`: a contract on +//! the route calls it with the fee attached, and the decoded claim comes back +//! to that contract. What an operator does from here is initialize, rotate +//! the trust roots and move the governance parameters — see +//! [`platform_verifier`](crate::platform_verifier) for the initializer that +//! checks the rules first. /// Bindings for `ceremony/NotaryService.sol` (which implements /// `INotaryService`). @@ -171,3 +180,273 @@ mod google_jwt_roots_inner { } pub use google_jwt_roots_inner::GoogleJwtRoots; + +/// Bindings for the two TLSNotary Platform Verifiers, `ceremony/XPlatformVerifier.sol` +/// and `ceremony/GitHubPlatformVerifier.sol`. One interface serves both: they +/// differ in the revealed layout they accept, not in the surface an operator +/// touches, and each answers for itself through `platformId()`. The same +/// module is exported as [`XPlatformVerifier`] and [`GitHubPlatformVerifier`], +/// so a consumer names the contract it means. +/// +/// The initializer takes what `PlatformVerifierBase.__PlatformVerifierBase_init` +/// takes. `notary_` is required here (nonzero): a TLSNotary profile +/// authenticates two attestations through it, and the base refuses a zero +/// address for a profile whose Attestation Count is nonzero +/// (`WrongNotaryForProfile`). `honkVerifierCodehash_` must equal +/// `address(honkVerifier_).codehash` and be neither zero nor `keccak256("")` +/// (`WrongVerifierArtifact`); the three parameters are capped by the `MAX_*` +/// constants (`ParameterTooLarge`). +#[allow(clippy::too_many_arguments, unused_attributes)] +mod tls_notary_platform_verifier_inner { + use alloy::sol; + + sol! { + #[sol(rpc)] + interface TlsNotaryPlatformVerifier { + /// Derives so the built call can be compared and printed by the + /// initializer that assembles it. + #[derive(Debug, PartialEq, Eq)] + function initialize( + address owner_, + address notary_, + address honkVerifier_, + bytes32 honkVerifierCodehash_, + uint64 proofLifetime_, + uint64 maxFutureAttestationSkew_, + uint64 futureObservationAllowance_ + ) external; + + /// The identity platform this verifier serves: `keccak256` of the + /// platform's bare name. The Proof Verifier refuses to register it + /// under another platform. + function platformId() external view returns (bytes32); + /// What a submission must carry: one Notary Fee per attestation + /// the profile requires — two, for a TLSNotary profile. + function quote() external view returns (uint256); + + function notaryService() external view returns (address); + function honkVerifier() external view returns (address); + /// The code hash of the artifact wired: the only handle on WHICH + /// circuit a deployed bb verifier answers for. + function honkVerifierCodehash() external view returns (bytes32); + function protocolParameters() + external + view + returns (uint64 proofLifetime, uint64 maxFutureAttestationSkew, uint64 futureObservationAllowance); + /// Rotate the trust roots. The same rules as `initialize`: the + /// code hash names the artifact, and the call fails if the + /// address does not hold it. + function setTrustRoots(address notary_, address honkVerifier_, bytes32 honkVerifierCodehash_) external; + function setProtocolParameters( + uint64 proofLifetime_, + uint64 maxFutureAttestationSkew_, + uint64 futureObservationAllowance_ + ) external; + + /// Ceilings on the three parameters, in seconds. + function MAX_PROOF_LIFETIME() external view returns (uint64); + function MAX_FUTURE_ATTESTATION_SKEW() external view returns (uint64); + function MAX_FUTURE_OBSERVATION_ALLOWANCE() external view returns (uint64); + + function owner() external view returns (address); + function pendingOwner() external view returns (address); + function transferOwnership(address newOwner) external; + function acceptOwnership() external; + + event TrustRootsChanged(address notary, address honkVerifier, bytes32 honkVerifierCodehash); + event ProtocolParametersChanged( + uint64 proofLifetime, uint64 maxFutureAttestationSkew, uint64 futureObservationAllowance + ); + + /// A profile that verifies no attestation holds a Notary Service, + /// or one that verifies some holds none. + error WrongNotaryForProfile(bytes32 platformId, address notary); + error ParameterTooLarge(uint64 provided, uint64 limit); + error ZeroAddress(); + /// The verifier at that address is not the artifact named. + error WrongVerifierArtifact(bytes32 expected, bytes32 found); + } + } +} + +pub use tls_notary_platform_verifier_inner::{ + TlsNotaryPlatformVerifier, + TlsNotaryPlatformVerifier as GitHubPlatformVerifier, + TlsNotaryPlatformVerifier as XPlatformVerifier, +}; + +/// Bindings for `ceremony/GooglePlatformVerifier.sol` — the `google/v1` +/// profile. +/// +/// A different shape from the other two: no notarized session, so no Notary +/// Service, no fee, no proof lifetime and no attestation skew. The evidence +/// is a signed ID Token whose `exp` is the whole validity ceiling, and the +/// signing keys it trusts are read from `GoogleJwtRoots`. +/// +/// `notary_` must therefore be the ZERO address: the base refuses a Notary +/// Service for a profile whose Attestation Count is zero +/// (`WrongNotaryForProfile`), because `notaryService()` would otherwise +/// report a collaborator nothing on this path calls. `jwtRoots_` must be +/// nonzero (`ZeroAddress`). The code hash and the allowance follow the same +/// rules as the TLSNotary verifiers'; the lifetime and skew read back as +/// zero. +#[allow(clippy::too_many_arguments, unused_attributes)] +mod google_platform_verifier_inner { + use alloy::sol; + + sol! { + #[sol(rpc)] + interface GooglePlatformVerifier { + #[derive(Debug, PartialEq, Eq)] + function initialize( + address owner_, + address notary_, + address honkVerifier_, + bytes32 honkVerifierCodehash_, + uint64 futureObservationAllowance_, + address jwtRoots_ + ) external; + + /// `keccak256("google")`. + function platformId() external view returns (bytes32); + /// Always zero: the profile verifies nothing that charges, and + /// `verify` refuses any value sent. + function quote() external view returns (uint256); + + /// The root list the trusted moduli are read through. + function jwtRoots() external view returns (address); + function setJwtRoots(address roots) external; + + function notaryService() external view returns (address); + function honkVerifier() external view returns (address); + function honkVerifierCodehash() external view returns (bytes32); + function protocolParameters() + external + view + returns (uint64 proofLifetime, uint64 maxFutureAttestationSkew, uint64 futureObservationAllowance); + function setTrustRoots(address notary_, address honkVerifier_, bytes32 honkVerifierCodehash_) external; + function setProtocolParameters( + uint64 proofLifetime_, + uint64 maxFutureAttestationSkew_, + uint64 futureObservationAllowance_ + ) external; + + function MAX_PROOF_LIFETIME() external view returns (uint64); + function MAX_FUTURE_ATTESTATION_SKEW() external view returns (uint64); + function MAX_FUTURE_OBSERVATION_ALLOWANCE() external view returns (uint64); + + function owner() external view returns (address); + function pendingOwner() external view returns (address); + function transferOwnership(address newOwner) external; + function acceptOwnership() external; + + event JwtRootsChanged(address roots); + event TrustRootsChanged(address notary, address honkVerifier, bytes32 honkVerifierCodehash); + event ProtocolParametersChanged( + uint64 proofLifetime, uint64 maxFutureAttestationSkew, uint64 futureObservationAllowance + ); + + error WrongNotaryForProfile(bytes32 platformId, address notary); + error ParameterTooLarge(uint64 provided, uint64 limit); + error ZeroAddress(); + error WrongVerifierArtifact(bytes32 expected, bytes32 found); + } + } +} + +pub use google_platform_verifier_inner::GooglePlatformVerifier; + +#[cfg(test)] +mod tests { + use alloy::sol_types::SolCall; + + use super::*; + use crate::Artifacts; + + /// A Platform Verifier binding and its vendored artifact come from one + /// tree, so every bound selector is one the compiled contract answers. + /// The initializer is the one that matters: an `initialize` the proxy's + /// implementation has no function for reaches its fallback, and the + /// proxy is left uninitialized for anyone to claim. + #[test] + fn every_bound_platform_verifier_selector_exists_in_its_artifact() { + let artifacts = Artifacts::embedded(); + let check = |contract: &str, sig: &str, selector: [u8; 4]| { + let methods = artifacts.method_identifiers(contract).unwrap(); + let found = methods + .get(sig) + .unwrap_or_else(|| panic!("{contract} has no {sig}")); + assert_eq!(*found, alloy::hex::encode(selector), "{contract}.{sig}"); + }; + + macro_rules! bound { + ($contract:expr, $iface:ident, [$($call:ident),* $(,)?]) => { + $(check($contract, $iface::$call::SIGNATURE, $iface::$call::SELECTOR);)* + }; + } + + for contract in ["XPlatformVerifier", "GitHubPlatformVerifier"] { + bound!( + contract, + TlsNotaryPlatformVerifier, + [ + initializeCall, + platformIdCall, + quoteCall, + notaryServiceCall, + honkVerifierCall, + honkVerifierCodehashCall, + protocolParametersCall, + setTrustRootsCall, + setProtocolParametersCall, + MAX_PROOF_LIFETIMECall, + MAX_FUTURE_ATTESTATION_SKEWCall, + MAX_FUTURE_OBSERVATION_ALLOWANCECall, + ownerCall, + pendingOwnerCall, + transferOwnershipCall, + acceptOwnershipCall, + ] + ); + } + bound!( + "GooglePlatformVerifier", + GooglePlatformVerifier, + [ + initializeCall, + platformIdCall, + quoteCall, + jwtRootsCall, + setJwtRootsCall, + notaryServiceCall, + honkVerifierCall, + honkVerifierCodehashCall, + protocolParametersCall, + setTrustRootsCall, + setProtocolParametersCall, + MAX_PROOF_LIFETIMECall, + MAX_FUTURE_ATTESTATION_SKEWCall, + MAX_FUTURE_OBSERVATION_ALLOWANCECall, + ownerCall, + pendingOwnerCall, + transferOwnershipCall, + acceptOwnershipCall, + ] + ); + + // The two initializers differ in shape, and the artifacts say so: + // the TLSNotary one is not on Google's contract, nor the reverse. + assert_ne!( + TlsNotaryPlatformVerifier::initializeCall::SELECTOR, + GooglePlatformVerifier::initializeCall::SELECTOR + ); + let google = artifacts + .method_identifiers("GooglePlatformVerifier") + .unwrap(); + assert!( + !google.contains_key(TlsNotaryPlatformVerifier::initializeCall::SIGNATURE) + ); + let x = artifacts.method_identifiers("XPlatformVerifier").unwrap(); + assert!(!x.contains_key(GooglePlatformVerifier::initializeCall::SIGNATURE)); + } +} diff --git a/rust/contracts/src/error.rs b/rust/contracts/src/error.rs index c1952ac..40b09a9 100644 --- a/rust/contracts/src/error.rs +++ b/rust/contracts/src/error.rs @@ -13,6 +13,13 @@ pub enum Error { /// What went wrong. detail: String, }, + /// A Platform Verifier initializer the contract would refuse, caught + /// before any transaction is sent. + #[error("initializer error: {detail}")] + Initializer { + /// Which rule, and which contract. + detail: String, + }, } /// Crate result alias. diff --git a/rust/contracts/src/lib.rs b/rust/contracts/src/lib.rs index a4c4196..4503d15 100644 --- a/rust/contracts/src/lib.rs +++ b/rust/contracts/src/lib.rs @@ -5,10 +5,11 @@ //! //! - [`bindings`] — hand-written `alloy::sol!` interfaces for every contract a //! consumer talks to: the ceremony verification path (`NotaryService`, -//! `CeremonyProofVerifier`, 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 `solidity/contracts`. +//! `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 +//! `solidity/contracts`. //! - [`artifacts`] — the compiled creation bytecode, link references, and //! method identifiers of every deployable contract, embedded at compile time //! ([`Artifacts::embedded`]) so deployment needs no filesystem at runtime. A @@ -21,6 +22,10 @@ //! cross-network factory address, install it (and the keyless CREATE2 //! deployer it hangs off) where missing, and deploy protocol proxies //! through it at name-derived CREATE3 addresses. +//! - [`platform_verifier`] — the Platform Verifier initializer: which +//! 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. //! //! Signing is the consumer's concern: every helper takes a provider you have //! already wired with a wallet. @@ -30,6 +35,7 @@ pub mod bindings; pub mod deploy; mod error; pub mod factory; +pub mod platform_verifier; pub use artifacts::Artifacts; pub use error::{ diff --git a/rust/contracts/src/platform_verifier.rs b/rust/contracts/src/platform_verifier.rs new file mode 100644 index 0000000..701b698 --- /dev/null +++ b/rust/contracts/src/platform_verifier.rs @@ -0,0 +1,508 @@ +//! Deploying a launch Platform Verifier: which contract serves which +//! platform, what it initializes with, and the rules its `initialize` +//! enforces — checked here, off chain, before a transaction is built. +//! +//! `PlatformVerifierBase.__PlatformVerifierBase_init` refuses four things a +//! deployer would otherwise rediscover at the proxy's constructor revert: +//! a Notary Service that does not match what the profile notarizes (a +//! TLSNotary profile must hold one, Google must hold none), a code hash +//! that is zero, `keccak256("")` or not the hash of the code at the Honk +//! verifier's address, a parameter over its ceiling, and a zero owner or +//! root list. [`Initializer::call`] reads the code hash off the chain, checks +//! the rest, and builds the exact `initialize` call; +//! [`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. + +use alloy::{ + primitives::{ + keccak256, + Address, + B256, + }, + providers::Provider, + sol_types::SolCall, +}; + +use crate::{ + artifacts::Artifacts, + bindings::ceremony::{ + GooglePlatformVerifier, + TlsNotaryPlatformVerifier, + }, + deploy::deploy_behind_proxy, + error::{ + Error, + Result, + }, +}; + +/// Ceiling on `proofLifetime`, in seconds: `PlatformVerifierBase.MAX_PROOF_LIFETIME`. +pub const MAX_PROOF_LIFETIME: u64 = 30 * 24 * 60 * 60; +/// Ceiling on `maxFutureAttestationSkew`, in seconds: +/// `PlatformVerifierBase.MAX_FUTURE_ATTESTATION_SKEW`. +pub const MAX_FUTURE_ATTESTATION_SKEW: u64 = 24 * 60 * 60; +/// Ceiling on `futureObservationAllowance`, in seconds: +/// `PlatformVerifierBase.MAX_FUTURE_OBSERVATION_ALLOWANCE`. +pub const MAX_FUTURE_OBSERVATION_ALLOWANCE: u64 = 24 * 60 * 60; + +/// One of the three launch Platform Verifiers. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum PlatformVerifier { + /// `x/v1`: `XPlatformVerifier`, a TLSNotary profile. + X, + /// `github/v1`: `GitHubPlatformVerifier`, a TLSNotary profile. + GitHub, + /// `google/v1`: `GooglePlatformVerifier`, a signed-token profile that + /// notarizes nothing. + Google, +} + +impl PlatformVerifier { + /// Every launch verifier. + pub const ALL: [Self; 3] = [Self::X, Self::GitHub, Self::Google]; + + /// The contract, which is also its `.sol` file and its entry in + /// [`COVERED`](crate::artifacts::COVERED). + pub const fn contract(self) -> &'static str { + match self { + Self::X => "XPlatformVerifier", + Self::GitHub => "GitHubPlatformVerifier", + Self::Google => "GooglePlatformVerifier", + } + } + + /// The platform's bare name, as `CeremonyProfile` spells it. libID + /// namespaces only its own strings. + pub const fn platform(self) -> &'static str { + match self { + Self::X => "x", + Self::GitHub => "github", + Self::Google => "google", + } + } + + /// What the deployed contract answers to `platformId()`: `keccak256` + /// of the bare name. + pub fn platform_id(self) -> B256 { + keccak256(self.platform().as_bytes()) + } + + /// 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 + /// `libid-profiles` table says the same, and a test pins the two + /// together. + pub const fn notarizes(self) -> bool { + match self { + Self::X | Self::GitHub => true, + Self::Google => false, + } + } +} + +/// What a TLSNotary Platform Verifier (`x/v1`, `github/v1`) initializes +/// with: its trust roots and governance parameters. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TlsNotaryRoots { + /// Governance. Rotates the roots, moves the parameters, upgrades. + pub owner: Address, + /// The Notary Service both attestations are authenticated through. + /// Required: the profile pins one (REQ-COMMON-18). + pub notary_service: Address, + /// The bb-generated UltraHonk verifier for this platform's circuit. Its + /// code hash is read off chain and pinned beside it. + pub honk_verifier: Address, + /// Maximum age of the token attestation, in seconds; at most + /// [`MAX_PROOF_LIFETIME`]. + pub proof_lifetime: u64, + /// Maximum lead over block time an attestation may carry, in seconds; + /// at most [`MAX_FUTURE_ATTESTATION_SKEW`]. + pub max_future_attestation_skew: u64, + /// How far ahead of block time the evidence time may run, in seconds; + /// at most [`MAX_FUTURE_OBSERVATION_ALLOWANCE`]. + pub future_observation_allowance: u64, +} + +/// What the Google Platform Verifier initializes with. No Notary Service: +/// the profile notarizes nothing, and the base refuses one. No lifetime and +/// no skew: the signed `exp` is the whole validity ceiling. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GoogleRoots { + /// Governance. + pub owner: Address, + /// The bb-generated UltraHonk verifier for the Google OIDC circuit. + pub honk_verifier: Address, + /// How far ahead of block time the signed `exp` may run, in seconds; at + /// most [`MAX_FUTURE_OBSERVATION_ALLOWANCE`]. Google's runs about an + /// hour ahead. + pub future_observation_allowance: u64, + /// The `GoogleJwtRoots` proxy the trusted moduli are read through. + pub jwt_roots: Address, +} + +/// What one Platform Verifier is initialized with. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Initializer { + X(TlsNotaryRoots), + GitHub(TlsNotaryRoots), + Google(GoogleRoots), +} + +/// A built `initialize` call, typed by shape. Feed +/// [`abi_encode`](Self::abi_encode) to an ERC1967 proxy as its init data — +/// through [`deploy_platform_verifier`], [`deploy_proxy`](crate::deploy::deploy_proxy), +/// or as part of the creation code a [factory](crate::factory) deploy takes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum InitializeCall { + /// `XPlatformVerifier.initialize` or `GitHubPlatformVerifier.initialize` + /// (one signature). + TlsNotary(TlsNotaryPlatformVerifier::initializeCall), + /// `GooglePlatformVerifier.initialize`. + Google(GooglePlatformVerifier::initializeCall), +} + +impl InitializeCall { + /// The ABI-encoded call. + pub fn abi_encode(&self) -> Vec { + match self { + Self::TlsNotary(call) => call.abi_encode(), + Self::Google(call) => call.abi_encode(), + } + } + + /// The code hash the call pins. + pub fn honk_verifier_codehash(&self) -> B256 { + match self { + Self::TlsNotary(call) => call.honkVerifierCodehash_, + Self::Google(call) => call.honkVerifierCodehash_, + } + } +} + +impl Initializer { + /// Which verifier this initializes. + pub const fn verifier(&self) -> PlatformVerifier { + match self { + Self::X(_) => PlatformVerifier::X, + Self::GitHub(_) => PlatformVerifier::GitHub, + Self::Google(_) => PlatformVerifier::Google, + } + } + + /// The Honk verifier it pins. + pub const fn honk_verifier(&self) -> Address { + match self { + Self::X(roots) | Self::GitHub(roots) => roots.honk_verifier, + Self::Google(roots) => roots.honk_verifier, + } + } + + /// The rules `initialize` enforces that need no chain: a nonzero owner, + /// a nonzero Honk verifier, a Notary Service where the profile notarizes + /// (the Google shape cannot carry one at all), a nonzero root list for + /// Google, and every parameter under its ceiling. The code hash is the + /// one rule left to [`call`](Self::call). + pub fn check(&self) -> Result<()> { + let contract = self.verifier().contract(); + let refuse = |detail: String| Error::Initializer { + detail: format!("{contract}: {detail}"), + }; + let nonzero = |what: &str, address: Address| { + if address == Address::ZERO { + return Err(refuse(format!("{what} is the zero address"))); + } + Ok(()) + }; + let capped = |what: &str, value: u64, limit: u64| { + if value > limit { + return Err(refuse(format!( + "{what} {value}s exceeds the ceiling {limit}s" + ))); + } + Ok(()) + }; + match self { + Self::X(roots) | Self::GitHub(roots) => { + nonzero("owner", roots.owner)?; + nonzero("honk verifier", roots.honk_verifier)?; + if roots.notary_service == Address::ZERO { + return Err(refuse( + "notary service is the zero address, but the profile \ + notarizes two sessions and must pin the Notary Service \ + they are authenticated through" + .into(), + )); + } + capped("proof lifetime", roots.proof_lifetime, MAX_PROOF_LIFETIME)?; + capped( + "max future attestation skew", + roots.max_future_attestation_skew, + MAX_FUTURE_ATTESTATION_SKEW, + )?; + capped( + "future observation allowance", + roots.future_observation_allowance, + MAX_FUTURE_OBSERVATION_ALLOWANCE, + ) + } + Self::Google(roots) => { + nonzero("owner", roots.owner)?; + nonzero("honk verifier", roots.honk_verifier)?; + nonzero("jwt roots", roots.jwt_roots)?; + capped( + "future observation allowance", + roots.future_observation_allowance, + MAX_FUTURE_OBSERVATION_ALLOWANCE, + ) + } + } + } + + /// Build the `initialize` call: [`check`](Self::check), then read the + /// code hash of the Honk verifier through `provider` and pin it. Fails + /// when the address holds no code — the contract would refuse the + /// resulting hash, and a verifier that is not deployed yet is the + /// mis-wiring the check exists to catch. + pub async fn call(&self, provider: &P) -> Result { + self.check()?; + let codehash = + codehash_at(provider, self.honk_verifier()) + .await + .map_err(|e| Error::Initializer { + detail: format!("{}: honk verifier: {e}", self.verifier().contract()), + })?; + Ok(match self { + Self::X(roots) | Self::GitHub(roots) => { + InitializeCall::TlsNotary(TlsNotaryPlatformVerifier::initializeCall { + owner_: roots.owner, + notary_: roots.notary_service, + honkVerifier_: roots.honk_verifier, + honkVerifierCodehash_: codehash, + proofLifetime_: roots.proof_lifetime, + maxFutureAttestationSkew_: roots.max_future_attestation_skew, + futureObservationAllowance_: roots.future_observation_allowance, + }) + } + Self::Google(roots) => { + InitializeCall::Google(GooglePlatformVerifier::initializeCall { + owner_: roots.owner, + // A profile whose Attestation Count is zero must not + // reach a Notary Service (REQ-COMMON-05D); the base + // refuses one. + notary_: Address::ZERO, + honkVerifier_: roots.honk_verifier, + honkVerifierCodehash_: codehash, + futureObservationAllowance_: roots.future_observation_allowance, + jwtRoots_: roots.jwt_roots, + }) + } + }) + } +} + +/// The code hash of the account at `address`, as `EXTCODEHASH` reports it +/// for an account with code: `keccak256` of its runtime bytecode. An +/// account without code is an error rather than `keccak256("")` or zero, +/// because `setTrustRoots` refuses both and a caller comparing against the +/// hash of nothing has nothing to pin. +pub async fn codehash_at(provider: &P, address: Address) -> Result { + let code = provider + .get_code_at(address) + .await + .map_err(|e| Error::Rpc { + detail: format!("failed to read code at {address}: {e}"), + })?; + if code.is_empty() { + return Err(Error::Rpc { + detail: format!("no code at {address}"), + }); + } + Ok(keccak256(&code)) +} + +/// Deploy the verifier's implementation from `artifacts` and put it behind +/// a fresh ERC1967 proxy initialized with `init` — the code hash read off +/// the chain, the rules checked first. Returns the proxy address, which is +/// the Platform Verifier a Proof Verifier registers with `setVerifier`. +/// +/// `sender` opts into explicit nonce management (see +/// [`deploy_contract_from`](crate::deploy::deploy_contract_from)). +pub async fn deploy_platform_verifier( + provider: &P, + artifacts: &Artifacts, + init: &Initializer, + sender: Option
, +) -> Result
{ + let contract = init.verifier().contract(); + match init.call(provider).await? { + InitializeCall::TlsNotary(call) => { + deploy_behind_proxy(provider, artifacts, contract, &call, sender).await + } + InitializeCall::Google(call) => { + deploy_behind_proxy(provider, artifacts, contract, &call, sender).await + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::artifacts::COVERED; + + fn tls() -> TlsNotaryRoots { + TlsNotaryRoots { + owner: Address::repeat_byte(0x01), + notary_service: Address::repeat_byte(0x02), + honk_verifier: Address::repeat_byte(0x03), + proof_lifetime: 3600, + max_future_attestation_skew: 300, + future_observation_allowance: 300, + } + } + + fn google() -> GoogleRoots { + GoogleRoots { + owner: Address::repeat_byte(0x01), + honk_verifier: Address::repeat_byte(0x03), + future_observation_allowance: 7200, + jwt_roots: Address::repeat_byte(0x04), + } + } + + /// Whether a verifier holds a Notary Service is derived from the + /// generated profile table, as the contract derives it from + /// `CeremonyProfile.attestationCount`. + #[test] + fn notarizes_follows_the_profile_table() { + for verifier in PlatformVerifier::ALL { + let profile = libid_profiles::LAUNCH + .iter() + .find(|p| p.platform == verifier.platform()) + .unwrap_or_else(|| panic!("{verifier:?} has no launch profile")); + assert_eq!( + verifier.notarizes(), + profile.attestation_count() != 0, + "{verifier:?}" + ); + assert_eq!(verifier.platform_id(), keccak256(profile.platform)); + } + assert_eq!(PlatformVerifier::ALL.len(), libid_profiles::LAUNCH.len()); + } + + /// Every verifier's contract is one the crate vendors. + #[test] + fn every_verifier_is_covered() { + for verifier in PlatformVerifier::ALL { + let contract = verifier.contract(); + assert!( + COVERED.contains(&(contract, contract)), + "{contract} is not in COVERED" + ); + } + } + + #[test] + fn well_formed_initializers_pass() { + Initializer::X(tls()).check().unwrap(); + Initializer::GitHub(tls()).check().unwrap(); + Initializer::Google(google()).check().unwrap(); + } + + #[test] + fn a_tls_notary_profile_must_pin_a_notary_service() { + let err = Initializer::GitHub(TlsNotaryRoots { + notary_service: Address::ZERO, + ..tls() + }) + .check() + .unwrap_err(); + assert!(matches!(err, Error::Initializer { .. }), "{err}"); + assert!(err.to_string().contains("notary service"), "{err}"); + assert!(err.to_string().contains("GitHubPlatformVerifier"), "{err}"); + } + + #[test] + fn parameters_over_their_ceilings_are_refused() { + let over = [ + Initializer::X(TlsNotaryRoots { + proof_lifetime: MAX_PROOF_LIFETIME + 1, + ..tls() + }), + Initializer::X(TlsNotaryRoots { + max_future_attestation_skew: MAX_FUTURE_ATTESTATION_SKEW + 1, + ..tls() + }), + Initializer::X(TlsNotaryRoots { + future_observation_allowance: MAX_FUTURE_OBSERVATION_ALLOWANCE + 1, + ..tls() + }), + Initializer::Google(GoogleRoots { + future_observation_allowance: MAX_FUTURE_OBSERVATION_ALLOWANCE + 1, + ..google() + }), + ]; + for init in over { + let err = init.check().unwrap_err(); + assert!(err.to_string().contains("exceeds the ceiling"), "{err}"); + } + // At the ceiling is allowed. + Initializer::X(TlsNotaryRoots { + proof_lifetime: MAX_PROOF_LIFETIME, + max_future_attestation_skew: MAX_FUTURE_ATTESTATION_SKEW, + future_observation_allowance: MAX_FUTURE_OBSERVATION_ALLOWANCE, + ..tls() + }) + .check() + .unwrap(); + } + + #[test] + fn zero_addresses_are_refused() { + let cases: [(Initializer, &str); 5] = [ + ( + Initializer::X(TlsNotaryRoots { + owner: Address::ZERO, + ..tls() + }), + "owner", + ), + ( + Initializer::X(TlsNotaryRoots { + honk_verifier: Address::ZERO, + ..tls() + }), + "honk verifier", + ), + ( + Initializer::Google(GoogleRoots { + owner: Address::ZERO, + ..google() + }), + "owner", + ), + ( + Initializer::Google(GoogleRoots { + honk_verifier: Address::ZERO, + ..google() + }), + "honk verifier", + ), + ( + Initializer::Google(GoogleRoots { + jwt_roots: Address::ZERO, + ..google() + }), + "jwt roots", + ), + ]; + for (init, what) in cases { + let err = init.check().unwrap_err(); + assert!(err.to_string().contains(what), "{what}: {err}"); + } + } +} diff --git a/rust/contracts/tests/anvil.rs b/rust/contracts/tests/anvil.rs index a115030..bcc1100 100644 --- a/rust/contracts/tests/anvil.rs +++ b/rust/contracts/tests/anvil.rs @@ -436,3 +436,273 @@ async fn deploys_the_ens_resolver_with_its_constructor_arguments() { .unwrap(); assert!(resolver.signers(next).call().await.unwrap()); } + +/// (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. +#[tokio::test] +async fn deploys_and_initializes_every_platform_verifier() { + use alloy::{ + hex, + primitives::keccak256, + sol_types::SolError, + }; + use libid_contracts::{ + bindings::ceremony::{ + GooglePlatformVerifier, + TlsNotaryPlatformVerifier, + }, + platform_verifier::{ + codehash_at, + deploy_platform_verifier, + GoogleRoots, + Initializer, + PlatformVerifier, + TlsNotaryRoots, + MAX_FUTURE_ATTESTATION_SKEW, + MAX_FUTURE_OBSERVATION_ALLOWANCE, + MAX_PROOF_LIFETIME, + }, + Error, + }; + + let provider = test_provider(); + let artifacts = Artifacts::embedded(); + let deployer = default_signer(&provider).await; + let fee = U256::from(1_000); + + let notary_proxy = deploy_behind_proxy( + &provider, + &artifacts, + "NotaryService", + &NotaryService::initializeCall { + owner_: deployer, + notary_: Address::repeat_byte(0x11), + fee_: fee, + }, + None, + ) + .await + .unwrap(); + let proof_verifier_proxy = deploy_behind_proxy( + &provider, + &artifacts, + "CeremonyProofVerifier", + &CeremonyProofVerifier::initializeCall { owner_: deployer }, + None, + ) + .await + .unwrap(); + let roots_proxy = deploy_behind_proxy( + &provider, + &artifacts, + "GoogleJwtRoots", + &GoogleJwtRoots::initializeCall { + owner_: deployer, + notary_: notary_proxy, + }, + None, + ) + .await + .unwrap(); + let honk = deploy_contract( + &provider, + artifacts.bytecode("WTIA9").unwrap(), + "stand-in Honk verifier", + ) + .await + .unwrap(); + let honk_codehash = codehash_at(&provider, honk).await.unwrap(); + assert_ne!(honk_codehash, keccak256([])); + + let tls = TlsNotaryRoots { + owner: deployer, + notary_service: notary_proxy, + honk_verifier: honk, + 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, + future_observation_allowance: 7200, + jwt_roots: roots_proxy, + }; + let proof_verifier = CeremonyProofVerifier::new(proof_verifier_proxy, &provider); + + for init in [ + Initializer::X(tls), + Initializer::GitHub(tls), + Initializer::Google(google), + ] { + let verifier = init.verifier(); + let proxy = deploy_platform_verifier(&provider, &artifacts, &init, None) + .await + .unwrap_or_else(|e| panic!("{verifier:?}: {e}")); + assert!(!provider.get_code_at(proxy).await.unwrap().is_empty()); + + // The quote is what the Proof Verifier forwards whole: one Notary + // Fee per attestation the profile requires. + let quote = match verifier { + PlatformVerifier::X | PlatformVerifier::GitHub => { + let v = TlsNotaryPlatformVerifier::new(proxy, &provider); + assert_eq!(v.owner().call().await.unwrap(), deployer); + assert_eq!(v.notaryService().call().await.unwrap(), notary_proxy); + assert_eq!(v.honkVerifier().call().await.unwrap(), honk); + assert_eq!( + v.honkVerifierCodehash().call().await.unwrap(), + honk_codehash + ); + let params = v.protocolParameters().call().await.unwrap(); + assert_eq!(params.proofLifetime, tls.proof_lifetime); + assert_eq!( + params.maxFutureAttestationSkew, + tls.max_future_attestation_skew + ); + assert_eq!( + params.futureObservationAllowance, + tls.future_observation_allowance + ); + assert_eq!( + v.MAX_PROOF_LIFETIME().call().await.unwrap(), + MAX_PROOF_LIFETIME + ); + assert_eq!( + v.MAX_FUTURE_ATTESTATION_SKEW().call().await.unwrap(), + MAX_FUTURE_ATTESTATION_SKEW + ); + assert_eq!( + v.MAX_FUTURE_OBSERVATION_ALLOWANCE().call().await.unwrap(), + MAX_FUTURE_OBSERVATION_ALLOWANCE + ); + let quote = v.quote().call().await.unwrap(); + assert_eq!(quote, fee * U256::from(2)); + quote + } + PlatformVerifier::Google => { + let v = GooglePlatformVerifier::new(proxy, &provider); + assert_eq!(v.owner().call().await.unwrap(), deployer); + assert_eq!(v.notaryService().call().await.unwrap(), Address::ZERO); + assert_eq!(v.honkVerifier().call().await.unwrap(), honk); + assert_eq!( + v.honkVerifierCodehash().call().await.unwrap(), + honk_codehash + ); + assert_eq!(v.jwtRoots().call().await.unwrap(), roots_proxy); + let params = v.protocolParameters().call().await.unwrap(); + assert_eq!(params.proofLifetime, 0); + assert_eq!(params.maxFutureAttestationSkew, 0); + assert_eq!( + params.futureObservationAllowance, + google.future_observation_allowance + ); + let quote = v.quote().call().await.unwrap(); + assert_eq!(quote, U256::ZERO); + quote + } + }; + + // The contract answers for the platform the crate says it serves, + // and the Proof Verifier registers it under that platform. + let platform_id = TlsNotaryPlatformVerifier::new(proxy, &provider) + .platformId() + .call() + .await + .unwrap(); + assert_eq!(platform_id, verifier.platform_id()); + proof_verifier + .setVerifier(platform_id, 1, proxy) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert_eq!( + proof_verifier + .verifierOf(platform_id, 1) + .call() + .await + .unwrap(), + proxy + ); + assert_eq!( + proof_verifier.quote(platform_id, 1).call().await.unwrap(), + quote + ); + } + + // A Honk verifier that is not deployed is caught before any transaction: + // the hash of nothing is exactly what the contract refuses to pin. + let err = Initializer::X(TlsNotaryRoots { + honk_verifier: Address::repeat_byte(0x99), + ..tls + }) + .call(&provider) + .await + .unwrap_err(); + assert!(matches!(err, Error::Initializer { .. }), "{err}"); + assert!(err.to_string().contains("no code at"), "{err}"); + + // The rules the wrapper enforces are the contract's, not its own: a + // hand-built Google initializer carrying a Notary Service, and an X one + // naming the wrong artifact, both revert at the proxy constructor with + // the error the wrapper's refusal names. Explicit nonces from here: + // a send that fails at gas estimation leaves alloy's cached nonce + // manager one ahead of the chain, and every later transaction would + // wait on a gap that never fills. + let google_with_notary = GooglePlatformVerifier::initializeCall { + owner_: deployer, + notary_: notary_proxy, + honkVerifier_: honk, + honkVerifierCodehash_: honk_codehash, + futureObservationAllowance_: 7200, + jwtRoots_: roots_proxy, + }; + let err = deploy_behind_proxy( + &provider, + &artifacts, + "GooglePlatformVerifier", + &google_with_notary, + Some(deployer), + ) + .await + .unwrap_err(); + assert!( + err.to_string().contains(&hex::encode( + GooglePlatformVerifier::WrongNotaryForProfile::SELECTOR + )), + "{err}" + ); + let x_wrong_artifact = TlsNotaryPlatformVerifier::initializeCall { + owner_: deployer, + notary_: notary_proxy, + honkVerifier_: honk, + honkVerifierCodehash_: keccak256("some other artifact"), + proofLifetime_: 3600, + maxFutureAttestationSkew_: 300, + futureObservationAllowance_: 300, + }; + let err = deploy_behind_proxy( + &provider, + &artifacts, + "XPlatformVerifier", + &x_wrong_artifact, + Some(deployer), + ) + .await + .unwrap_err(); + assert!( + err.to_string().contains(&hex::encode( + TlsNotaryPlatformVerifier::WrongVerifierArtifact::SELECTOR + )), + "{err}" + ); +} diff --git a/scripts/vendor-artifacts.sh b/scripts/vendor-artifacts.sh index a60c817..c5bf008 100755 --- a/scripts/vendor-artifacts.sh +++ b/scripts/vendor-artifacts.sh @@ -33,6 +33,11 @@ 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) + "XPlatformVerifier:XPlatformVerifier" + "GitHubPlatformVerifier:GitHubPlatformVerifier" + "GooglePlatformVerifier:GooglePlatformVerifier" # identity "IdentityNames:IdentityNames" # ens (deployed once per network, not CREATE3-canonical; embedded so a