diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2cc7f230087..d4d461a5699 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -590,7 +590,7 @@ jobs: uses: ./.github/workflows/actions/publish-rust-crates with: dry_run: "true" - packages: mithril-stm, mithril-build-script, mithril-common, mithril-aggregator-client, mithril-aggregator-discovery, mithril-cardano-node-internal-database, mithril-client, mithril-merkle-tree + packages: mithril-stm, mithril-build-script, mithril-common, mithril-circuit-key-registry, mithril-aggregator-client, mithril-aggregator-discovery, mithril-cardano-node-internal-database, mithril-client, mithril-merkle-tree publish-npm-test: strategy: @@ -869,7 +869,7 @@ jobs: # the same name (we only want to document those anyway) cargo doc --no-deps --lib -p mithril-stm -p mithril-common \ -p mithril-cardano-node-chain -p mithril-cardano-node-internal-database \ - -p mithril-aggregator-client -p mithril-aggregator-discovery -p mithril-build-script -p mithril-cli-helper \ + -p mithril-aggregator-client -p mithril-aggregator-discovery -p mithril-build-script -p mithril-circuit-key-registry -p mithril-cli-helper \ -p mithril-dmq -p mithril-doc -p mithril-doc-derive -p mithril-era -p mithril-file-archiver \ -p mithril-merkle-tree -p mithril-metric -p mithril-persistence -p mithril-resource-pool \ -p mithril-ticker -p mithril-signed-entity-lock -p mithril-signed-entity-preloader \ diff --git a/.github/workflows/manual-publish-crates.yml b/.github/workflows/manual-publish-crates.yml index 17bf3631dc0..c8d31426088 100644 --- a/.github/workflows/manual-publish-crates.yml +++ b/.github/workflows/manual-publish-crates.yml @@ -17,6 +17,7 @@ on: - all - mithril-stm - mithril-common + - mithril-circuit-key-registry - mithril-client - mithril-aggregator-client - mithril-aggregator-discovery @@ -36,7 +37,7 @@ jobs: id: prepare run: | if [[ ${{ inputs.package }} == 'all' ]]; then - echo "packages=mithril-stm, mithril-build-script, mithril-common, mithril-aggregator-client, mithril-aggregator-discovery, mithril-cardano-node-internal-database, mithril-client" >> $GITHUB_OUTPUT + echo "packages=mithril-stm, mithril-build-script, mithril-common, mithril-circuit-key-registry, mithril-aggregator-client, mithril-aggregator-discovery, mithril-cardano-node-internal-database, mithril-client" >> $GITHUB_OUTPUT else echo "packages=${{ inputs.package }}" >> $GITHUB_OUTPUT fi diff --git a/.github/workflows/nightly-dispatcher.yml b/.github/workflows/nightly-dispatcher.yml index 1d68dafe73f..3e9f1d68a28 100644 --- a/.github/workflows/nightly-dispatcher.yml +++ b/.github/workflows/nightly-dispatcher.yml @@ -43,7 +43,7 @@ jobs: dry_run: "true" dry_run_verify: "true" publish_args: "--features rustls" - packages: mithril-stm, mithril-build-script, mithril-common, mithril-aggregator-client, mithril-aggregator-discovery, mithril-cardano-node-internal-database, mithril-client, mithril-merkle-tree + packages: mithril-stm, mithril-build-script, mithril-common, mithril-circuit-key-registry, mithril-aggregator-client, mithril-aggregator-discovery, mithril-cardano-node-internal-database, mithril-client, mithril-merkle-tree clippy-check-all-crates: runs-on: ubuntu-24.04 diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index e8fdbb02085..387f5c81efd 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -332,7 +332,7 @@ jobs: uses: ./.github/workflows/actions/publish-rust-crates with: dry_run: "true" - packages: mithril-stm, mithril-build-script, mithril-common, mithril-aggregator-client, mithril-aggregator-discovery, mithril-cardano-node-internal-database, mithril-client, mithril-merkle-tree + packages: mithril-stm, mithril-build-script, mithril-common, mithril-circuit-key-registry, mithril-aggregator-client, mithril-aggregator-discovery, mithril-cardano-node-internal-database, mithril-client, mithril-merkle-tree publish-next-wasm-package: if: github.event_name == 'push' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47b2b8e6bbf..28c7e6dd8d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -248,7 +248,7 @@ jobs: uses: ./.github/workflows/actions/publish-rust-crates with: dry_run: "false" - packages: mithril-stm, mithril-build-script, mithril-common, mithril-aggregator-client, mithril-aggregator-discovery, mithril-cardano-node-internal-database, mithril-client, mithril-merkle-tree + packages: mithril-stm, mithril-build-script, mithril-common, mithril-circuit-key-registry, mithril-aggregator-client, mithril-aggregator-discovery, mithril-cardano-node-internal-database, mithril-client, mithril-merkle-tree api_token: ${{ secrets.CRATES_IO_API_TOKEN }} promote-wasm-package-to-latest: diff --git a/.github/workflows/test-rust.yml b/.github/workflows/test-rust.yml index 0e054595c0d..150dfc075c4 100644 --- a/.github/workflows/test-rust.yml +++ b/.github/workflows/test-rust.yml @@ -19,7 +19,7 @@ jobs: test-args: --features full,unstable --workspace cargo-profile: ci-tests - os: ubuntu-24.04 - test-args: --features future_snark,rustls -p mithril-stm -p mithril-common -p mithril-aggregator -p mithril-signer -p mithril-relay + test-args: --features future_snark,rustls -p mithril-stm -p mithril-common -p mithril-circuit-key-registry -p mithril-aggregator -p mithril-signer -p mithril-relay artifact-suffix: -future-snark # Default to the fast profile; the `prepare` step upgrades to `slow-cargo-profile` # only when the slow SNARK tests are actually in scope for this run (so we don't pay diff --git a/.prettierignore b/.prettierignore index c1df304adb4..3dbb3b0ff3e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,6 +6,7 @@ mithril-explorer/out/ mithril-explorer/.next/ mithril-explorer/explorer/ mithril-infra/assets/docker/cardano/ +mithril-infra/configuration/*/circuit-verification-key-registry.json mithril-test-lab/cardano-devnet/artifacts/ mithril-test-lab/mithril-end-to-end/monitor/config/grafana/provisioning/dashboards/*.json mithril-test-lab/mithril-aggregator-fake/default_data/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 02c68741cb1..c999bc82f07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ As a minor extension, we have adopted a slightly different versioning convention - Existing archives must be regenerated by the Mithril aggregator to ensure byte stability. - Preliminary support for uploading immutable files to IPFS with the Mithril aggregator and downloading them from IPFS with the Mithril client library and CLI. - Preliminary support for the circuit verification key registry, a genesis-signed whitelist (with revocations) of the circuit verification keys trusted for SNARK certificates. + - The registry lives in the new `mithril-circuit-key-registry` crate, holding one entry per circuit verification key, either allowed over an epoch range or revoked for every epoch. + - The registry is retrieved over HTTP and the last verified registry is kept when a refresh fails. + - New `circuit-key-registry export`, `whitelist`, `expire`, `revoke` and `sign` commands in the Mithril aggregator to manage the genesis-signed registry of a Mithril network. - **REMOVED** support for `Gzip` compression/decompression in the Mithril aggregator and client: - The aggregator no longer produces or supports `Gzip` compression for snapshot-related artifacts: immutable files and ancillaries. diff --git a/Cargo.lock b/Cargo.lock index 80f552f5399..1f622a36eb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4236,6 +4236,7 @@ dependencies = [ "mithril-api-spec", "mithril-cardano-node-chain", "mithril-cardano-node-internal-database", + "mithril-circuit-key-registry", "mithril-cli-helper", "mithril-common", "mithril-dmq", @@ -4407,6 +4408,30 @@ dependencies = [ "walkdir", ] +[[package]] +name = "mithril-circuit-key-registry" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "futures", + "hex", + "httpmock", + "mithril-common", + "mockall", + "rand_chacha 0.3.1", + "rand_core 0.6.4", + "reqwest", + "serde", + "serde_json", + "slog", + "slog-async", + "slog-term", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "mithril-cli-helper" version = "0.0.9" diff --git a/Cargo.toml b/Cargo.toml index 7abb171305f..38048a8d8d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "internal/mithril-aggregator-client", "internal/mithril-aggregator-discovery", "internal/mithril-build-script", + "internal/mithril-circuit-key-registry", "internal/mithril-cli-helper", "internal/mithril-dmq", "internal/mithril-doc", diff --git a/Makefile b/Makefile index 2afdaabee12..0e72e2a2b67 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ COMPONENTS = demo/protocol-demo \ internal/mithril-aggregator-client \ internal/mithril-aggregator-discovery \ internal/mithril-build-script \ + internal/mithril-circuit-key-registry \ internal/mithril-cli-helper \ internal/mithril-dmq \ internal/mithril-doc \ diff --git a/README.md b/README.md index 625d3494717..2f065276450 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ This repository consists of the following parts: - [**Mithril cardano-node-internal-database**](./internal/cardano-node/mithril-cardano-node-internal-database): mechanisms to read the files of a **Cardano node** internal database and compute digests from them, used by **Mithril network** nodes and client library. + - [**Mithril circuit key registry**](./internal/mithril-circuit-key-registry): the genesis-signed **registry** of the circuit verification keys trusted for **SNARK certificates**, used by **Mithril network** nodes and client library. + - [**Mithril cli helper**](./internal/mithril-cli-helper): **CLI** tools for **Mithril** binaries. - [**Mithril DMQ node**](./internal/mithril-dmq): mechanisms to publish and consume messages of a **Decentralized Message Queue network** through a DMQ node, used by Mithril network nodes. diff --git a/docs/website/root/manual/develop/nodes/mithril-aggregator.md b/docs/website/root/manual/develop/nodes/mithril-aggregator.md index 6455107dc9c..96c9e7ec000 100644 --- a/docs/website/root/manual/develop/nodes/mithril-aggregator.md +++ b/docs/website/root/manual/develop/nodes/mithril-aggregator.md @@ -470,6 +470,12 @@ Here are the available subcommands: | **database migrate** | Migrate databases located in the given stores directory | | **database vacuum** | Vacuum the aggregator main database | | **tools recompute-certificates-hash** | Loads all certificates in the database, recomputing their hash, and updating all related entities | +| **circuit-key-registry export** | Exports the circuit verification key digests of the network | +| **circuit-key-registry whitelist** | Whitelists a circuit verification key in the signed registry | +| **circuit-key-registry expire** | Expires a circuit verification key in the signed registry at its last allowed epoch | +| **circuit-key-registry revoke** | Revokes a circuit verification key in the signed registry | +| **circuit-key-registry sign** | Signs a hand-authored circuit verification key registry with the genesis secret key | +| **circuit-key-registry bootstrap** | Bootstraps a signed circuit verification key registry (test only usage) | ## Configuration parameters @@ -627,3 +633,64 @@ Here is a list of the available parameters for the serve command: | Parameter | Command line (long) | Command line (short) | Environment variable | Description | Default value | Example | Mandatory | | ----------------------- | ------------------- | :------------------: | ----------------------- | --------------------------------------- | ------------- | ------- | :----------------: | | `data_stores_directory` | - | - | `DATA_STORES_DIRECTORY` | Directory to store aggregator databases | - | - | :heavy_check_mark: | + +`circuit-key-registry export` command: + +| Parameter | Command line (long) | Command line (short) | Environment variable | Description | Default value | Example | Mandatory | +| --------------------- | ----------------------- | :------------------: | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ------- | :----------------: | +| `protocol_parameters` | `--protocol-parameters` | - | - | Protocol parameters of the network as JSON (e.g. `{"k":5,"m":9,"phi_f":0.95}`), defaults to the production protocol parameters of the embedded certificate circuit key | - | - | - | +| `target_path` | `--target-path` | - | - | Target Path | - | - | :heavy_check_mark: | +| `help` | `--help` | `-h` | - | Print help | - | - | - | + +`circuit-key-registry whitelist` command: + +| Parameter | Command line (long) | Command line (short) | Environment variable | Description | Default value | Example | Mandatory | +| ------------------------- | --------------------------- | :------------------: | -------------------- | --------------------------------------------------------------------------- | ------------- | ------- | :----------------: | +| `registry_path` | `--registry-path` | - | - | Signed Registry Path, updated in place | - | - | :heavy_check_mark: | +| `genesis_secret_key_path` | `--genesis-secret-key-path` | - | - | Genesis Secret Key Path | - | - | :heavy_check_mark: | +| `digest` | `--digest` | - | - | Digest of the circuit verification key (hex encoded) | - | - | :heavy_check_mark: | +| `name` | `--name` | - | - | Name of the circuit verification key (e.g. 'certificate-circuit v1') | - | - | :heavy_check_mark: | +| `start_epoch` | `--start-epoch` | - | - | First epoch (inclusive) at which the key is allowed | - | - | :heavy_check_mark: | +| `end_epoch` | `--end-epoch` | - | - | Last epoch (inclusive) at which the key is allowed, open-ended when omitted | - | - | - | +| `comment` | `--comment` | - | - | Comment recorded in the entry | - | - | - | +| `help` | `--help` | `-h` | - | Print help | - | - | - | + +`circuit-key-registry expire` command: + +| Parameter | Command line (long) | Command line (short) | Environment variable | Description | Default value | Example | Mandatory | +| ------------------------- | --------------------------- | :------------------: | -------------------- | ---------------------------------------------------------------------- | ------------- | ------- | :----------------: | +| `registry_path` | `--registry-path` | - | - | Signed Registry Path, updated in place | - | - | :heavy_check_mark: | +| `genesis_secret_key_path` | `--genesis-secret-key-path` | - | - | Genesis Secret Key Path | - | - | :heavy_check_mark: | +| `digest` | `--digest` | - | - | Digest of the allowed circuit verification key to expire (hex encoded) | - | - | :heavy_check_mark: | +| `end_epoch` | `--end-epoch` | - | - | Last epoch (inclusive) at which the key is allowed | - | - | :heavy_check_mark: | +| `comment` | `--comment` | - | - | Comment recorded in the entry, kept as is when omitted | - | - | - | +| `help` | `--help` | `-h` | - | Print help | - | - | - | + +`circuit-key-registry revoke` command: + +| Parameter | Command line (long) | Command line (short) | Environment variable | Description | Default value | Example | Mandatory | +| ------------------------- | --------------------------- | :------------------: | -------------------- | ------------------------------------------------------------------------------------ | ------------- | ------- | :----------------: | +| `registry_path` | `--registry-path` | - | - | Signed Registry Path, updated in place | - | - | :heavy_check_mark: | +| `genesis_secret_key_path` | `--genesis-secret-key-path` | - | - | Genesis Secret Key Path | - | - | :heavy_check_mark: | +| `digest` | `--digest` | - | - | Digest of the allowed circuit verification key to revoke (hex encoded) | - | - | :heavy_check_mark: | +| `revocation_epoch` | `--revocation-epoch` | - | - | Epoch of the revocation, recorded in the entry (the key is rejected for every epoch) | - | - | :heavy_check_mark: | +| `comment` | `--comment` | - | - | Comment recorded in the entry, explaining the revocation | - | - | :heavy_check_mark: | +| `help` | `--help` | `-h` | - | Print help | - | - | - | + +`circuit-key-registry sign` command: + +| Parameter | Command line (long) | Command line (short) | Environment variable | Description | Default value | Example | Mandatory | +| ----------------------------- | ------------------------------- | :------------------: | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ------- | :----------------: | +| `to_sign_registry_path` | `--to-sign-registry-path` | - | - | To Sign Registry Path | - | - | :heavy_check_mark: | +| `target_signed_registry_path` | `--target-signed-registry-path` | - | - | Target Signed Registry Path, replaced in place: the registry to sign must carry the version following the signed registry found there, or the initial version when there is none | - | - | :heavy_check_mark: | +| `genesis_secret_key_path` | `--genesis-secret-key-path` | - | - | Genesis Secret Key Path | - | - | :heavy_check_mark: | +| `help` | `--help` | `-h` | - | Print help | - | - | - | + +`circuit-key-registry bootstrap` command: + +| Parameter | Command line (long) | Command line (short) | Environment variable | Description | Default value | Example | Mandatory | +| ---------------------- | ------------------------ | :------------------: | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ------- | :----------------: | +| `genesis_secret_key` | `--genesis-secret-key` | - | `GENESIS_SECRET_KEY` | Genesis Secret Key (test only) | - | - | :heavy_check_mark: | +| `protocol_parameters` | `--protocol-parameters` | - | - | Protocol parameters of the network as JSON (e.g. `{"k":5,"m":9,"phi_f":0.95}`), repeatable to whitelist several parameter sets, defaults to the production protocol parameters of the embedded certificate circuit key | - | - | - | +| `target_registry_path` | `--target-registry-path` | - | - | Target Registry Path | - | - | :heavy_check_mark: | +| `help` | `--help` | `-h` | - | Print help | - | - | - | diff --git a/internal/mithril-circuit-key-registry/Cargo.toml b/internal/mithril-circuit-key-registry/Cargo.toml new file mode 100644 index 00000000000..dd9eb2e0101 --- /dev/null +++ b/internal/mithril-circuit-key-registry/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "mithril-circuit-key-registry" +description = "Genesis-signed registry of the circuit verification keys trusted for Mithril SNARK certificates." +version = "0.1.0" +authors.workspace = true +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true +include = ["**/*.rs", "Cargo.toml", "README.md", ".gitignore"] + +[package.metadata.docs.rs] +all-features = true + +[features] +default = ["num-integer-backend"] + +# Enables the registry, which requires the future SNARK implementation of the `mithril-common` dependency +future_snark = ["mithril-common/future_snark"] + +# Enables `rug-backend` features for `mithril-common` dependency +rug-backend = ["mithril-common/rug-backend"] +# Enables `num-integer-backend` features for `mithril-common` dependency +num-integer-backend = ["mithril-common/num-integer-backend"] + +# Enables the TLS features for the registry download and the `mithril-common` dependency +# Exactly one of these must be enabled +native-tls = ["mithril-common/native-tls", "reqwest/native-tls"] +rustls = ["mithril-common/rustls", "reqwest/rustls"] + +[dependencies] +anyhow = { workspace = true } +async-trait = { workspace = true } +chrono = { workspace = true } +futures = "0.3.32" +mithril-common = { path = "../../mithril-common", version = "0.7.22", default-features = false } +reqwest = { workspace = true, features = ["stream"] } +serde = { workspace = true } +serde_json = { workspace = true } +slog = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +hex = { workspace = true } +httpmock = "0.8.3" +mockall = { workspace = true } +rand_chacha = { workspace = true } +rand_core = { workspace = true } +slog-async = { workspace = true } +slog-term = { workspace = true } +tokio = { workspace = true, features = ["macros"] } diff --git a/internal/mithril-circuit-key-registry/Makefile b/internal/mithril-circuit-key-registry/Makefile new file mode 100644 index 00000000000..0d9473655b3 --- /dev/null +++ b/internal/mithril-circuit-key-registry/Makefile @@ -0,0 +1,19 @@ +.PHONY: all build test check doc + +CARGO = cargo + +all: test build + +build: + ${CARGO} build --release --features rustls,future_snark + +test: + ${CARGO} test --features rustls,future_snark + +check: + ${CARGO} check --release --all-features --all-targets + ${CARGO} clippy --release --all-features --all-targets + ${CARGO} fmt --check + +doc: + ${CARGO} doc --no-deps --open --features rustls,future_snark diff --git a/internal/mithril-circuit-key-registry/README.md b/internal/mithril-circuit-key-registry/README.md new file mode 100644 index 00000000000..f9bca82569f --- /dev/null +++ b/internal/mithril-circuit-key-registry/README.md @@ -0,0 +1,17 @@ +# Mithril-circuit-key-registry + +This crate provides the genesis-signed registry of the circuit verification keys trusted for the +SNARK certificates of a Mithril network. + +It holds: + +- the registry format, with one entry per circuit verification key digest, either allowed over an + epoch range or revoked, and its genesis signature, +- the certifiers checking the circuit verification key digests of a certificate against the + registry, with a cache refreshing the registry periodically and keeping the last verified + registry for at most a day when the refreshes fail, +- the retrievers of the signed registry from a local file or over HTTP. + +The nodes enforce the registry through the `CircuitVerificationKeyCertifier` trait of +`mithril-common`. The operations on the registry are provided by the +`circuit-key-registry` command of the Mithril aggregator. diff --git a/internal/mithril-circuit-key-registry/src/certifier.rs b/internal/mithril-circuit-key-registry/src/certifier.rs new file mode 100644 index 00000000000..c437fd1420e --- /dev/null +++ b/internal/mithril-circuit-key-registry/src/certifier.rs @@ -0,0 +1,823 @@ +//! Certifiers of circuit verification key digests against the genesis-signed registry. + +use std::sync::Arc; + +use anyhow::{Context, anyhow}; +use async_trait::async_trait; +use chrono::{DateTime, TimeDelta, Utc}; +use slog::{Logger, warn}; +use thiserror::Error; +use tokio::sync::RwLock; + +use mithril_common::certificate_chain::CircuitVerificationKeyCertifier; +use mithril_common::crypto_helper::{CircuitVerificationKeyDigest, GenesisVerifier}; +use mithril_common::entities::Epoch; +use mithril_common::logging::LoggerExtensions; +use mithril_common::{StdError, StdResult}; + +use crate::{CircuitVerificationKeyRegistry, CircuitVerificationKeyRegistryRetriever}; + +/// Time to live in seconds of the registry cached by +/// [CachedCircuitVerificationKeyCertifier]. +/// +/// Once elapsed, the registry is retrieved and verified again, so a registry updated while a +/// node is running (e.g. a revocation) is picked up without a restart. +pub const REGISTRY_CACHE_TIME_TO_LIVE_IN_SECONDS: i64 = 3600; + +/// Delay in seconds after which [CachedCircuitVerificationKeyCertifier] retries a failed +/// refresh, so a registry published right after a transient failure does not wait a whole time +/// to live. +pub const REGISTRY_REFRESH_RETRY_DELAY_IN_SECONDS: i64 = 300; + +/// Maximum age in seconds of the last successful verification of the registry cached by +/// [CachedCircuitVerificationKeyCertifier]. +/// +/// Once exceeded, a node whose refreshes keep failing fails closed, so an outage of the registry +/// source cannot hide a revocation from a running node indefinitely. +pub const REGISTRY_VERIFICATION_MAXIMUM_AGE_IN_SECONDS: i64 = 24 * 3600; + +/// Errors raised by a [CircuitVerificationKeyCertifier] when obtaining a trusted registry. +#[derive(Error, Debug)] +pub enum CircuitVerificationKeyCertifierError { + /// The signed registry could not be retrieved from its source. + #[error("circuit verification key registry retrieval failed")] + RegistryRetrieval(#[source] StdError), + + /// The genesis signature of the retrieved registry is invalid, or its signed payload cannot + /// be parsed. + /// + /// A registry published for another network is also rejected here, as each network signs its + /// own registry with its own genesis key. + #[error("circuit verification key registry has an invalid genesis signature")] + InvalidRegistrySignature(#[source] StdError), + + /// The cached registry could not be refreshed since longer than the maximum age of its + /// verification. + #[error( + "circuit verification key registry could not be refreshed for {age_in_seconds} seconds, more than the {maximum_age_in_seconds} seconds allowed since its last verification" + )] + RegistryRefreshOverdue { + /// Seconds elapsed since the cached registry was last verified. + age_in_seconds: i64, + /// Maximum seconds allowed since the last verification. + maximum_age_in_seconds: i64, + /// Failure of the last refresh. + source: StdError, + }, +} + +/// A [CircuitVerificationKeyCertifier] retrieving the registry and verifying its genesis +/// signature at every use. +/// +/// Wrap it in a [CachedCircuitVerificationKeyCertifier] to avoid retrieving the registry at +/// every check. Fail-closed: any retrieval or verification failure fails the check. +pub struct MithrilCircuitVerificationKeyCertifier { + registry_retriever: Arc, + genesis_verifier: Arc, +} + +impl MithrilCircuitVerificationKeyCertifier { + /// Build a certifier from a registry retriever and the genesis verifier holding the registry + /// signing key, which scopes the registry to its network. + pub fn new( + registry_retriever: Arc, + genesis_verifier: Arc, + ) -> Self { + Self { + registry_retriever, + genesis_verifier, + } + } + + /// Retrieve the registry and verify its genesis signature. + pub async fn get_verified_registry(&self) -> StdResult { + let signed_registry = self + .registry_retriever + .retrieve_signed_registry() + .await + .map_err(|e| CircuitVerificationKeyCertifierError::RegistryRetrieval(e.into()))?; + + signed_registry + .verify(&self.genesis_verifier) + .map_err(|e| CircuitVerificationKeyCertifierError::InvalidRegistrySignature(e).into()) + } + + /// Check that every digest is allowed by the verified registry for the given epoch. + fn certify( + registry: &CircuitVerificationKeyRegistry, + digests: &[CircuitVerificationKeyDigest], + epoch: Epoch, + ) -> StdResult<()> { + registry + .check(digests, epoch) + .map_err(|e| anyhow!(e)) + .with_context(|| "Circuit verification key certification failed") + } +} + +#[cfg_attr(target_family = "wasm", async_trait(?Send))] +#[cfg_attr(not(target_family = "wasm"), async_trait)] +impl CircuitVerificationKeyCertifier for MithrilCircuitVerificationKeyCertifier { + async fn check(&self, digests: &[CircuitVerificationKeyDigest], epoch: Epoch) -> StdResult<()> { + let registry = self.get_verified_registry().await?; + + Self::certify(®istry, digests, epoch) + } +} + +/// A verified registry together with its refresh schedule. +struct VerifiedRegistryCache { + /// The verified registry. + registry: CircuitVerificationKeyRegistry, + + /// Time the registry was last obtained and verified. + verified_at: DateTime, + + /// Time of the last refresh attempt, successful or not. + refreshed_at: DateTime, + + /// Time from which the registry is refreshed again. + next_refresh_at: DateTime, +} + +impl VerifiedRegistryCache { + /// Cache a registry verified now, to be refreshed once the time to live elapsed. + fn verified(registry: CircuitVerificationKeyRegistry, time_to_live_in_seconds: i64) -> Self { + let now = Utc::now(); + + Self { + registry, + verified_at: now, + refreshed_at: now, + next_refresh_at: now + TimeDelta::seconds(time_to_live_in_seconds), + } + } + + /// Whether the cached registry is still fresh. + /// + /// A cache refreshed in the future (the clock jumped backwards) is stale, so it forces a + /// refresh instead of staying fresh until the clock catches up. + fn is_fresh(&self) -> bool { + (self.refreshed_at..self.next_refresh_at).contains(&Utc::now()) + } + + /// Whether a refreshed registry replaces the cached one: a newer version, or the cached + /// registry verified again. + fn is_superseded_by(&self, refreshed: &CircuitVerificationKeyRegistry) -> bool { + refreshed.version > self.registry.version || *refreshed == self.registry + } + + /// Keep the cached registry after a failed refresh, to be refreshed again once the delay + /// elapsed. + fn postpone_refresh(&mut self, delay_in_seconds: i64) { + self.refreshed_at = Utc::now(); + self.next_refresh_at = self.refreshed_at + TimeDelta::seconds(delay_in_seconds); + } + + /// Seconds elapsed since the cached registry was last verified. + fn verified_age_in_seconds(&self) -> i64 { + (Utc::now() - self.verified_at).num_seconds() + } +} + +/// A [CircuitVerificationKeyCertifier] decorating a [MithrilCircuitVerificationKeyCertifier] with +/// a cache of the verified registry for [REGISTRY_CACHE_TIME_TO_LIVE_IN_SECONDS]. +/// +/// Once elapsed, the registry is obtained again from the decorated certifier, so a registry +/// updated while the node runs (e.g. a revocation) is picked up without a restart. A refresh +/// that fails, or yields a registry that is not newer than the cached one, is logged and keeps +/// the cached registry until a retry after [REGISTRY_REFRESH_RETRY_DELAY_IN_SECONDS], so an +/// outage of the registry source does not stop a running node. The node fails closed without +/// any verified registry, and once the cached registry could not be refreshed for +/// [REGISTRY_VERIFICATION_MAXIMUM_AGE_IN_SECONDS], so the outage cannot hide a revocation +/// indefinitely. +pub struct CachedCircuitVerificationKeyCertifier { + /// Certifier obtaining and verifying the registry. + certifier: MithrilCircuitVerificationKeyCertifier, + + /// Seconds a verified registry is served from the cache before a refresh. + cache_time_to_live_in_seconds: i64, + + /// Seconds before a failed refresh is retried. + refresh_retry_delay_in_seconds: i64, + + /// Maximum seconds since the last successful verification before failing closed. + verification_maximum_age_in_seconds: i64, + + /// The last verified registry with its refresh schedule. + verified_registry_cache: RwLock>, + + /// Logger. + logger: Logger, +} + +impl CachedCircuitVerificationKeyCertifier { + /// Build a caching decorator over the given certifier. + pub fn new(certifier: MithrilCircuitVerificationKeyCertifier, logger: Logger) -> Self { + Self { + certifier, + cache_time_to_live_in_seconds: REGISTRY_CACHE_TIME_TO_LIVE_IN_SECONDS, + refresh_retry_delay_in_seconds: REGISTRY_REFRESH_RETRY_DELAY_IN_SECONDS, + verification_maximum_age_in_seconds: REGISTRY_VERIFICATION_MAXIMUM_AGE_IN_SECONDS, + verified_registry_cache: RwLock::new(None), + logger: logger.new_with_component_name::(), + } + } + + #[cfg(test)] + fn with_cache_time_to_live_in_seconds(mut self, cache_time_to_live_in_seconds: i64) -> Self { + self.cache_time_to_live_in_seconds = cache_time_to_live_in_seconds; + self + } + + #[cfg(test)] + fn with_refresh_retry_delay_in_seconds(mut self, refresh_retry_delay_in_seconds: i64) -> Self { + self.refresh_retry_delay_in_seconds = refresh_retry_delay_in_seconds; + self + } + + #[cfg(test)] + fn with_verification_maximum_age_in_seconds( + mut self, + verification_maximum_age_in_seconds: i64, + ) -> Self { + self.verification_maximum_age_in_seconds = verification_maximum_age_in_seconds; + self + } + + /// Obtain the verified registry from the cache, refreshing it through the decorated + /// certifier once due. + async fn get_verified_registry(&self) -> StdResult { + { + let cache = self.verified_registry_cache.read().await; + if let Some(cache) = cache.as_ref() + && cache.is_fresh() + { + return Ok(cache.registry.clone()); + } + } + + let mut cache = self.verified_registry_cache.write().await; + if let Some(cache) = cache.as_ref() + && cache.is_fresh() + { + return Ok(cache.registry.clone()); + } + + let refreshed = self.certifier.get_verified_registry().await; + match cache.as_mut() { + Some(cache) => self.refresh_cached_registry(cache, refreshed), + None => { + let registry = refreshed?; + *cache = Some(VerifiedRegistryCache::verified( + registry.clone(), + self.cache_time_to_live_in_seconds, + )); + + Ok(registry) + } + } + } + + /// Replace the cached registry with the refreshed one when it supersedes it, otherwise keep + /// the cached registry until a retry, failing closed once it could not be refreshed for + /// longer than the maximum age of its verification. + fn refresh_cached_registry( + &self, + cache: &mut VerifiedRegistryCache, + refreshed: StdResult, + ) -> StdResult { + match refreshed { + Ok(registry) if cache.is_superseded_by(®istry) => { + *cache = + VerifiedRegistryCache::verified(registry, self.cache_time_to_live_in_seconds); + } + Ok(registry) => { + warn!( + self.logger, + "Refreshed circuit verification key registry is not newer than the cached one, keeping the cached registry"; + "refreshed_version" => registry.version, + "cached_version" => cache.registry.version, + ); + cache.postpone_refresh(self.refresh_retry_delay_in_seconds); + } + Err(error) => { + warn!( + self.logger, + "Circuit verification key registry refresh failed, keeping the cached registry"; + "error" => ?error, + "cached_version" => cache.registry.version, + "verified_age_in_seconds" => cache.verified_age_in_seconds(), + ); + cache.postpone_refresh(self.refresh_retry_delay_in_seconds); + if cache.verified_age_in_seconds() > self.verification_maximum_age_in_seconds { + return Err( + CircuitVerificationKeyCertifierError::RegistryRefreshOverdue { + age_in_seconds: cache.verified_age_in_seconds(), + maximum_age_in_seconds: self.verification_maximum_age_in_seconds, + source: error, + } + .into(), + ); + } + } + } + + Ok(cache.registry.clone()) + } +} + +#[cfg_attr(target_family = "wasm", async_trait(?Send))] +#[cfg_attr(not(target_family = "wasm"), async_trait)] +impl CircuitVerificationKeyCertifier for CachedCircuitVerificationKeyCertifier { + async fn check(&self, digests: &[CircuitVerificationKeyDigest], epoch: Epoch) -> StdResult<()> { + let registry = self.get_verified_registry().await?; + + MithrilCircuitVerificationKeyCertifier::certify(®istry, digests, epoch) + } +} + +#[cfg(test)] +mod tests { + use rand_chacha::ChaCha20Rng; + use rand_core::SeedableRng; + + use mithril_common::crypto_helper::{GenesisEd25519Signer, GenesisSigner}; + + use crate::retriever::MockCircuitVerificationKeyRegistryRetriever; + use crate::test::TestLogger; + use crate::test::double::FakeCircuitVerificationKeyRegistryRetriever; + use crate::{ + CircuitVerificationKeyEntry, CircuitVerificationKeyRegistryError, + CircuitVerificationKeyRegistryRetrieverError, CircuitVerificationKeyRejection, + CircuitVerificationKeyRejectionReason, CircuitVerificationKeyStatus, + SignedCircuitVerificationKeyRegistry, + }; + + use super::*; + + fn digest(seed: u8) -> CircuitVerificationKeyDigest { + hex::encode([seed; 32]).parse().unwrap() + } + + fn genesis_signer() -> GenesisSigner { + GenesisSigner::from_ed25519(GenesisEd25519Signer::create_deterministic_signer()) + } + + fn registry_allowing( + digests: &[CircuitVerificationKeyDigest], + ) -> CircuitVerificationKeyRegistry { + CircuitVerificationKeyRegistry { + version: 1, + entries: digests + .iter() + .map(|digest| CircuitVerificationKeyEntry { + digest: *digest, + name: "circuit".to_string(), + status: CircuitVerificationKeyStatus::Allowed, + start_epoch: Epoch(0), + end_epoch: None, + comment: None, + }) + .collect(), + } + } + + mod mithril_certifier { + use super::*; + + fn certifier_over( + registry: CircuitVerificationKeyRegistry, + genesis_signer: &GenesisSigner, + ) -> MithrilCircuitVerificationKeyCertifier { + let signed_registry = + SignedCircuitVerificationKeyRegistry::try_new(registry, genesis_signer).unwrap(); + MithrilCircuitVerificationKeyCertifier::new( + Arc::new( + FakeCircuitVerificationKeyRegistryRetriever::from_signed_registry( + signed_registry, + ), + ), + Arc::new(genesis_signer.create_verifier()), + ) + } + + #[tokio::test] + async fn check_succeeds_with_a_whitelisted_digest() { + let genesis_signer = genesis_signer(); + let certifier = certifier_over(registry_allowing(&[digest(1)]), &genesis_signer); + + certifier.check(&[digest(1)], Epoch(10)).await.unwrap(); + } + + #[tokio::test] + async fn check_propagates_registry_check_errors() { + let genesis_signer = genesis_signer(); + let certifier = certifier_over(registry_allowing(&[digest(1)]), &genesis_signer); + + let error = certifier.check(&[digest(9)], Epoch(10)).await.unwrap_err(); + + assert_eq!( + error.downcast_ref::(), + Some(&CircuitVerificationKeyRegistryError::Rejected { + epoch: Epoch(10), + rejections: vec![CircuitVerificationKeyRejection { + digest: digest(9), + reason: CircuitVerificationKeyRejectionReason::NotWhitelisted, + }], + }), + "the registry check error must be preserved, got: {error}" + ); + } + + #[tokio::test] + async fn check_fails_closed_when_retrieval_fails() { + let genesis_signer = genesis_signer(); + let certifier = MithrilCircuitVerificationKeyCertifier::new( + Arc::new(FakeCircuitVerificationKeyRegistryRetriever::that_fails()), + Arc::new(genesis_signer.create_verifier()), + ); + + let error = certifier.check(&[digest(1)], Epoch(10)).await.unwrap_err(); + + assert!( + matches!( + error.downcast_ref::(), + Some(CircuitVerificationKeyCertifierError::RegistryRetrieval(_)) + ), + "a retrieval failure must fail the check, got: {error}" + ); + } + + #[tokio::test] + async fn check_rejects_a_registry_signed_by_another_genesis_key() { + let genesis_signer = genesis_signer(); + let other_genesis_signer = GenesisSigner::from_ed25519( + GenesisEd25519Signer::create_test_signer(ChaCha20Rng::from_seed([7u8; 32])), + ); + let signed_registry = SignedCircuitVerificationKeyRegistry::try_new( + registry_allowing(&[digest(1)]), + &other_genesis_signer, + ) + .unwrap(); + let certifier = MithrilCircuitVerificationKeyCertifier::new( + Arc::new( + FakeCircuitVerificationKeyRegistryRetriever::from_signed_registry( + signed_registry, + ), + ), + Arc::new(genesis_signer.create_verifier()), + ); + + let error = certifier.check(&[digest(1)], Epoch(10)).await.unwrap_err(); + + assert!( + matches!( + error.downcast_ref::(), + Some(CircuitVerificationKeyCertifierError::InvalidRegistrySignature(_)) + ), + "a registry signed by another genesis key must be rejected, got: {error}" + ); + } + + #[tokio::test] + async fn check_retrieves_and_verifies_the_registry_at_every_use() { + let genesis_signer = genesis_signer(); + let signed_registry = SignedCircuitVerificationKeyRegistry::try_new( + registry_allowing(&[digest(1)]), + &genesis_signer, + ) + .unwrap(); + let mut registry_retriever = MockCircuitVerificationKeyRegistryRetriever::new(); + registry_retriever + .expect_retrieve_signed_registry() + .times(2) + .returning(move || Ok(signed_registry.clone())); + let certifier = MithrilCircuitVerificationKeyCertifier::new( + Arc::new(registry_retriever), + Arc::new(genesis_signer.create_verifier()), + ); + + certifier.check(&[digest(1)], Epoch(10)).await.unwrap(); + certifier.check(&[digest(1)], Epoch(11)).await.unwrap(); + } + } + + mod cached_certifier { + use super::*; + + fn cached_certifier_over_retriever( + registry_retriever: MockCircuitVerificationKeyRegistryRetriever, + genesis_signer: &GenesisSigner, + ) -> CachedCircuitVerificationKeyCertifier { + CachedCircuitVerificationKeyCertifier::new( + MithrilCircuitVerificationKeyCertifier::new( + Arc::new(registry_retriever), + Arc::new(genesis_signer.create_verifier()), + ), + TestLogger::stdout(), + ) + } + + fn retriever_returning( + responses: Vec< + Result< + SignedCircuitVerificationKeyRegistry, + CircuitVerificationKeyRegistryRetrieverError, + >, + >, + ) -> MockCircuitVerificationKeyRegistryRetriever { + let mut registry_retriever = MockCircuitVerificationKeyRegistryRetriever::new(); + for response in responses { + registry_retriever + .expect_retrieve_signed_registry() + .times(1) + .return_once(move || response); + } + + registry_retriever + } + + fn signed( + registry: CircuitVerificationKeyRegistry, + genesis_signer: &GenesisSigner, + ) -> SignedCircuitVerificationKeyRegistry { + SignedCircuitVerificationKeyRegistry::try_new(registry, genesis_signer).unwrap() + } + + fn registry_allowing_at_version( + version: u64, + digests: &[CircuitVerificationKeyDigest], + ) -> CircuitVerificationKeyRegistry { + CircuitVerificationKeyRegistry { + version, + ..registry_allowing(digests) + } + } + + fn retrieval_failure() -> CircuitVerificationKeyRegistryRetrieverError { + CircuitVerificationKeyRegistryRetrieverError(anyhow!("registry source unreachable")) + } + + #[tokio::test] + async fn check_retrieves_and_verifies_the_registry_only_once_within_the_time_to_live() { + let genesis_signer = genesis_signer(); + let signed_registry = SignedCircuitVerificationKeyRegistry::try_new( + registry_allowing(&[digest(1)]), + &genesis_signer, + ) + .unwrap(); + let mut registry_retriever = MockCircuitVerificationKeyRegistryRetriever::new(); + registry_retriever + .expect_retrieve_signed_registry() + .times(1) + .return_once(move || Ok(signed_registry)); + let certifier = cached_certifier_over_retriever(registry_retriever, &genesis_signer); + + certifier.check(&[digest(1)], Epoch(10)).await.unwrap(); + certifier.check(&[digest(1)], Epoch(11)).await.unwrap(); + } + + #[test] + fn a_cache_is_fresh_until_its_next_refresh() { + let cache = VerifiedRegistryCache::verified(registry_allowing(&[digest(1)]), 3600); + assert!(cache.is_fresh()); + + let cache = VerifiedRegistryCache::verified(registry_allowing(&[digest(1)]), 0); + assert!(!cache.is_fresh(), "a cache due for refresh must be stale"); + } + + #[test] + fn a_cache_refreshed_in_the_future_is_stale() { + let cache = VerifiedRegistryCache::verified(registry_allowing(&[digest(1)]), 3600); + let cache = VerifiedRegistryCache { + refreshed_at: cache.refreshed_at + TimeDelta::hours(2), + next_refresh_at: cache.next_refresh_at + TimeDelta::hours(2), + ..cache + }; + + assert!( + !cache.is_fresh(), + "a cache refreshed in the future (backwards clock jump) must be stale" + ); + } + + #[test] + fn a_cache_is_superseded_by_a_newer_version_or_by_its_registry_verified_again() { + let cache = + VerifiedRegistryCache::verified(registry_allowing_at_version(2, &[digest(1)]), 10); + + assert!(cache.is_superseded_by(®istry_allowing_at_version(3, &[digest(1)]))); + assert!(cache.is_superseded_by(®istry_allowing_at_version(2, &[digest(1)]))); + assert!(!cache.is_superseded_by(®istry_allowing_at_version(2, &[digest(2)]))); + assert!(!cache.is_superseded_by(®istry_allowing_at_version(1, &[digest(1)]))); + } + + #[tokio::test] + async fn check_refreshes_the_registry_after_the_cache_time_to_live_expires() { + let genesis_signer = genesis_signer(); + let signed_registry = SignedCircuitVerificationKeyRegistry::try_new( + registry_allowing(&[digest(1)]), + &genesis_signer, + ) + .unwrap(); + let mut registry_retriever = MockCircuitVerificationKeyRegistryRetriever::new(); + registry_retriever + .expect_retrieve_signed_registry() + .times(2) + .returning(move || Ok(signed_registry.clone())); + let certifier = cached_certifier_over_retriever(registry_retriever, &genesis_signer) + .with_cache_time_to_live_in_seconds(-1); + + certifier.check(&[digest(1)], Epoch(10)).await.unwrap(); + certifier.check(&[digest(1)], Epoch(11)).await.unwrap(); + } + + #[tokio::test] + async fn check_keeps_the_previously_verified_registry_over_a_refreshed_lower_version() { + let genesis_signer = genesis_signer(); + let mut newer_registry = registry_allowing(&[digest(1), digest(2)]); + newer_registry.version = 2; + let newer_signed_registry = + SignedCircuitVerificationKeyRegistry::try_new(newer_registry, &genesis_signer) + .unwrap(); + let older_signed_registry = SignedCircuitVerificationKeyRegistry::try_new( + registry_allowing(&[digest(1)]), + &genesis_signer, + ) + .unwrap(); + let mut registry_retriever = MockCircuitVerificationKeyRegistryRetriever::new(); + registry_retriever + .expect_retrieve_signed_registry() + .times(1) + .return_once(move || Ok(newer_signed_registry)); + registry_retriever + .expect_retrieve_signed_registry() + .times(1) + .return_once(move || Ok(older_signed_registry)); + let certifier = cached_certifier_over_retriever(registry_retriever, &genesis_signer) + .with_cache_time_to_live_in_seconds(-1); + + certifier.check(&[digest(2)], Epoch(10)).await.unwrap(); + certifier + .check(&[digest(2)], Epoch(11)) + .await + .expect("the newer registry must be kept over a refreshed lower version"); + } + + #[tokio::test] + async fn check_keeps_the_previously_verified_registry_when_the_refresh_fails() { + let genesis_signer = genesis_signer(); + let signed_registry = SignedCircuitVerificationKeyRegistry::try_new( + registry_allowing(&[digest(1)]), + &genesis_signer, + ) + .unwrap(); + let mut registry_retriever = MockCircuitVerificationKeyRegistryRetriever::new(); + registry_retriever + .expect_retrieve_signed_registry() + .times(1) + .return_once(move || Ok(signed_registry)); + registry_retriever + .expect_retrieve_signed_registry() + .times(1) + .return_once(|| { + Err(CircuitVerificationKeyRegistryRetrieverError(anyhow!( + "registry source unreachable" + ))) + }); + let certifier = cached_certifier_over_retriever(registry_retriever, &genesis_signer) + .with_cache_time_to_live_in_seconds(-1); + + certifier.check(&[digest(1)], Epoch(10)).await.unwrap(); + certifier + .check(&[digest(1)], Epoch(11)) + .await + .expect("the previously verified registry must be kept when the refresh fails"); + } + + #[tokio::test] + async fn check_replaces_the_cached_registry_with_a_refreshed_newer_version() { + let genesis_signer = genesis_signer(); + let registry_retriever = retriever_returning(vec![ + Ok(signed(registry_allowing(&[digest(1)]), &genesis_signer)), + Ok(signed( + registry_allowing_at_version(2, &[digest(2)]), + &genesis_signer, + )), + ]); + let certifier = cached_certifier_over_retriever(registry_retriever, &genesis_signer) + .with_cache_time_to_live_in_seconds(-1); + + certifier.check(&[digest(1)], Epoch(10)).await.unwrap(); + certifier + .check(&[digest(2)], Epoch(11)) + .await + .expect("the refreshed newer registry must replace the cached one"); + } + + #[tokio::test] + async fn check_keeps_the_cached_registry_over_a_refreshed_same_version_with_another_content() + { + let genesis_signer = genesis_signer(); + let registry_retriever = retriever_returning(vec![ + Ok(signed(registry_allowing(&[digest(1)]), &genesis_signer)), + Ok(signed( + registry_allowing(&[digest(1), digest(2)]), + &genesis_signer, + )), + ]); + let certifier = cached_certifier_over_retriever(registry_retriever, &genesis_signer) + .with_cache_time_to_live_in_seconds(-1); + + certifier.check(&[digest(1)], Epoch(10)).await.unwrap(); + certifier.check(&[digest(2)], Epoch(11)).await.expect_err( + "the cached registry must be kept over a refreshed same version with another content", + ); + } + + #[tokio::test] + async fn check_postpones_the_next_refresh_by_the_retry_delay_after_a_failed_refresh() { + let genesis_signer = genesis_signer(); + let registry_retriever = retriever_returning(vec![ + Ok(signed(registry_allowing(&[digest(1)]), &genesis_signer)), + Err(retrieval_failure()), + ]); + let certifier = cached_certifier_over_retriever(registry_retriever, &genesis_signer) + .with_cache_time_to_live_in_seconds(-1) + .with_refresh_retry_delay_in_seconds(3600); + + certifier.check(&[digest(1)], Epoch(10)).await.unwrap(); + certifier.check(&[digest(1)], Epoch(11)).await.unwrap(); + certifier.check(&[digest(1)], Epoch(12)).await.unwrap(); + } + + #[tokio::test] + async fn check_retries_a_failed_refresh_once_the_retry_delay_elapsed() { + let genesis_signer = genesis_signer(); + let registry_retriever = retriever_returning(vec![ + Ok(signed(registry_allowing(&[digest(1)]), &genesis_signer)), + Err(retrieval_failure()), + Ok(signed( + registry_allowing_at_version(2, &[digest(2)]), + &genesis_signer, + )), + ]); + let certifier = cached_certifier_over_retriever(registry_retriever, &genesis_signer) + .with_cache_time_to_live_in_seconds(-1) + .with_refresh_retry_delay_in_seconds(-1); + + certifier.check(&[digest(1)], Epoch(10)).await.unwrap(); + certifier.check(&[digest(1)], Epoch(11)).await.unwrap(); + certifier + .check(&[digest(2)], Epoch(12)) + .await + .expect("the registry must be refreshed again once the retry delay elapsed"); + } + + #[tokio::test] + async fn check_fails_closed_once_the_cached_registry_could_not_be_refreshed_for_too_long() { + let genesis_signer = genesis_signer(); + let registry_retriever = retriever_returning(vec![ + Ok(signed(registry_allowing(&[digest(1)]), &genesis_signer)), + Err(retrieval_failure()), + ]); + let certifier = cached_certifier_over_retriever(registry_retriever, &genesis_signer) + .with_cache_time_to_live_in_seconds(-1) + .with_verification_maximum_age_in_seconds(-1); + + certifier.check(&[digest(1)], Epoch(10)).await.unwrap(); + let error = certifier.check(&[digest(1)], Epoch(11)).await.unwrap_err(); + + assert!( + matches!( + error.downcast_ref::(), + Some(CircuitVerificationKeyCertifierError::RegistryRefreshOverdue { .. }) + ), + "a cached registry not refreshed for longer than the maximum age must fail the check, got: {error}" + ); + } + + #[tokio::test] + async fn check_fails_closed_without_a_previously_verified_registry() { + let genesis_signer = genesis_signer(); + let certifier = CachedCircuitVerificationKeyCertifier::new( + MithrilCircuitVerificationKeyCertifier::new( + Arc::new(FakeCircuitVerificationKeyRegistryRetriever::that_fails()), + Arc::new(genesis_signer.create_verifier()), + ), + TestLogger::stdout(), + ); + + let error = certifier.check(&[digest(1)], Epoch(10)).await.unwrap_err(); + + assert!( + matches!( + error.downcast_ref::(), + Some(CircuitVerificationKeyCertifierError::RegistryRetrieval(_)) + ), + "a failed retrieval without any verified registry must fail the check, got: {error}" + ); + } + } +} diff --git a/internal/mithril-circuit-key-registry/src/http_downloader.rs b/internal/mithril-circuit-key-registry/src/http_downloader.rs new file mode 100644 index 00000000000..a0efbfb475a --- /dev/null +++ b/internal/mithril-circuit-key-registry/src/http_downloader.rs @@ -0,0 +1,287 @@ +//! Bounded HTTP download of the documents involved in the circuit verification key registry +//! retrieval. + +use std::error::Error; + +use anyhow::{Context, anyhow}; +use futures::{Stream, StreamExt}; +use reqwest::Url; + +use mithril_common::StdResult; + +/// Maximum number of attempts of a download. +pub const DOWNLOAD_MAX_ATTEMPTS: usize = 3; + +/// Maximum size in bytes of a downloaded document, the memory bound on the documents served by +/// the untrusted routing URLs. +pub const DOWNLOAD_MAX_BODY_SIZE_IN_BYTES: u64 = 1024 * 1024; + +#[cfg(not(target_family = "wasm"))] +const DOWNLOAD_RETRY_DELAY_IN_MILLISECONDS: u64 = 1000; + +#[cfg(not(target_family = "wasm"))] +const DOWNLOAD_TIMEOUT_IN_SECONDS: u64 = 10; + +/// HTTP downloader bounding the request duration and the response size, and retrying failed +/// attempts. +pub struct BoundedHttpDownloader { + /// HTTP client of the downloads. + client: reqwest::Client, + + /// Whether a response served over plain HTTP is refused. + https_only: bool, +} + +impl BoundedHttpDownloader { + /// Build a downloader with a request timeout, so a hung download cannot stall certificate + /// verification, restricted to HTTPS so a redirect cannot downgrade a download to plain HTTP. + #[cfg(not(target_family = "wasm"))] + pub fn new() -> StdResult { + Self::build(true) + } + + /// Build a downloader also accepting plain HTTP, for local development and tests served by a + /// plain HTTP server: never use it in production. + #[cfg(not(target_family = "wasm"))] + pub fn new_allowing_plain_http() -> StdResult { + Self::build(false) + } + + /// Build a downloader with a request timeout, restricted to HTTPS when required. + #[cfg(not(target_family = "wasm"))] + fn build(https_only: bool) -> StdResult { + let client = reqwest::Client::builder() + .https_only(https_only) + .timeout(std::time::Duration::from_secs(DOWNLOAD_TIMEOUT_IN_SECONDS)) + .build() + .with_context(|| "Failed to build the HTTP client of the registry downloader")?; + + Ok(Self { client, https_only }) + } + + /// Build a downloader relying on the browser to bound the request duration, as the request + /// timeout builder is not available on WASM, and refusing a response served over plain HTTP, + /// as the HTTPS only builder is not available either. + #[cfg(target_family = "wasm")] + pub fn new() -> StdResult { + Ok(Self { + client: reqwest::Client::new(), + https_only: true, + }) + } + + /// Download the document at the given URL, retrying failed attempts up to + /// [DOWNLOAD_MAX_ATTEMPTS] times. + pub async fn download_with_retry(&self, url: &str) -> StdResult { + let mut attempts = 0; + loop { + attempts += 1; + match self.download(url).await { + Ok(document) => return Ok(document), + Err(_) if attempts < DOWNLOAD_MAX_ATTEMPTS => Self::wait_before_retry().await, + Err(error) => { + return Err(error.context(format!( + "Failed to download '{url}' after {DOWNLOAD_MAX_ATTEMPTS} attempts" + ))); + } + } + } + } + + /// Download the document at the given URL, failing on a non success status or a response + /// exceeding the size limit, which is enforced while the body is read so an oversized body + /// is never buffered. + async fn download(&self, url: &str) -> StdResult { + let response = self + .client + .get(url) + .send() + .await + .with_context(|| format!("Failed to download '{url}'"))?; + if self.https_only { + Self::check_response_url_is_https(url, response.url())?; + } + if !response.status().is_success() { + return Err(anyhow!( + "Failed to download '{url}': status {}", + response.status() + )); + } + Self::check_size_limit(url, response.content_length().unwrap_or_default())?; + let body = Self::read_body_within_size_limit(url, response.bytes_stream()).await?; + + String::from_utf8(body).with_context(|| format!("The response of '{url}' is not UTF-8")) + } + + /// Read the body chunks of the response of the URL, failing as soon as the bytes read exceed + /// [DOWNLOAD_MAX_BODY_SIZE_IN_BYTES]. + async fn read_body_within_size_limit, E: Error + Send + Sync + 'static>( + url: &str, + mut chunks: impl Stream> + Unpin, + ) -> StdResult> { + let mut body = Vec::new(); + while let Some(chunk) = chunks.next().await { + let chunk = chunk.with_context(|| format!("Failed to read the response of '{url}'"))?; + Self::check_size_limit(url, (body.len() + chunk.as_ref().len()) as u64)?; + body.extend_from_slice(chunk.as_ref()); + } + + Ok(body) + } + + /// Fail when the response was served over plain HTTP, so a redirect cannot downgrade a + /// download where the client cannot restrict the scheme itself. + fn check_response_url_is_https(url: &str, response_url: &Url) -> StdResult<()> { + if response_url.scheme() != "https" { + return Err(anyhow!( + "Failed to download '{url}': the response served from '{response_url}' is not over HTTPS" + )); + } + + Ok(()) + } + + /// Fail when the response size exceeds [DOWNLOAD_MAX_BODY_SIZE_IN_BYTES]. + fn check_size_limit(url: &str, size_in_bytes: u64) -> StdResult<()> { + if size_in_bytes > DOWNLOAD_MAX_BODY_SIZE_IN_BYTES { + return Err(anyhow!( + "Failed to download '{url}': response of {size_in_bytes} bytes exceeds the {DOWNLOAD_MAX_BODY_SIZE_IN_BYTES} bytes limit" + )); + } + + Ok(()) + } + + /// Wait for [DOWNLOAD_RETRY_DELAY_IN_MILLISECONDS] before the next download attempt. + #[cfg(not(target_family = "wasm"))] + async fn wait_before_retry() { + tokio::time::sleep(std::time::Duration::from_millis( + DOWNLOAD_RETRY_DELAY_IN_MILLISECONDS, + )) + .await; + } + + /// Retry immediately: no timer is available on WASM. + #[cfg(target_family = "wasm")] + async fn wait_before_retry() {} +} + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use futures::stream; + use httpmock::{Method, MockServer}; + + use super::*; + + #[tokio::test] + async fn downloads_a_document() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(Method::GET).path("/document"); + then.status(200).body("the document"); + }); + + let document = BoundedHttpDownloader::new_allowing_plain_http() + .unwrap() + .download_with_retry(&server.url("/document")) + .await + .unwrap(); + + assert_eq!("the document", document); + } + + #[tokio::test] + async fn refuses_to_download_over_plain_http() { + let server = MockServer::start(); + let document = server.mock(|when, then| { + when.method(Method::GET).path("/document"); + then.status(200).body("the document"); + }); + + BoundedHttpDownloader::new() + .unwrap() + .download_with_retry(&server.url("/document")) + .await + .expect_err("a download over plain HTTP must be refused"); + + assert_eq!(0, document.calls()); + } + + #[test] + fn accepts_a_response_served_over_https() { + BoundedHttpDownloader::check_response_url_is_https( + "https://example.com/document", + &Url::parse("https://example.com/redirected-document").unwrap(), + ) + .expect("a response served over HTTPS must be accepted"); + } + + #[test] + fn refuses_a_response_served_over_plain_http() { + BoundedHttpDownloader::check_response_url_is_https( + "https://example.com/document", + &Url::parse("http://example.com/redirected-document").unwrap(), + ) + .expect_err("a response served over plain HTTP must be refused"); + } + + #[tokio::test] + async fn fails_on_a_response_declaring_a_length_exceeding_the_body_size_limit() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(Method::GET).path("/document"); + then.status(200) + .body(" ".repeat(DOWNLOAD_MAX_BODY_SIZE_IN_BYTES as usize + 1)); + }); + + BoundedHttpDownloader::new_allowing_plain_http() + .unwrap() + .download_with_retry(&server.url("/document")) + .await + .expect_err("an oversized response must fail the download"); + } + + #[tokio::test] + async fn reads_the_body_chunks_within_the_size_limit() { + let chunks = + stream::iter([Ok::<_, reqwest::Error>(b"the ".to_vec()), Ok(b"document".to_vec())]); + + let body = BoundedHttpDownloader::read_body_within_size_limit( + "https://example.com/document", + chunks, + ) + .await + .unwrap(); + + assert_eq!(b"the document".to_vec(), body); + } + + #[tokio::test] + async fn fails_on_body_chunks_exceeding_the_size_limit_after_the_first_one() { + let chunks = stream::iter([ + Ok::<_, reqwest::Error>(vec![b' '; DOWNLOAD_MAX_BODY_SIZE_IN_BYTES as usize]), + Ok(vec![b' ']), + ]); + + BoundedHttpDownloader::read_body_within_size_limit("https://example.com/document", chunks) + .await + .expect_err("body chunks exceeding the size limit must fail the download"); + } + + #[tokio::test] + async fn retries_a_failed_download_up_to_the_maximum_attempts() { + let server = MockServer::start(); + let failing_document = server.mock(|when, then| { + when.method(Method::GET).path("/document"); + then.status(500); + }); + + BoundedHttpDownloader::new_allowing_plain_http() + .unwrap() + .download_with_retry(&server.url("/document")) + .await + .expect_err("a persistently failing download must fail"); + + assert_eq!(DOWNLOAD_MAX_ATTEMPTS, failing_document.calls()); + } +} diff --git a/internal/mithril-circuit-key-registry/src/lib.rs b/internal/mithril-circuit-key-registry/src/lib.rs new file mode 100644 index 00000000000..cb9c0dfe89c --- /dev/null +++ b/internal/mithril-circuit-key-registry/src/lib.rs @@ -0,0 +1,28 @@ +//! Genesis-signed registry of the circuit verification keys trusted for SNARK certificates. +//! +//! The registry holds one entry per circuit verification key digest, allowed over an inclusive +//! epoch range or revoked, e.g. after a circuit vulnerability. It is published in the repository +//! per network, retrieved at runtime and verified against the Ed25519 half of the genesis +//! verification key before use. + +#![warn(missing_docs)] + +#[cfg(feature = "future_snark")] +mod certifier; +#[cfg(feature = "future_snark")] +mod http_downloader; +#[cfg(feature = "future_snark")] +mod registry; +#[cfg(feature = "future_snark")] +mod retriever; +#[cfg(feature = "future_snark")] +pub mod test; + +#[cfg(feature = "future_snark")] +pub use certifier::*; +#[cfg(feature = "future_snark")] +pub use http_downloader::*; +#[cfg(feature = "future_snark")] +pub use registry::*; +#[cfg(feature = "future_snark")] +pub use retriever::*; diff --git a/mithril-common/src/crypto_helper/circuit_key_registry/registry.rs b/internal/mithril-circuit-key-registry/src/registry.rs similarity index 78% rename from mithril-common/src/crypto_helper/circuit_key_registry/registry.rs rename to internal/mithril-circuit-key-registry/src/registry.rs index e575ba7132f..be9e6788e28 100644 --- a/mithril-common/src/crypto_helper/circuit_key_registry/registry.rs +++ b/internal/mithril-circuit-key-registry/src/registry.rs @@ -6,11 +6,11 @@ use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; use thiserror::Error; -use mithril_stm::CircuitVerificationKeyDigest; - -use crate::StdResult; -use crate::crypto_helper::{GenesisEd25519Signature, GenesisSigner, GenesisVerifier}; -use crate::entities::Epoch; +use mithril_common::StdResult; +use mithril_common::crypto_helper::{ + CircuitVerificationKeyDigest, GenesisEd25519Signature, GenesisSigner, GenesisVerifier, +}; +use mithril_common::entities::Epoch; /// Errors raised when checking circuit verification key digests against a /// [CircuitVerificationKeyRegistry]. @@ -70,18 +70,19 @@ impl Display for CircuitVerificationKeyRejection { } } -/// Status of a circuit verification key entry over its epoch range. +/// Status of a circuit verification key entry. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum CircuitVerificationKeyStatus { /// The key may certify certificates whose epoch falls in the entry's range. Allowed, - /// Certificates produced with this key in the entry's range must be rejected. + /// The key is rejected for every epoch. Revoked, } -/// One statement about a circuit verification key, valid over an inclusive epoch range. +/// The single statement about a circuit verification key: allowed over an inclusive epoch +/// range, or revoked. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CircuitVerificationKeyEntry { /// Digest of the circuit verification key the statement is about. @@ -90,13 +91,14 @@ pub struct CircuitVerificationKeyEntry { /// Human readable label of the circuit, e.g. "certificate-circuit v2". pub name: String, - /// Whether the key is allowed or revoked over the entry's range. + /// Whether the key is allowed over the entry's range or revoked. pub status: CircuitVerificationKeyStatus, /// First epoch (inclusive) covered by the statement. pub start_epoch: Epoch, - /// Last epoch (inclusive) covered by the statement, open-ended when absent. + /// Last epoch (inclusive) covered by an allowed entry, open-ended when absent, or the + /// revocation epoch of a revoked entry. pub end_epoch: Option, /// Audit trail, e.g. the reason of a revocation. @@ -113,26 +115,27 @@ impl CircuitVerificationKeyEntry { /// Registry of the circuit verification keys trusted for SNARK certificates. /// /// The registry is scoped by the genesis key that signs it: each network publishes its own -/// registry, signed with its own genesis key. A digest absent from the registry is rejected -/// (whitelist semantics); a revoked entry rejects the epochs it covers even when an allowed -/// entry also covers them (revocation wins). +/// registry, signed with its own genesis key. It holds one entry per circuit verification key +/// digest: a digest absent from the registry is rejected (whitelist semantics), an allowed entry +/// accepts the epochs it covers, and a revoked entry rejects every epoch. A digest listed +/// several times is rejected as soon as one of its entries is revoked. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CircuitVerificationKeyRegistry { /// Monotonically increasing registry version, used for rollback protection. pub version: u64, - /// Statements about the circuit verification keys. + /// One statement per circuit verification key. pub entries: Vec, } impl CircuitVerificationKeyRegistry { - /// Check that every digest is whitelisted and not revoked for the given epoch, reporting - /// every rejected digest at once. + /// Check that every digest is allowed for the given epoch, reporting every rejected digest + /// at once. /// - /// A digest is rejected as [Revoked](CircuitVerificationKeyRejectionReason::Revoked) when - /// any revoked entry covers the epoch, and as - /// [NotWhitelisted](CircuitVerificationKeyRejectionReason::NotWhitelisted) when no allowed - /// entry covers it. + /// A digest is rejected as [Revoked](CircuitVerificationKeyRejectionReason::Revoked) when its + /// entry is revoked, and as + /// [NotWhitelisted](CircuitVerificationKeyRejectionReason::NotWhitelisted) when it has no + /// entry or its allowed entry does not cover the epoch. pub fn check( &self, digests: &[CircuitVerificationKeyDigest], @@ -150,23 +153,20 @@ impl CircuitVerificationKeyRegistry { } } - /// Find why the digest is rejected for the epoch, if it is: revocation wins over an allowed - /// entry covering the same epoch. + /// Find why the digest is rejected for the epoch, if it is: a revoked entry of the digest + /// wins over any allowed one, so a malformed registry listing a digest twice cannot certify + /// a revoked key. fn find_digest_rejection( &self, digest: &CircuitVerificationKeyDigest, epoch: Epoch, ) -> Option { - let is_revoked = self.has_covering_entry_with_status( - digest, - epoch, - CircuitVerificationKeyStatus::Revoked, - ); - let is_allowed = self.has_covering_entry_with_status( - digest, - epoch, - CircuitVerificationKeyStatus::Allowed, - ); + let entries: Vec<&CircuitVerificationKeyEntry> = + self.entries.iter().filter(|entry| entry.digest == *digest).collect(); + let is_revoked = entries + .iter() + .any(|entry| entry.status == CircuitVerificationKeyStatus::Revoked); + let is_allowed = entries.iter().any(|entry| entry.covers(epoch)); let reason = match (is_revoked, is_allowed) { (true, _) => Some(CircuitVerificationKeyRejectionReason::Revoked), (false, false) => Some(CircuitVerificationKeyRejectionReason::NotWhitelisted), @@ -178,18 +178,6 @@ impl CircuitVerificationKeyRegistry { reason, }) } - - /// Whether an entry with the given status covers the digest for the epoch. - fn has_covering_entry_with_status( - &self, - digest: &CircuitVerificationKeyDigest, - epoch: Epoch, - status: CircuitVerificationKeyStatus, - ) -> bool { - self.entries - .iter() - .any(|entry| entry.digest == *digest && entry.covers(epoch) && entry.status == status) - } } /// Domain separation prefix of the registry genesis signature, so registry signatures can never @@ -219,11 +207,24 @@ impl SignedCircuitVerificationKeyRegistry { registry: CircuitVerificationKeyRegistry, genesis_signer: &GenesisSigner, ) -> StdResult { - let registry_json = serde_json::to_string_pretty(®istry)?; - let signature = genesis_signer.ed25519.sign(&Self::signable_bytes(®istry_json)); + Self::try_new_from_json(serde_json::to_string_pretty(®istry)?, genesis_signer) + } + + /// Sign the exact registry JSON with the Ed25519 half of the genesis signer. + /// + /// A tool that edits a published registry signs the JSON document it edited rather than a + /// re-serialization of the parsed registry, so the fields added by a future schema version + /// are not silently stripped from the re-signed registry. The whitespace surrounding the + /// document (e.g. the final newline of a file) is not part of the signed bytes. + pub fn try_new_from_json( + registry_json: String, + genesis_signer: &GenesisSigner, + ) -> StdResult { + let registry = RawValue::from_string(registry_json)?; + let signature = genesis_signer.ed25519.sign(&Self::signable_bytes(registry.get())); Ok(Self { - registry: RawValue::from_string(registry_json)?, + registry, signature, }) } @@ -233,10 +234,20 @@ impl SignedCircuitVerificationKeyRegistry { &self, genesis_verifier: &GenesisVerifier, ) -> StdResult { + Ok(serde_json::from_str( + self.verify_to_json(genesis_verifier)?, + )?) + } + + /// Verify the genesis signature and return the exact registry JSON bytes it covers. + /// + /// A tool that edits a published registry starts from these bytes rather than from the parsed + /// registry, so the fields added by a future schema version survive the edit. + pub fn verify_to_json(&self, genesis_verifier: &GenesisVerifier) -> StdResult<&str> { genesis_verifier .verify_ed25519(&Self::signable_bytes(self.registry.get()), &self.signature)?; - Ok(serde_json::from_str(self.registry.get())?) + Ok(self.registry.get()) } /// Parse the registry without verifying its signature, for displaying or testing purposes @@ -273,7 +284,7 @@ mod tests { use rand_chacha::ChaCha20Rng; use rand_core::SeedableRng; - use crate::crypto_helper::GenesisEd25519Signer; + use mithril_common::crypto_helper::GenesisEd25519Signer; use super::*; @@ -404,18 +415,47 @@ mod tests { } #[test] - fn revocation_wins_over_an_allowed_entry_covering_the_same_epoch() { + fn rejects_a_revoked_key_for_every_epoch() { + let registry = registry(vec![entry( + digest(1), + CircuitVerificationKeyStatus::Revoked, + 10, + Some(250), + )]); + + for epoch in [Epoch(5), Epoch(100), Epoch(300)] { + let error = registry.check(&[digest(1)], epoch).unwrap_err(); + + assert_eq!( + CircuitVerificationKeyRegistryError::Rejected { + epoch, + rejections: vec![rejection( + digest(1), + CircuitVerificationKeyRejectionReason::Revoked + )], + }, + error + ); + } + } + + #[test] + fn rejects_a_digest_listed_as_allowed_and_revoked() { let registry = registry(vec![ entry(digest(1), CircuitVerificationKeyStatus::Allowed, 10, None), - entry(digest(1), CircuitVerificationKeyStatus::Revoked, 250, None), + entry( + digest(1), + CircuitVerificationKeyStatus::Revoked, + 10, + Some(20), + ), ]); - registry.check(&[digest(1)], Epoch(249)).unwrap(); - let error = registry.check(&[digest(1)], Epoch(250)).unwrap_err(); + let error = registry.check(&[digest(1)], Epoch(15)).unwrap_err(); assert_eq!( CircuitVerificationKeyRegistryError::Rejected { - epoch: Epoch(250), + epoch: Epoch(15), rejections: vec![rejection( digest(1), CircuitVerificationKeyRejectionReason::Revoked @@ -499,6 +539,26 @@ mod tests { ); } + #[test] + fn signed_registry_json_surrounded_by_whitespace_verifies() { + let genesis_signer = + GenesisSigner::from_ed25519(GenesisEd25519Signer::create_deterministic_signer()); + let registry_json = format!( + "{}\n", + serde_json::to_string_pretty(®istry(vec![])).unwrap() + ); + + let signed_registry = SignedCircuitVerificationKeyRegistry::try_new_from_json( + registry_json, + &genesis_signer, + ) + .unwrap(); + + signed_registry.verify(&genesis_signer.create_verifier()).expect( + "the signature must cover the registry JSON without its surrounding whitespace", + ); + } + #[test] fn tampered_registry_fails_signature_verification() { let genesis_signer = diff --git a/internal/mithril-circuit-key-registry/src/retriever.rs b/internal/mithril-circuit-key-registry/src/retriever.rs new file mode 100644 index 00000000000..cb40cadc5d4 --- /dev/null +++ b/internal/mithril-circuit-key-registry/src/retriever.rs @@ -0,0 +1,307 @@ +//! Retrieval of the signed circuit verification key registry from its published source. + +#[cfg(not(target_family = "wasm"))] +use std::path::PathBuf; + +use anyhow::{Context, anyhow}; +use async_trait::async_trait; +use reqwest::Url; +use thiserror::Error; + +use mithril_common::{StdError, StdResult}; + +use crate::{BoundedHttpDownloader, SignedCircuitVerificationKeyRegistry}; + +/// [CircuitVerificationKeyRegistryRetriever] related errors. +#[derive(Debug, Error)] +#[error("Error when retrieving circuit verification key registry")] +pub struct CircuitVerificationKeyRegistryRetrieverError(#[source] pub StdError); + +/// Retrieves the signed circuit verification key registry published at the root of the repository. +/// +/// Implementations return the signed document unverified: the genesis signature and version +/// checks belong to the caller, so an untrusted transport cannot bypass them. +#[cfg_attr(test, mockall::automock)] +#[cfg_attr(target_family = "wasm", async_trait(?Send))] +#[cfg_attr(not(target_family = "wasm"), async_trait)] +pub trait CircuitVerificationKeyRegistryRetriever: Sync + Send { + /// Retrieve the signed registry from its source. + async fn retrieve_signed_registry( + &self, + ) -> Result; +} + +/// A [CircuitVerificationKeyRegistryRetriever] reading the signed registry JSON from a local file. +#[cfg(not(target_family = "wasm"))] +pub struct FileCircuitVerificationKeyRegistryRetriever { + registry_file_path: PathBuf, +} + +#[cfg(not(target_family = "wasm"))] +impl FileCircuitVerificationKeyRegistryRetriever { + /// Build a retriever reading the given signed registry JSON file. + pub fn new(registry_file_path: PathBuf) -> Self { + Self { registry_file_path } + } + + /// Read the signed registry JSON file and parse it. + fn read_and_parse_registry_file( + registry_file_path: &PathBuf, + ) -> StdResult { + let json = std::fs::read_to_string(registry_file_path).with_context(|| { + format!( + "Failed to read signed registry file at '{}'", + registry_file_path.display() + ) + })?; + serde_json::from_str(&json).with_context(|| { + format!( + "Failed to parse signed registry file at '{}'", + registry_file_path.display() + ) + }) + } +} + +#[cfg(not(target_family = "wasm"))] +#[async_trait] +impl CircuitVerificationKeyRegistryRetriever for FileCircuitVerificationKeyRegistryRetriever { + async fn retrieve_signed_registry( + &self, + ) -> Result + { + let registry_file_path = self.registry_file_path.clone(); + tokio::task::spawn_blocking(move || Self::read_and_parse_registry_file(®istry_file_path)) + .await + .map_err(|e| CircuitVerificationKeyRegistryRetrieverError(e.into()))? + .map_err(CircuitVerificationKeyRegistryRetrieverError) + } +} + +/// A [CircuitVerificationKeyRegistryRetriever] downloading the signed registry JSON from a URL. +pub struct HttpCircuitVerificationKeyRegistryRetriever { + registry_url: String, + downloader: BoundedHttpDownloader, +} + +impl HttpCircuitVerificationKeyRegistryRetriever { + /// Build a retriever downloading the signed registry from the given HTTPS URL: plain HTTP is + /// refused, so an on-path attacker cannot serve an outdated signed registry or block its + /// refreshes. + pub fn new(registry_url: String) -> StdResult { + Self::check_url_is_https(®istry_url)?; + + Ok(Self { + registry_url, + downloader: BoundedHttpDownloader::new()?, + }) + } + + /// Build a retriever also accepting a plain HTTP URL, for the tests served by a local server. + #[cfg(all(test, not(target_family = "wasm")))] + fn new_allowing_plain_http(registry_url: String) -> StdResult { + Ok(Self { + registry_url, + downloader: BoundedHttpDownloader::new_allowing_plain_http()?, + }) + } + + /// Fail unless the URL uses the HTTPS scheme. + fn check_url_is_https(registry_url: &str) -> StdResult<()> { + let url = Url::parse(registry_url).with_context(|| { + format!("Invalid circuit verification key registry URL '{registry_url}'") + })?; + if url.scheme() != "https" { + return Err(anyhow!( + "The circuit verification key registry URL '{registry_url}' must use HTTPS" + )); + } + + Ok(()) + } + + /// Download and parse the signed registry. + async fn download_registry(&self) -> StdResult { + let registry_json = self.downloader.download_with_retry(&self.registry_url).await?; + + serde_json::from_str(®istry_json).with_context(|| { + format!( + "Failed to parse signed registry downloaded from '{}'", + self.registry_url + ) + }) + } +} + +#[cfg_attr(target_family = "wasm", async_trait(?Send))] +#[cfg_attr(not(target_family = "wasm"), async_trait)] +impl CircuitVerificationKeyRegistryRetriever for HttpCircuitVerificationKeyRegistryRetriever { + async fn retrieve_signed_registry( + &self, + ) -> Result + { + self.download_registry() + .await + .map_err(CircuitVerificationKeyRegistryRetrieverError) + } +} + +/// A [CircuitVerificationKeyRegistryRetriever] for nodes without a configured registry source, +/// failing every retrieval so the certificates requiring the registry are rejected. +pub struct UnconfiguredCircuitVerificationKeyRegistryRetriever; + +#[cfg_attr(target_family = "wasm", async_trait(?Send))] +#[cfg_attr(not(target_family = "wasm"), async_trait)] +impl CircuitVerificationKeyRegistryRetriever + for UnconfiguredCircuitVerificationKeyRegistryRetriever +{ + async fn retrieve_signed_registry( + &self, + ) -> Result + { + Err(CircuitVerificationKeyRegistryRetrieverError(anyhow!( + "No circuit verification key registry source is configured" + ))) + } +} + +#[cfg(test)] +mod tests { + use httpmock::{Method, MockServer}; + + use mithril_common::crypto_helper::{GenesisEd25519Signer, GenesisSigner}; + use mithril_common::temp_dir_create; + + use crate::CircuitVerificationKeyRegistry; + + use super::*; + + fn genesis_signer() -> GenesisSigner { + GenesisSigner::from_ed25519(GenesisEd25519Signer::create_deterministic_signer()) + } + + fn signed_registry() -> SignedCircuitVerificationKeyRegistry { + SignedCircuitVerificationKeyRegistry::try_new( + CircuitVerificationKeyRegistry { + version: 1, + entries: vec![], + }, + &genesis_signer(), + ) + .unwrap() + } + + #[tokio::test] + async fn file_retriever_reads_a_signed_registry_json_file() { + let temp_dir = temp_dir_create!(); + let registry_file_path = temp_dir.join("signed-registry.json"); + let signed_registry = signed_registry(); + std::fs::write( + ®istry_file_path, + serde_json::to_string(&signed_registry).unwrap(), + ) + .unwrap(); + + let retrieved = FileCircuitVerificationKeyRegistryRetriever::new(registry_file_path) + .retrieve_signed_registry() + .await + .unwrap(); + + assert_eq!(signed_registry, retrieved); + } + + #[tokio::test] + async fn file_retriever_fails_on_a_missing_file() { + let temp_dir = temp_dir_create!(); + + FileCircuitVerificationKeyRegistryRetriever::new(temp_dir.join("missing.json")) + .retrieve_signed_registry() + .await + .expect_err("a missing registry file must fail retrieval"); + } + + #[tokio::test] + async fn file_retriever_fails_on_an_invalid_json_file() { + let temp_dir = temp_dir_create!(); + let registry_file_path = temp_dir.join("signed-registry.json"); + std::fs::write(®istry_file_path, "not a signed registry").unwrap(); + + FileCircuitVerificationKeyRegistryRetriever::new(registry_file_path) + .retrieve_signed_registry() + .await + .expect_err("an invalid registry file must fail retrieval"); + } + + #[tokio::test] + async fn http_retriever_downloads_a_signed_registry() { + let server = MockServer::start(); + let signed_registry = signed_registry(); + server.mock(|when, then| { + when.method(Method::GET).path("/registry.json"); + then.status(200) + .body(serde_json::to_string(&signed_registry).unwrap()); + }); + + let retrieved = HttpCircuitVerificationKeyRegistryRetriever::new_allowing_plain_http( + server.url("/registry.json"), + ) + .unwrap() + .retrieve_signed_registry() + .await + .unwrap(); + + assert_eq!(signed_registry, retrieved); + } + + #[tokio::test] + async fn http_retriever_fails_on_an_invalid_document() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(Method::GET).path("/registry.json"); + then.status(200).body("not a signed registry"); + }); + + HttpCircuitVerificationKeyRegistryRetriever::new_allowing_plain_http( + server.url("/registry.json"), + ) + .unwrap() + .retrieve_signed_registry() + .await + .expect_err("an invalid document must fail retrieval"); + } + + #[test] + fn http_retriever_accepts_an_https_registry_url() { + HttpCircuitVerificationKeyRegistryRetriever::new( + "https://example.com/registry.json".to_string(), + ) + .expect("an HTTPS registry URL must be accepted"); + } + + #[test] + fn http_retriever_refuses_a_plain_http_registry_url() { + assert!( + HttpCircuitVerificationKeyRegistryRetriever::new( + "http://example.com/registry.json".to_string() + ) + .is_err(), + "a plain HTTP registry URL must be refused" + ); + } + + #[test] + fn http_retriever_refuses_an_invalid_registry_url() { + assert!( + HttpCircuitVerificationKeyRegistryRetriever::new("not a url".to_string()).is_err(), + "an invalid registry URL must be refused" + ); + } + + #[tokio::test] + async fn unconfigured_retriever_fails_every_retrieval() { + UnconfiguredCircuitVerificationKeyRegistryRetriever + .retrieve_signed_registry() + .await + .expect_err("an unconfigured registry source must fail retrieval"); + } +} diff --git a/mithril-common/src/test/double/circuit_key_registry_retriever.rs b/internal/mithril-circuit-key-registry/src/test/double/circuit_key_registry_retriever.rs similarity index 98% rename from mithril-common/src/test/double/circuit_key_registry_retriever.rs rename to internal/mithril-circuit-key-registry/src/test/double/circuit_key_registry_retriever.rs index 7ff338b6781..695c6ea8955 100644 --- a/mithril-common/src/test/double/circuit_key_registry_retriever.rs +++ b/internal/mithril-circuit-key-registry/src/test/double/circuit_key_registry_retriever.rs @@ -4,7 +4,7 @@ use anyhow::anyhow; use async_trait::async_trait; -use crate::crypto_helper::{ +use crate::{ CircuitVerificationKeyRegistryRetriever, CircuitVerificationKeyRegistryRetrieverError, SignedCircuitVerificationKeyRegistry, }; diff --git a/internal/mithril-circuit-key-registry/src/test/double/mod.rs b/internal/mithril-circuit-key-registry/src/test/double/mod.rs new file mode 100644 index 00000000000..044aa5c2960 --- /dev/null +++ b/internal/mithril-circuit-key-registry/src/test/double/mod.rs @@ -0,0 +1,7 @@ +//! Test doubles +//! +//! Enable unit testing with controlled inputs and predictable behavior. + +mod circuit_key_registry_retriever; + +pub use circuit_key_registry_retriever::FakeCircuitVerificationKeyRegistryRetriever; diff --git a/internal/mithril-circuit-key-registry/src/test/mod.rs b/internal/mithril-circuit-key-registry/src/test/mod.rs new file mode 100644 index 00000000000..41a76a28d96 --- /dev/null +++ b/internal/mithril-circuit-key-registry/src/test/mod.rs @@ -0,0 +1,9 @@ +//! Test utilities. +//! +//! âš  Do not use in production code âš  +//! +//! This module provides in particular test doubles for the traits defined in this crate. + +pub mod double; + +mithril_common::define_test_logger!(); diff --git a/mithril-aggregator/Cargo.toml b/mithril-aggregator/Cargo.toml index 025ccf13883..4a3ba21c239 100644 --- a/mithril-aggregator/Cargo.toml +++ b/mithril-aggregator/Cargo.toml @@ -11,9 +11,18 @@ repository = { workspace = true } [features] default = ["rustls"] -rustls = ["mithril-common/rustls", "reqwest/rustls"] +rustls = [ + "mithril-common/rustls", + "reqwest/rustls", + "mithril-circuit-key-registry?/rustls", +] -future_snark = ["mithril-common/future_snark", "dep:ciborium"] +future_snark = [ + "mithril-common/future_snark", + "dep:ciborium", + "dep:mithril-circuit-key-registry", + "mithril-circuit-key-registry/future_snark", +] [dependencies] anyhow = { workspace = true } @@ -28,6 +37,7 @@ gcloud-storage = { version = "1.3.0", default-features = false, features = ["aut mithril-aggregator-client = { path = "../internal/mithril-aggregator-client" } mithril-cardano-node-chain = { path = "../internal/cardano-node/mithril-cardano-node-chain" } mithril-cardano-node-internal-database = { path = "../internal/cardano-node/mithril-cardano-node-internal-database" } +mithril-circuit-key-registry = { path = "../internal/mithril-circuit-key-registry", optional = true } mithril-cli-helper = { path = "../internal/mithril-cli-helper" } mithril-common = { path = "../mithril-common" } mithril-dmq = { path = "../internal/mithril-dmq" } diff --git a/mithril-aggregator/src/commands/circuit_key_registry_command.rs b/mithril-aggregator/src/commands/circuit_key_registry_command.rs new file mode 100644 index 00000000000..b542bc60126 --- /dev/null +++ b/mithril-aggregator/src/commands/circuit_key_registry_command.rs @@ -0,0 +1,385 @@ +use std::{collections::HashMap, path::PathBuf}; + +use anyhow::Context; +use clap::{Parser, Subcommand}; +use slog::{Logger, debug}; + +use mithril_circuit_key_registry::{CircuitVerificationKeyEntry, CircuitVerificationKeyStatus}; +use mithril_common::{ + StdResult, + crypto_helper::CircuitVerificationKeyDigest, + entities::{Epoch, HexEncodedGenesisSecretKey, ProtocolParameters}, +}; +use mithril_doc::StructDoc; + +use crate::{extract_all, tools::CircuitKeyRegistryTools}; + +/// Circuit verification key registry tools +#[derive(Parser, Debug, Clone)] +pub struct CircuitKeyRegistryCommand { + /// commands + #[clap(subcommand)] + pub circuit_key_registry_subcommand: CircuitKeyRegistrySubCommand, +} + +impl CircuitKeyRegistryCommand { + pub async fn execute(&self, root_logger: Logger) -> StdResult<()> { + self.circuit_key_registry_subcommand.execute(root_logger).await + } + + pub fn extract_config(command_path: String) -> HashMap { + extract_all!( + command_path, + CircuitKeyRegistrySubCommand, + Export = { ExportCircuitKeyRegistrySubCommand }, + Whitelist = { WhitelistCircuitKeyRegistrySubCommand }, + Expire = { ExpireCircuitKeyRegistrySubCommand }, + Revoke = { RevokeCircuitKeyRegistrySubCommand }, + Sign = { SignCircuitKeyRegistrySubCommand }, + Bootstrap = { BootstrapCircuitKeyRegistrySubCommand }, + ) + } + + /// Parse protocol parameters from their JSON representation. + fn parse_protocol_parameters(value: &str) -> Result { + serde_json::from_str(value) + .map_err(|error| format!("invalid protocol parameters JSON: {error}")) + } +} + +/// Circuit verification key registry commands. +#[derive(Debug, Clone, Subcommand)] +pub enum CircuitKeyRegistrySubCommand { + /// Circuit verification key digests export command. + Export(ExportCircuitKeyRegistrySubCommand), + + /// Circuit verification key whitelist command. + Whitelist(WhitelistCircuitKeyRegistrySubCommand), + + /// Circuit verification key expire command. + Expire(ExpireCircuitKeyRegistrySubCommand), + + /// Circuit verification key revoke command. + Revoke(RevokeCircuitKeyRegistrySubCommand), + + /// Circuit verification key registry sign command. + Sign(SignCircuitKeyRegistrySubCommand), + + /// Circuit verification key registry bootstrap command (test only). + Bootstrap(BootstrapCircuitKeyRegistrySubCommand), +} + +impl CircuitKeyRegistrySubCommand { + pub async fn execute(&self, root_logger: Logger) -> StdResult<()> { + match self { + Self::Export(cmd) => cmd.execute(root_logger).await, + Self::Whitelist(cmd) => cmd.execute(root_logger).await, + Self::Expire(cmd) => cmd.execute(root_logger).await, + Self::Revoke(cmd) => cmd.execute(root_logger).await, + Self::Sign(cmd) => cmd.execute(root_logger).await, + Self::Bootstrap(cmd) => cmd.execute(root_logger).await, + } + } +} + +/// Circuit verification key digests export command +#[derive(Parser, Debug, Clone)] +pub struct ExportCircuitKeyRegistrySubCommand { + /// Protocol parameters of the network as JSON (e.g. '{"k":5,"m":9,"phi_f":0.95}'), defaults to + /// the production protocol parameters of the embedded certificate circuit key + #[clap(long, value_parser = CircuitKeyRegistryCommand::parse_protocol_parameters)] + protocol_parameters: Option, + + /// Target Path + #[clap(long)] + target_path: PathBuf, +} + +impl ExportCircuitKeyRegistrySubCommand { + pub async fn execute(&self, root_logger: Logger) -> StdResult<()> { + debug!(root_logger, "EXPORT CIRCUIT KEY REGISTRY command"); + println!( + "Circuit verification key digests export to {}", + self.target_path.display() + ); + + let digests = CircuitKeyRegistryTools::export_digests( + self.protocol_parameters.as_ref(), + &self.target_path, + ) + .with_context(|| "circuit-key-registry-tools: export digests error")?; + println!("certificate-circuit: {}", digests.certificate_circuit); + println!("ivc-circuit: {}", digests.ivc_circuit); + + Ok(()) + } + + pub fn extract_config(_parent: String) -> HashMap { + HashMap::new() + } +} + +/// Circuit verification key whitelist command +#[derive(Parser, Debug, Clone)] +pub struct WhitelistCircuitKeyRegistrySubCommand { + /// Signed Registry Path, updated in place + #[clap(long)] + registry_path: PathBuf, + + /// Genesis Secret Key Path + #[clap(long)] + genesis_secret_key_path: PathBuf, + + /// Digest of the circuit verification key (hex encoded) + #[clap(long)] + digest: CircuitVerificationKeyDigest, + + /// Name of the circuit verification key (e.g. 'certificate-circuit v1') + #[clap(long)] + name: String, + + /// First epoch (inclusive) at which the key is allowed + #[clap(long)] + start_epoch: u64, + + /// Last epoch (inclusive) at which the key is allowed, open-ended when omitted + #[clap(long)] + end_epoch: Option, + + /// Comment recorded in the entry + #[clap(long)] + comment: Option, +} + +impl WhitelistCircuitKeyRegistrySubCommand { + pub async fn execute(&self, root_logger: Logger) -> StdResult<()> { + debug!(root_logger, "WHITELIST CIRCUIT KEY REGISTRY command"); + println!( + "Circuit verification key '{}' whitelist in {}", + self.name, + self.registry_path.display() + ); + + let entry = CircuitVerificationKeyEntry { + digest: self.digest, + name: self.name.clone(), + status: CircuitVerificationKeyStatus::Allowed, + start_epoch: Epoch(self.start_epoch), + end_epoch: self.end_epoch.map(Epoch), + comment: self.comment.clone(), + }; + let registry = CircuitKeyRegistryTools::add_entry( + &self.registry_path, + &self.genesis_secret_key_path, + entry, + ) + .with_context(|| "circuit-key-registry-tools: whitelist error")?; + println!( + "Circuit verification key registry version {} with {} entries signed and written to {}", + registry.version, + registry.entries.len(), + self.registry_path.display() + ); + + Ok(()) + } + + pub fn extract_config(_parent: String) -> HashMap { + HashMap::new() + } +} + +/// Circuit verification key expire command +#[derive(Parser, Debug, Clone)] +pub struct ExpireCircuitKeyRegistrySubCommand { + /// Signed Registry Path, updated in place + #[clap(long)] + registry_path: PathBuf, + + /// Genesis Secret Key Path + #[clap(long)] + genesis_secret_key_path: PathBuf, + + /// Digest of the allowed circuit verification key to expire (hex encoded) + #[clap(long)] + digest: CircuitVerificationKeyDigest, + + /// Last epoch (inclusive) at which the key is allowed + #[clap(long)] + end_epoch: u64, + + /// Comment recorded in the entry, kept as is when omitted + #[clap(long)] + comment: Option, +} + +impl ExpireCircuitKeyRegistrySubCommand { + pub async fn execute(&self, root_logger: Logger) -> StdResult<()> { + debug!(root_logger, "EXPIRE CIRCUIT KEY REGISTRY command"); + println!( + "Circuit verification key '{}' expiration at epoch {} in {}", + self.digest, + self.end_epoch, + self.registry_path.display() + ); + + let registry = CircuitKeyRegistryTools::expire( + &self.registry_path, + &self.genesis_secret_key_path, + &self.digest, + Epoch(self.end_epoch), + self.comment.as_deref(), + ) + .with_context(|| "circuit-key-registry-tools: expire error")?; + println!( + "Circuit verification key registry version {} with {} entries signed and written to {}", + registry.version, + registry.entries.len(), + self.registry_path.display() + ); + + Ok(()) + } + + pub fn extract_config(_parent: String) -> HashMap { + HashMap::new() + } +} + +/// Circuit verification key revoke command +#[derive(Parser, Debug, Clone)] +pub struct RevokeCircuitKeyRegistrySubCommand { + /// Signed Registry Path, updated in place + #[clap(long)] + registry_path: PathBuf, + + /// Genesis Secret Key Path + #[clap(long)] + genesis_secret_key_path: PathBuf, + + /// Digest of the allowed circuit verification key to revoke (hex encoded) + #[clap(long)] + digest: CircuitVerificationKeyDigest, + + /// Epoch of the revocation, recorded in the entry (the key is rejected for every epoch) + #[clap(long)] + revocation_epoch: u64, + + /// Comment recorded in the entry, explaining the revocation + #[clap(long)] + comment: String, +} + +impl RevokeCircuitKeyRegistrySubCommand { + pub async fn execute(&self, root_logger: Logger) -> StdResult<()> { + debug!(root_logger, "REVOKE CIRCUIT KEY REGISTRY command"); + println!( + "Circuit verification key '{}' revocation in {}", + self.digest, + self.registry_path.display() + ); + + let registry = CircuitKeyRegistryTools::revoke( + &self.registry_path, + &self.genesis_secret_key_path, + &self.digest, + Epoch(self.revocation_epoch), + &self.comment, + ) + .with_context(|| "circuit-key-registry-tools: revoke error")?; + println!( + "Circuit verification key registry version {} with {} entries signed and written to {}", + registry.version, + registry.entries.len(), + self.registry_path.display() + ); + + Ok(()) + } + + pub fn extract_config(_parent: String) -> HashMap { + HashMap::new() + } +} + +/// Circuit verification key registry sign command +#[derive(Parser, Debug, Clone)] +pub struct SignCircuitKeyRegistrySubCommand { + /// To Sign Registry Path + #[clap(long)] + to_sign_registry_path: PathBuf, + + /// Target Signed Registry Path, replaced in place: the registry to sign must carry the version + /// following the signed registry found there, or the initial version when there is none + #[clap(long)] + target_signed_registry_path: PathBuf, + + /// Genesis Secret Key Path + #[clap(long)] + genesis_secret_key_path: PathBuf, +} + +impl SignCircuitKeyRegistrySubCommand { + pub async fn execute(&self, root_logger: Logger) -> StdResult<()> { + debug!(root_logger, "SIGN CIRCUIT KEY REGISTRY command"); + println!( + "Circuit verification key registry sign from {} to {}", + self.to_sign_registry_path.display(), + self.target_signed_registry_path.display() + ); + + CircuitKeyRegistryTools::sign( + &self.to_sign_registry_path, + &self.target_signed_registry_path, + &self.genesis_secret_key_path, + ) + .with_context(|| "circuit-key-registry-tools: sign registry error")?; + + Ok(()) + } + + pub fn extract_config(_parent: String) -> HashMap { + HashMap::new() + } +} + +/// Circuit verification key registry bootstrap command (test only) +#[derive(Parser, Debug, Clone)] +pub struct BootstrapCircuitKeyRegistrySubCommand { + /// Genesis Secret Key (test only) + #[clap(long, env = "GENESIS_SECRET_KEY")] + genesis_secret_key: HexEncodedGenesisSecretKey, + + /// Protocol parameters of the network as JSON (e.g. '{"k":5,"m":9,"phi_f":0.95}'), repeatable + /// to whitelist several parameter sets, defaults to the production protocol parameters of the + /// embedded certificate circuit key + #[clap(long, value_parser = CircuitKeyRegistryCommand::parse_protocol_parameters)] + protocol_parameters: Vec, + + /// Target Registry Path + #[clap(long)] + target_registry_path: PathBuf, +} + +impl BootstrapCircuitKeyRegistrySubCommand { + pub async fn execute(&self, root_logger: Logger) -> StdResult<()> { + debug!(root_logger, "BOOTSTRAP CIRCUIT KEY REGISTRY command"); + println!( + "Circuit verification key registry bootstrap for test only, to {}", + self.target_registry_path.display() + ); + + CircuitKeyRegistryTools::bootstrap( + &self.genesis_secret_key, + &self.protocol_parameters, + &self.target_registry_path, + ) + .with_context(|| "circuit-key-registry-tools: bootstrap registry error")?; + + Ok(()) + } + + pub fn extract_config(_parent: String) -> HashMap { + HashMap::new() + } +} diff --git a/mithril-aggregator/src/commands/mod.rs b/mithril-aggregator/src/commands/mod.rs index ee4c55bc268..0c91bb8052b 100644 --- a/mithril-aggregator/src/commands/mod.rs +++ b/mithril-aggregator/src/commands/mod.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "future_snark")] +mod circuit_key_registry_command; mod config_association; mod database_command; mod era_command; @@ -26,6 +28,8 @@ pub enum MainCommand { Tools(tools_command::ToolsCommand), Database(database_command::DatabaseCommand), ProtocolConfiguration(protocol_configuration_command::ProtocolConfigurationCommand), + #[cfg(feature = "future_snark")] + CircuitKeyRegistry(circuit_key_registry_command::CircuitKeyRegistryCommand), #[clap(alias("doc"), hide(true))] GenerateDoc(GenerateDocCommands), } @@ -51,6 +55,8 @@ impl MainCommand { Self::Tools(cmd) => cmd.execute(root_logger, config_builder).await, Self::Database(cmd) => cmd.execute(root_logger, config_builder).await, Self::ProtocolConfiguration(cmd) => cmd.execute(root_logger, config_builder).await, + #[cfg(feature = "future_snark")] + Self::CircuitKeyRegistry(cmd) => cmd.execute(root_logger).await, Self::GenerateDoc(cmd) => { let commands_configs = Self::extract_config(Self::format_crate_name_to_config_key()); @@ -62,18 +68,37 @@ impl MainCommand { } pub fn extract_config(command_path: String) -> HashMap { - extract_all!( - command_path, - MainCommand, - Database = { database_command::DatabaseCommand }, - Era = { era_command::EraCommand }, - Genesis = { genesis_command::GenesisCommand }, - Serve = { serve_command::ServeCommand }, - Tools = { tools_command::ToolsCommand }, - ProtocolConfiguration = - { protocol_configuration_command::ProtocolConfigurationCommand }, - GenerateDoc = {}, - ) + #[cfg(feature = "future_snark")] + { + extract_all!( + command_path, + MainCommand, + Database = { database_command::DatabaseCommand }, + Era = { era_command::EraCommand }, + Genesis = { genesis_command::GenesisCommand }, + Serve = { serve_command::ServeCommand }, + Tools = { tools_command::ToolsCommand }, + ProtocolConfiguration = + { protocol_configuration_command::ProtocolConfigurationCommand }, + CircuitKeyRegistry = { circuit_key_registry_command::CircuitKeyRegistryCommand }, + GenerateDoc = {}, + ) + } + #[cfg(not(feature = "future_snark"))] + { + extract_all!( + command_path, + MainCommand, + Database = { database_command::DatabaseCommand }, + Era = { era_command::EraCommand }, + Genesis = { genesis_command::GenesisCommand }, + Serve = { serve_command::ServeCommand }, + Tools = { tools_command::ToolsCommand }, + ProtocolConfiguration = + { protocol_configuration_command::ProtocolConfigurationCommand }, + GenerateDoc = {}, + ) + } } fn format_crate_name_to_config_key() -> String { @@ -88,6 +113,8 @@ impl MainCommand { MainCommand::Tools(_) => CommandType::CommandLine, MainCommand::Database(_) => CommandType::CommandLine, MainCommand::ProtocolConfiguration(_) => CommandType::CommandLine, + #[cfg(feature = "future_snark")] + MainCommand::CircuitKeyRegistry(_) => CommandType::CommandLine, MainCommand::GenerateDoc(_) => CommandType::CommandLine, } } diff --git a/mithril-aggregator/src/tools/circuit_key_registry.rs b/mithril-aggregator/src/tools/circuit_key_registry.rs new file mode 100644 index 00000000000..e434bf39735 --- /dev/null +++ b/mithril-aggregator/src/tools/circuit_key_registry.rs @@ -0,0 +1,1477 @@ +//! Tools for the circuit verification key registry: export the circuit key digests, whitelist, +//! expire and revoke circuit keys in a genesis-signed registry, sign and bootstrap it. + +use std::{ + collections::HashSet, + fs::{File, read_to_string, rename}, + io::Write, + path::Path, +}; + +use anyhow::{Context, anyhow}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use mithril_circuit_key_registry::{ + CircuitVerificationKeyEntry, CircuitVerificationKeyRegistry, CircuitVerificationKeyStatus, + SignedCircuitVerificationKeyRegistry, +}; +use mithril_common::{ + StdResult, + crypto_helper::{CircuitVerificationKeyDigest, GenesisSigner}, + entities::{Epoch, ProtocolParameters}, +}; + +/// Version of the first registry of a Mithril network. +const INITIAL_REGISTRY_VERSION: u64 = 1; + +/// Digests of the circuit verification keys a network signs with. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CircuitVerificationKeyDigests { + /// Digest of the certificate circuit verification key. + pub certificate_circuit: CircuitVerificationKeyDigest, + + /// Digest of the IVC circuit verification key. + pub ivc_circuit: CircuitVerificationKeyDigest, +} + +impl CircuitVerificationKeyDigests { + /// Compute the digests for the given protocol parameters, deriving the certificate circuit key + /// from the trusted setup when it is not cached yet, or using the embedded production + /// certificate circuit key when no parameters are given. + pub fn compute(protocol_parameters: Option<&ProtocolParameters>) -> StdResult { + let certificate_circuit = match protocol_parameters { + Some(parameters) => CircuitVerificationKeyDigest::compute_for_certificate_circuit( + ¶meters.clone().into(), + ) + .with_context(|| { + format!( + "Failed to compute the certificate circuit verification key digest for protocol parameters {parameters:?}" + ) + })?, + None => CircuitVerificationKeyDigest::for_production_certificate_circuit() + .with_context(|| { + "Failed to compute the production certificate circuit verification key digest" + })?, + }; + let ivc_circuit = CircuitVerificationKeyDigest::for_ivc_circuit() + .with_context(|| "Failed to compute the IVC circuit verification key digest")?; + + Ok(Self { + certificate_circuit, + ivc_circuit, + }) + } +} + +/// Circuit verification key registry tools. +pub struct CircuitKeyRegistryTools; + +impl CircuitKeyRegistryTools { + /// Export the circuit verification key digests for the given protocol parameters as a JSON + /// file. + pub fn export_digests( + protocol_parameters: Option<&ProtocolParameters>, + target_path: &Path, + ) -> StdResult { + let digests = CircuitVerificationKeyDigests::compute(protocol_parameters)?; + std::fs::write(target_path, serde_json::to_string_pretty(&digests)?).with_context( + || { + format!( + "Failed to write circuit verification key digests file at '{}'", + target_path.display() + ) + }, + )?; + + Ok(digests) + } + + /// Add an entry to the signed registry at the given path, then sign the incremented version + /// with the genesis secret key and write it back in place. An existing registry must carry a + /// valid signature of the same genesis key, a missing one is created at the initial version. + pub fn add_entry( + registry_path: &Path, + genesis_secret_key_path: &Path, + entry: CircuitVerificationKeyEntry, + ) -> StdResult { + let genesis_signer = GenesisSigner::read_from_file(genesis_secret_key_path)?; + let registry_json = match Self::read_signed_registry_json(registry_path, &genesis_signer)? { + Some(current_registry_json) => { + Self::extended_registry_json(¤t_registry_json, &entry)? + } + None => serde_json::to_string_pretty(&CircuitVerificationKeyRegistry { + version: INITIAL_REGISTRY_VERSION, + entries: vec![entry], + })?, + }; + + Self::check_sign_and_write_json(®istry_json, &genesis_signer, registry_path) + } + + /// Revoke the allowed circuit verification key with the given digest in the signed registry + /// at the given path: its entry becomes revoked at the given epoch with the given comment, + /// then the incremented version is signed with the genesis secret key and written back in + /// place. + pub fn revoke( + registry_path: &Path, + genesis_secret_key_path: &Path, + digest: &CircuitVerificationKeyDigest, + revocation_epoch: Epoch, + comment: &str, + ) -> StdResult { + Self::edit_allowed_entry(registry_path, genesis_secret_key_path, digest, |entry| { + entry.insert( + "status".to_string(), + json!(CircuitVerificationKeyStatus::Revoked), + ); + entry.insert("end_epoch".to_string(), json!(revocation_epoch)); + entry.insert("comment".to_string(), json!(comment)); + }) + } + + /// Expire the allowed circuit verification key with the given digest in the signed registry + /// at the given path: its entry ends at the given epoch, with the given comment when + /// provided, then the incremented version is signed with the genesis secret key and written + /// back in place. + pub fn expire( + registry_path: &Path, + genesis_secret_key_path: &Path, + digest: &CircuitVerificationKeyDigest, + end_epoch: Epoch, + comment: Option<&str>, + ) -> StdResult { + Self::edit_allowed_entry(registry_path, genesis_secret_key_path, digest, |entry| { + entry.insert("end_epoch".to_string(), json!(end_epoch)); + if let Some(comment) = comment { + entry.insert("comment".to_string(), json!(comment)); + } + }) + } + + /// Apply the edit to the allowed entry of the digest in the signed registry at the given + /// path, then sign the incremented version with the genesis secret key and write it back in + /// place. + fn edit_allowed_entry( + registry_path: &Path, + genesis_secret_key_path: &Path, + digest: &CircuitVerificationKeyDigest, + edit: impl FnOnce(&mut Map), + ) -> StdResult { + let genesis_signer = GenesisSigner::read_from_file(genesis_secret_key_path)?; + let current_registry_json = + Self::read_signed_registry_json(registry_path, &genesis_signer)?.ok_or_else(|| { + anyhow!( + "No signed registry at '{}': check the path", + registry_path.display() + ) + })?; + let registry_json = Self::edited_registry_json(¤t_registry_json, digest, edit)?; + + Self::check_sign_and_write_json(®istry_json, &genesis_signer, registry_path) + } + + /// Append the entry to the registry JSON, refusing a digest already listed, and increment + /// the version. + /// + /// The entry is appended to the JSON document rather than to the parsed registry, so the + /// fields added by a future schema version survive an entry added by an older binary. + fn extended_registry_json( + current_registry_json: &str, + entry: &CircuitVerificationKeyEntry, + ) -> StdResult { + let mut registry_value: Value = serde_json::from_str(current_registry_json)?; + let entries = Self::entries_mut(&mut registry_value)?; + if entries.iter().any(|listed| Self::has_digest(listed, &entry.digest)) { + return Err(anyhow!( + "The circuit verification key '{}' already has an entry in the registry", + entry.digest + )); + } + entries.push(serde_json::to_value(entry)?); + Self::increment_version(&mut registry_value)?; + + Ok(serde_json::to_string_pretty(®istry_value)?) + } + + /// Apply the edit to the allowed entry of the digest in the registry JSON and increment the + /// version. + fn edited_registry_json( + current_registry_json: &str, + digest: &CircuitVerificationKeyDigest, + edit: impl FnOnce(&mut Map), + ) -> StdResult { + let mut registry_value: Value = serde_json::from_str(current_registry_json)?; + let entry = Self::entries_mut(&mut registry_value)? + .iter_mut() + .find(|listed| Self::has_digest(listed, digest)) + .ok_or_else(|| { + anyhow!("The circuit verification key '{digest}' has no entry in the registry") + })?; + if entry.get("status") != Some(&json!(CircuitVerificationKeyStatus::Allowed)) { + return Err(anyhow!( + "The circuit verification key '{digest}' is not allowed in the registry" + )); + } + let entry_object = entry + .as_object_mut() + .ok_or_else(|| anyhow!("The registry entry of '{digest}' is not an object"))?; + edit(entry_object); + Self::increment_version(&mut registry_value)?; + + Ok(serde_json::to_string_pretty(®istry_value)?) + } + + /// Increment the version of the registry JSON. + fn increment_version(registry_value: &mut Value) -> StdResult<()> { + let registry_object = registry_value + .as_object_mut() + .ok_or_else(|| anyhow!("The signed registry JSON is not an object"))?; + let version = registry_object + .get("version") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow!("The signed registry JSON has no 'version' number"))?; + let incremented_version = version + .checked_add(1) + .ok_or_else(|| anyhow!("The registry version {version} cannot be incremented"))?; + registry_object.insert("version".to_string(), json!(incremented_version)); + + Ok(()) + } + + /// The entries array of the registry JSON. + fn entries_mut(registry_value: &mut Value) -> StdResult<&mut Vec> { + registry_value + .get_mut("entries") + .and_then(Value::as_array_mut) + .ok_or_else(|| anyhow!("The signed registry JSON has no 'entries' array")) + } + + /// Whether the entry JSON is about the given digest. + fn has_digest(entry: &Value, digest: &CircuitVerificationKeyDigest) -> bool { + entry.get("digest") == Some(&json!(digest)) + } + + /// Sign a circuit verification key registry with the Ed25519 half of the genesis signing key + /// and write the signed registry JSON, after verifying the produced signature. The registry + /// version must follow the version of the signed registry found at the target path, or be + /// the initial version when there is none, so a hand-authored registry cannot be published + /// with a version the running nodes would refuse, or keep over the later publications. + pub fn sign( + to_sign_registry_path: &Path, + target_signed_registry_path: &Path, + genesis_secret_key_path: &Path, + ) -> StdResult<()> { + let genesis_signer = GenesisSigner::read_from_file(genesis_secret_key_path)?; + let registry_json = read_to_string(to_sign_registry_path).with_context(|| { + format!( + "Failed to read registry file at '{}'", + to_sign_registry_path.display() + ) + })?; + let registry: CircuitVerificationKeyRegistry = serde_json::from_str(®istry_json) + .with_context(|| { + format!( + "Failed to parse registry file at '{}'", + to_sign_registry_path.display() + ) + })?; + Self::check_registry_can_be_signed(®istry)?; + Self::check_registry_version_follows_signed( + ®istry, + target_signed_registry_path, + &genesis_signer, + )?; + + Self::sign_and_write_json(®istry_json, &genesis_signer, target_signed_registry_path) + } + + /// Check that the registry version follows the version of the signed registry at the given + /// path, or is the initial version when no registry is signed there yet. + fn check_registry_version_follows_signed( + registry: &CircuitVerificationKeyRegistry, + signed_registry_path: &Path, + genesis_signer: &GenesisSigner, + ) -> StdResult<()> { + let expected_version = + match Self::read_signed_registry_json(signed_registry_path, genesis_signer)? { + Some(signed_registry_json) => { + let signed_registry: CircuitVerificationKeyRegistry = + serde_json::from_str(&signed_registry_json)?; + signed_registry.version.checked_add(1).ok_or_else(|| { + anyhow!( + "The registry version {} cannot be incremented", + signed_registry.version + ) + })? + } + None => INITIAL_REGISTRY_VERSION, + }; + if registry.version != expected_version { + return Err(anyhow!( + "The registry version {} must be {expected_version}: the version following the signed registry at '{}', or {INITIAL_REGISTRY_VERSION} when there is none", + registry.version, + signed_registry_path.display() + )); + } + + Ok(()) + } + + /// Create and sign the circuit verification key registry, whitelisting from epoch 0 the + /// certificate circuit key of every given protocol parameter set (or of the production + /// parameters when none is given) and the IVC circuit key, and write the signed registry + /// JSON. For test only. + pub fn bootstrap( + genesis_secret_key: &str, + protocol_parameters: &[ProtocolParameters], + target_registry_path: &Path, + ) -> StdResult<()> { + let genesis_signer = GenesisSigner::try_from_hex(genesis_secret_key) + .with_context(|| "hex decode of genesis secret key failure")?; + let registry = CircuitVerificationKeyRegistry { + version: INITIAL_REGISTRY_VERSION, + entries: Self::bootstrap_entries(&Self::compute_bootstrap_digests( + protocol_parameters, + CircuitVerificationKeyDigests::compute, + )?), + }; + + Self::sign_and_write_json( + &serde_json::to_string_pretty(®istry)?, + &genesis_signer, + target_registry_path, + ) + } + + /// Compute with the given function the named circuit verification key digests of each + /// protocol parameter set, or of the production parameters when none is given. + fn compute_bootstrap_digests( + protocol_parameters: &[ProtocolParameters], + compute_digests: impl Fn( + Option<&ProtocolParameters>, + ) -> StdResult, + ) -> StdResult> { + if protocol_parameters.is_empty() { + return Ok(vec![( + "certificate-circuit".to_string(), + compute_digests(None)?, + )]); + } + + protocol_parameters + .iter() + .map(|parameters| { + Ok(( + format!("certificate-circuit k={} m={}", parameters.k, parameters.m), + compute_digests(Some(parameters))?, + )) + }) + .collect() + } + + /// Build the entries allowing from epoch 0 each distinct certificate circuit key digest under + /// its name, then the IVC circuit key digest, which does not depend on the parameters. + fn bootstrap_entries( + named_digests: &[(String, CircuitVerificationKeyDigests)], + ) -> Vec { + let mut entries: Vec = Vec::new(); + for (name, digests) in named_digests { + if !entries + .iter() + .any(|entry| entry.digest == digests.certificate_circuit) + { + entries.push(Self::allowed_circuit_key_entry( + digests.certificate_circuit, + name, + )); + } + } + if let Some((_, digests)) = named_digests.first() { + entries.push(Self::allowed_circuit_key_entry( + digests.ivc_circuit, + "ivc-circuit", + )); + } + + entries + } + + /// Read the signed registry at the given path, verify it with the verifier of the genesis + /// signer and return the exact registry JSON bytes the signature covers, or return nothing + /// when the file does not exist. + fn read_signed_registry_json( + registry_path: &Path, + genesis_signer: &GenesisSigner, + ) -> StdResult> { + if !registry_path.exists() { + return Ok(None); + } + let signed_registry: SignedCircuitVerificationKeyRegistry = + serde_json::from_str(&read_to_string(registry_path).with_context(|| { + format!( + "Failed to read signed registry file at '{}'", + registry_path.display() + ) + })?) + .with_context(|| { + format!( + "Failed to parse signed registry file at '{}'", + registry_path.display() + ) + })?; + let registry_json = signed_registry + .verify_to_json(&genesis_signer.create_verifier()) + .with_context(|| { + format!( + "The signed registry at '{}' does not verify with the given genesis key", + registry_path.display() + ) + })?; + + Ok(Some(registry_json.to_string())) + } + + /// Check that a registry is well formed before signing it: each circuit verification key has + /// a single entry, and no allowed entry has an inverted epoch range (which would silently + /// never match). + fn check_registry_can_be_signed(registry: &CircuitVerificationKeyRegistry) -> StdResult<()> { + let mut digests = HashSet::new(); + for entry in ®istry.entries { + if !digests.insert(entry.digest) { + return Err(anyhow!( + "The circuit verification key '{}' has several entries", + entry.digest + )); + } + if entry.status == CircuitVerificationKeyStatus::Allowed + && let Some(end_epoch) = entry.end_epoch + && entry.start_epoch > end_epoch + { + return Err(anyhow!( + "The entry '{}' has an inverted epoch range ({} > {}), it would never match", + entry.name, + entry.start_epoch, + end_epoch + )); + } + } + + Ok(()) + } + + /// Check that the registry JSON can be signed, sign it with the genesis signer, write the + /// signed registry JSON at the given path and return the registry. + fn check_sign_and_write_json( + registry_json: &str, + genesis_signer: &GenesisSigner, + target_path: &Path, + ) -> StdResult { + let registry: CircuitVerificationKeyRegistry = serde_json::from_str(registry_json)?; + Self::check_registry_can_be_signed(®istry)?; + Self::sign_and_write_json(registry_json, genesis_signer, target_path)?; + + Ok(registry) + } + + /// Sign the exact registry JSON with the genesis signer, verify the produced signature and + /// write the signed registry JSON at the given path. + fn sign_and_write_json( + registry_json: &str, + genesis_signer: &GenesisSigner, + target_path: &Path, + ) -> StdResult<()> { + let signed_registry = SignedCircuitVerificationKeyRegistry::try_new_from_json( + registry_json.to_string(), + genesis_signer, + )?; + signed_registry + .verify(&genesis_signer.create_verifier()) + .with_context(|| "The produced registry signature does not verify")?; + Self::write_atomically( + target_path, + &serde_json::to_string_pretty(&signed_registry)?, + ) + .with_context(|| { + format!( + "Failed to write signed registry file at '{}'", + target_path.display() + ) + }) + } + + /// Write the contents at the given path atomically: a temporary file in the same directory is + /// filled and flushed to disk, then renamed over the target and the directory entry is + /// flushed too. + /// + /// The registry is updated in place on an air-gapped machine, so a crash during a plain + /// truncating write would destroy the only copy of the signed registry. + fn write_atomically(target_path: &Path, contents: &str) -> StdResult<()> { + let directory = match target_path.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent, + _ => Path::new("."), + }; + let temporary_path = target_path.with_extension("tmp"); + + let mut temporary_file = File::create(&temporary_path).with_context(|| { + format!( + "Failed to create temporary file at '{}'", + temporary_path.display() + ) + })?; + temporary_file.write_all(contents.as_bytes())?; + temporary_file.sync_all()?; + rename(&temporary_path, target_path).with_context(|| { + format!( + "Failed to rename '{}' to '{}'", + temporary_path.display(), + target_path.display() + ) + })?; + File::open(directory)?.sync_all()?; + + Ok(()) + } + + /// Build a registry entry allowing the given circuit verification key digest from epoch 0. + fn allowed_circuit_key_entry( + digest: CircuitVerificationKeyDigest, + name: &str, + ) -> CircuitVerificationKeyEntry { + CircuitVerificationKeyEntry { + digest, + name: name.to_string(), + status: CircuitVerificationKeyStatus::Allowed, + start_epoch: Epoch(0), + end_epoch: None, + comment: None, + } + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use mithril_common::{crypto_helper::GenesisEd25519Signer, test::TempDir}; + + use super::*; + + fn get_temp_dir(dir_name: &str) -> PathBuf { + TempDir::create("circuit_key_registry", dir_name) + } + + fn write_genesis_secret_key(temp_dir: &Path) -> (PathBuf, GenesisSigner) { + let genesis_signer = GenesisSigner::create_deterministic_signer(); + let genesis_secret_key_path = temp_dir.join("genesis.sk"); + genesis_signer.write_to_file(&genesis_secret_key_path).unwrap(); + + (genesis_secret_key_path, genesis_signer) + } + + fn read_signed_registry(path: &Path) -> SignedCircuitVerificationKeyRegistry { + serde_json::from_str(&read_to_string(path).unwrap()).unwrap() + } + + fn entry(digest_byte: u8, status: CircuitVerificationKeyStatus) -> CircuitVerificationKeyEntry { + CircuitVerificationKeyEntry { + digest: hex::encode([digest_byte; 32]).parse().unwrap(), + name: format!("circuit-{digest_byte}"), + status, + start_epoch: Epoch(10), + end_epoch: None, + comment: Some("a comment".to_string()), + } + } + + fn registry_with_allowed_keys( + temp_dir: &Path, + genesis_secret_key_path: &Path, + digest_bytes: &[u8], + ) -> PathBuf { + let registry_path = temp_dir.join("registry.json"); + for digest_byte in digest_bytes { + CircuitKeyRegistryTools::add_entry( + ®istry_path, + genesis_secret_key_path, + entry(*digest_byte, CircuitVerificationKeyStatus::Allowed), + ) + .unwrap(); + } + + registry_path + } + + mod export_digests { + use super::*; + + #[test] + fn exports_the_production_digests_without_protocol_parameters() { + let temp_dir = get_temp_dir("export_digests_production"); + let target_path = temp_dir.join("digests.json"); + + let digests = CircuitKeyRegistryTools::export_digests(None, &target_path).unwrap(); + + let expected = CircuitVerificationKeyDigests { + certificate_circuit: + CircuitVerificationKeyDigest::for_production_certificate_circuit().unwrap(), + ivc_circuit: CircuitVerificationKeyDigest::for_ivc_circuit().unwrap(), + }; + assert_eq!(expected, digests); + let exported: CircuitVerificationKeyDigests = + serde_json::from_str(&read_to_string(&target_path).unwrap()).unwrap(); + assert_eq!(expected, exported); + } + } + + mod add_entry { + use super::*; + + #[test] + fn creates_a_signed_registry_at_the_initial_version_when_missing() { + let temp_dir = get_temp_dir("add_entry_creates_registry"); + let (genesis_secret_key_path, genesis_signer) = write_genesis_secret_key(&temp_dir); + let registry_path = temp_dir.join("registry.json"); + let added_entry = entry(1, CircuitVerificationKeyStatus::Allowed); + + let registry = CircuitKeyRegistryTools::add_entry( + ®istry_path, + &genesis_secret_key_path, + added_entry.clone(), + ) + .unwrap(); + + assert_eq!(INITIAL_REGISTRY_VERSION, registry.version); + assert_eq!(vec![added_entry], registry.entries); + let verified_registry = read_signed_registry(®istry_path) + .verify(&genesis_signer.create_verifier()) + .expect("the written registry must carry a valid genesis signature"); + assert_eq!(registry, verified_registry); + } + + #[test] + fn appends_the_entry_and_increments_the_version_of_an_existing_registry() { + let temp_dir = get_temp_dir("add_entry_appends"); + let (genesis_secret_key_path, genesis_signer) = write_genesis_secret_key(&temp_dir); + let registry_path = temp_dir.join("registry.json"); + let first_entry = entry(1, CircuitVerificationKeyStatus::Allowed); + let second_entry = entry(2, CircuitVerificationKeyStatus::Allowed); + CircuitKeyRegistryTools::add_entry( + ®istry_path, + &genesis_secret_key_path, + first_entry.clone(), + ) + .unwrap(); + + let registry = CircuitKeyRegistryTools::add_entry( + ®istry_path, + &genesis_secret_key_path, + second_entry.clone(), + ) + .unwrap(); + + assert_eq!(INITIAL_REGISTRY_VERSION + 1, registry.version); + assert_eq!(vec![first_entry, second_entry], registry.entries); + let verified_registry = read_signed_registry(®istry_path) + .verify(&genesis_signer.create_verifier()) + .expect("the written registry must carry a valid genesis signature"); + assert_eq!(registry, verified_registry); + } + + #[test] + fn preserves_the_fields_of_a_future_registry_schema() { + let temp_dir = get_temp_dir("add_entry_preserves_future_fields"); + let (genesis_secret_key_path, genesis_signer) = write_genesis_secret_key(&temp_dir); + let registry_path = temp_dir.join("registry.json"); + let future_registry_json = json!({ + "version": INITIAL_REGISTRY_VERSION, + "network": "release-preprod", + "entries": [{ + "digest": hex::encode([1; 32]), + "name": "circuit-1", + "status": "allowed", + "start_epoch": 10, + "end_epoch": null, + "comment": null, + "issued_by": "a future entry field" + }] + }) + .to_string(); + let signed_registry = SignedCircuitVerificationKeyRegistry::try_new_from_json( + future_registry_json, + &genesis_signer, + ) + .unwrap(); + std::fs::write( + ®istry_path, + serde_json::to_string(&signed_registry).unwrap(), + ) + .unwrap(); + + CircuitKeyRegistryTools::add_entry( + ®istry_path, + &genesis_secret_key_path, + entry(2, CircuitVerificationKeyStatus::Allowed), + ) + .unwrap(); + + let re_signed_registry = read_signed_registry(®istry_path); + let registry_json = re_signed_registry + .verify_to_json(&genesis_signer.create_verifier()) + .expect("the re-signed registry must carry a valid genesis signature"); + let registry_value: Value = serde_json::from_str(registry_json).unwrap(); + assert_eq!( + Some("release-preprod"), + registry_value["network"].as_str(), + "a registry field of a future schema must survive the edit" + ); + assert_eq!( + Some("a future entry field"), + registry_value["entries"][0]["issued_by"].as_str(), + "an entry field of a future schema must survive the edit" + ); + assert_eq!( + Some(INITIAL_REGISTRY_VERSION + 1), + registry_value["version"].as_u64() + ); + } + + #[test] + fn fails_on_an_existing_registry_signed_by_another_genesis_key() { + let temp_dir = get_temp_dir("add_entry_rejects_other_key"); + let (genesis_secret_key_path, _) = write_genesis_secret_key(&temp_dir); + let registry_path = temp_dir.join("registry.json"); + let other_genesis_signer = GenesisSigner::from_ed25519( + GenesisEd25519Signer::create_non_deterministic_signer(), + ); + let other_signed_registry = SignedCircuitVerificationKeyRegistry::try_new( + CircuitVerificationKeyRegistry { + version: INITIAL_REGISTRY_VERSION, + entries: vec![], + }, + &other_genesis_signer, + ) + .unwrap(); + std::fs::write( + ®istry_path, + serde_json::to_string(&other_signed_registry).unwrap(), + ) + .unwrap(); + + CircuitKeyRegistryTools::add_entry( + ®istry_path, + &genesis_secret_key_path, + entry(1, CircuitVerificationKeyStatus::Allowed), + ) + .expect_err("a registry signed by another genesis key must not be extended"); + + assert_eq!( + other_signed_registry, + read_signed_registry(®istry_path), + "the registry file must be left untouched" + ); + } + + #[test] + fn fails_on_a_registry_version_that_cannot_be_incremented() { + let temp_dir = get_temp_dir("add_entry_version_overflow"); + let (genesis_secret_key_path, genesis_signer) = write_genesis_secret_key(&temp_dir); + let registry_path = temp_dir.join("registry.json"); + let signed_registry = SignedCircuitVerificationKeyRegistry::try_new( + CircuitVerificationKeyRegistry { + version: u64::MAX, + entries: vec![], + }, + &genesis_signer, + ) + .unwrap(); + std::fs::write( + ®istry_path, + serde_json::to_string(&signed_registry).unwrap(), + ) + .unwrap(); + + CircuitKeyRegistryTools::add_entry( + ®istry_path, + &genesis_secret_key_path, + entry(1, CircuitVerificationKeyStatus::Allowed), + ) + .expect_err("a registry version that cannot be incremented must be refused"); + + assert_eq!(signed_registry, read_signed_registry(®istry_path)); + } + + #[test] + fn fails_on_a_digest_already_listed() { + let temp_dir = get_temp_dir("add_entry_duplicate"); + let (genesis_secret_key_path, _) = write_genesis_secret_key(&temp_dir); + let registry_path = temp_dir.join("registry.json"); + CircuitKeyRegistryTools::add_entry( + ®istry_path, + &genesis_secret_key_path, + entry(1, CircuitVerificationKeyStatus::Allowed), + ) + .unwrap(); + let registry_before = read_signed_registry(®istry_path); + + CircuitKeyRegistryTools::add_entry( + ®istry_path, + &genesis_secret_key_path, + entry(1, CircuitVerificationKeyStatus::Allowed), + ) + .expect_err("a digest already listed must be refused"); + + assert_eq!(registry_before, read_signed_registry(®istry_path)); + } + + #[test] + fn fails_on_an_entry_with_an_inverted_epoch_range() { + let temp_dir = get_temp_dir("add_entry_inverted_range"); + let (genesis_secret_key_path, _) = write_genesis_secret_key(&temp_dir); + let registry_path = temp_dir.join("registry.json"); + + CircuitKeyRegistryTools::add_entry( + ®istry_path, + &genesis_secret_key_path, + CircuitVerificationKeyEntry { + start_epoch: Epoch(20), + end_epoch: Some(Epoch(10)), + ..entry(1, CircuitVerificationKeyStatus::Allowed) + }, + ) + .expect_err("an entry with an inverted epoch range must be rejected"); + + assert!(!registry_path.exists()); + } + } + + mod expire { + use super::*; + + #[test] + fn expires_an_allowed_key_in_place_and_increments_the_version() { + let temp_dir = get_temp_dir("expire"); + let (genesis_secret_key_path, genesis_signer) = write_genesis_secret_key(&temp_dir); + let registry_path = + registry_with_allowed_keys(&temp_dir, &genesis_secret_key_path, &[1, 2]); + + let registry = CircuitKeyRegistryTools::expire( + ®istry_path, + &genesis_secret_key_path, + &entry(2, CircuitVerificationKeyStatus::Allowed).digest, + Epoch(42), + Some("rotated to circuit-3"), + ) + .unwrap(); + + assert_eq!(INITIAL_REGISTRY_VERSION + 2, registry.version); + assert_eq!( + vec![ + entry(1, CircuitVerificationKeyStatus::Allowed), + CircuitVerificationKeyEntry { + end_epoch: Some(Epoch(42)), + comment: Some("rotated to circuit-3".to_string()), + ..entry(2, CircuitVerificationKeyStatus::Allowed) + }, + ], + registry.entries + ); + let verified_registry = read_signed_registry(®istry_path) + .verify(&genesis_signer.create_verifier()) + .expect("the written registry must carry a valid genesis signature"); + assert_eq!(registry, verified_registry); + } + + #[test] + fn keeps_the_comment_of_the_entry_when_none_is_given() { + let temp_dir = get_temp_dir("expire_keeps_comment"); + let (genesis_secret_key_path, _) = write_genesis_secret_key(&temp_dir); + let registry_path = + registry_with_allowed_keys(&temp_dir, &genesis_secret_key_path, &[1]); + + let registry = CircuitKeyRegistryTools::expire( + ®istry_path, + &genesis_secret_key_path, + &entry(1, CircuitVerificationKeyStatus::Allowed).digest, + Epoch(42), + None, + ) + .unwrap(); + + assert_eq!( + vec![CircuitVerificationKeyEntry { + end_epoch: Some(Epoch(42)), + ..entry(1, CircuitVerificationKeyStatus::Allowed) + }], + registry.entries + ); + } + + #[test] + fn fails_on_an_end_epoch_before_the_start_epoch() { + let temp_dir = get_temp_dir("expire_before_start"); + let (genesis_secret_key_path, _) = write_genesis_secret_key(&temp_dir); + let registry_path = + registry_with_allowed_keys(&temp_dir, &genesis_secret_key_path, &[1]); + let registry_before = read_signed_registry(®istry_path); + + CircuitKeyRegistryTools::expire( + ®istry_path, + &genesis_secret_key_path, + &entry(1, CircuitVerificationKeyStatus::Allowed).digest, + Epoch(5), + None, + ) + .expect_err("an end epoch before the start epoch must be refused"); + + assert_eq!(registry_before, read_signed_registry(®istry_path)); + } + + #[test] + fn fails_on_a_key_already_revoked() { + let temp_dir = get_temp_dir("expire_revoked"); + let (genesis_secret_key_path, _) = write_genesis_secret_key(&temp_dir); + let registry_path = + registry_with_allowed_keys(&temp_dir, &genesis_secret_key_path, &[1]); + let digest = entry(1, CircuitVerificationKeyStatus::Allowed).digest; + CircuitKeyRegistryTools::revoke( + ®istry_path, + &genesis_secret_key_path, + &digest, + Epoch(42), + "soundness issue", + ) + .unwrap(); + let registry_before = read_signed_registry(®istry_path); + + CircuitKeyRegistryTools::expire( + ®istry_path, + &genesis_secret_key_path, + &digest, + Epoch(43), + None, + ) + .expect_err("a revoked key must not be expired"); + + assert_eq!(registry_before, read_signed_registry(®istry_path)); + } + } + + mod revoke { + use super::*; + + #[test] + fn revokes_an_allowed_key_in_place_and_increments_the_version() { + let temp_dir = get_temp_dir("revoke"); + let (genesis_secret_key_path, genesis_signer) = write_genesis_secret_key(&temp_dir); + let registry_path = + registry_with_allowed_keys(&temp_dir, &genesis_secret_key_path, &[1, 2]); + + let registry = CircuitKeyRegistryTools::revoke( + ®istry_path, + &genesis_secret_key_path, + &entry(2, CircuitVerificationKeyStatus::Allowed).digest, + Epoch(42), + "soundness issue", + ) + .unwrap(); + + assert_eq!(INITIAL_REGISTRY_VERSION + 2, registry.version); + assert_eq!( + vec![ + entry(1, CircuitVerificationKeyStatus::Allowed), + CircuitVerificationKeyEntry { + end_epoch: Some(Epoch(42)), + comment: Some("soundness issue".to_string()), + ..entry(2, CircuitVerificationKeyStatus::Revoked) + }, + ], + registry.entries + ); + let verified_registry = read_signed_registry(®istry_path) + .verify(&genesis_signer.create_verifier()) + .expect("the written registry must carry a valid genesis signature"); + assert_eq!(registry, verified_registry); + } + + #[test] + fn revokes_a_key_before_its_start_epoch() { + let temp_dir = get_temp_dir("revoke_before_start"); + let (genesis_secret_key_path, _) = write_genesis_secret_key(&temp_dir); + let registry_path = + registry_with_allowed_keys(&temp_dir, &genesis_secret_key_path, &[1]); + + let registry = CircuitKeyRegistryTools::revoke( + ®istry_path, + &genesis_secret_key_path, + &entry(1, CircuitVerificationKeyStatus::Allowed).digest, + Epoch(5), + "revoked before use", + ) + .expect("a key must be revocable before its start epoch"); + + assert_eq!(Some(Epoch(5)), registry.entries[0].end_epoch); + } + + #[test] + fn fails_on_a_digest_without_entry() { + let temp_dir = get_temp_dir("revoke_unknown"); + let (genesis_secret_key_path, _) = write_genesis_secret_key(&temp_dir); + let registry_path = + registry_with_allowed_keys(&temp_dir, &genesis_secret_key_path, &[1]); + let registry_before = read_signed_registry(®istry_path); + + CircuitKeyRegistryTools::revoke( + ®istry_path, + &genesis_secret_key_path, + &entry(9, CircuitVerificationKeyStatus::Allowed).digest, + Epoch(42), + "soundness issue", + ) + .expect_err("a digest without entry must not be revoked"); + + assert_eq!(registry_before, read_signed_registry(®istry_path)); + } + + #[test] + fn fails_on_a_key_already_revoked() { + let temp_dir = get_temp_dir("revoke_twice"); + let (genesis_secret_key_path, _) = write_genesis_secret_key(&temp_dir); + let registry_path = + registry_with_allowed_keys(&temp_dir, &genesis_secret_key_path, &[1]); + let digest = entry(1, CircuitVerificationKeyStatus::Allowed).digest; + CircuitKeyRegistryTools::revoke( + ®istry_path, + &genesis_secret_key_path, + &digest, + Epoch(42), + "soundness issue", + ) + .unwrap(); + let registry_before = read_signed_registry(®istry_path); + + CircuitKeyRegistryTools::revoke( + ®istry_path, + &genesis_secret_key_path, + &digest, + Epoch(43), + "again", + ) + .expect_err("a key already revoked must not be revoked again"); + + assert_eq!(registry_before, read_signed_registry(®istry_path)); + } + + #[test] + fn fails_on_a_missing_registry() { + let temp_dir = get_temp_dir("revoke_missing_registry"); + let (genesis_secret_key_path, _) = write_genesis_secret_key(&temp_dir); + let registry_path = temp_dir.join("registry.json"); + + CircuitKeyRegistryTools::revoke( + ®istry_path, + &genesis_secret_key_path, + &entry(1, CircuitVerificationKeyStatus::Allowed).digest, + Epoch(42), + "soundness issue", + ) + .expect_err("a missing registry must not be created by a revocation"); + + assert!(!registry_path.exists()); + } + } + + mod sign { + use super::*; + + #[test] + fn signs_a_registry_and_writes_a_verifiable_signed_registry() { + let temp_dir = get_temp_dir("sign"); + let (genesis_secret_key_path, genesis_signer) = write_genesis_secret_key(&temp_dir); + let registry = CircuitVerificationKeyRegistry { + version: 1, + entries: vec![], + }; + let to_sign_registry_path = temp_dir.join("registry.json"); + let target_signed_registry_path = temp_dir.join("signed-registry.json"); + std::fs::write( + &to_sign_registry_path, + serde_json::to_string(®istry).unwrap(), + ) + .unwrap(); + + CircuitKeyRegistryTools::sign( + &to_sign_registry_path, + &target_signed_registry_path, + &genesis_secret_key_path, + ) + .unwrap(); + + let verified_registry = read_signed_registry(&target_signed_registry_path) + .verify(&genesis_signer.create_verifier()) + .expect("the written signed registry must carry a valid genesis signature"); + assert_eq!(registry, verified_registry); + } + + #[test] + fn signs_the_authored_bytes_without_dropping_the_fields_of_a_future_schema() { + let temp_dir = get_temp_dir("sign_preserves_future_fields"); + let (genesis_secret_key_path, genesis_signer) = write_genesis_secret_key(&temp_dir); + let to_sign_registry_path = temp_dir.join("registry.json"); + let target_signed_registry_path = temp_dir.join("signed-registry.json"); + let authored_registry_json = json!({ + "version": INITIAL_REGISTRY_VERSION, + "network": "release-preprod", + "entries": [] + }) + .to_string(); + std::fs::write(&to_sign_registry_path, &authored_registry_json).unwrap(); + + CircuitKeyRegistryTools::sign( + &to_sign_registry_path, + &target_signed_registry_path, + &genesis_secret_key_path, + ) + .unwrap(); + + let registry_json = read_signed_registry(&target_signed_registry_path) + .verify_to_json(&genesis_signer.create_verifier()) + .expect("the signed registry must carry a valid genesis signature") + .to_string(); + assert_eq!(authored_registry_json, registry_json); + } + + #[test] + fn signs_a_registry_file_ending_with_a_newline() { + let temp_dir = get_temp_dir("sign_trailing_newline"); + let (genesis_secret_key_path, genesis_signer) = write_genesis_secret_key(&temp_dir); + let to_sign_registry_path = temp_dir.join("registry.json"); + let target_signed_registry_path = temp_dir.join("signed-registry.json"); + let authored_registry_json = json!({ + "version": INITIAL_REGISTRY_VERSION, + "entries": [] + }) + .to_string(); + std::fs::write( + &to_sign_registry_path, + format!("{authored_registry_json}\n"), + ) + .unwrap(); + + CircuitKeyRegistryTools::sign( + &to_sign_registry_path, + &target_signed_registry_path, + &genesis_secret_key_path, + ) + .expect("a registry file ending with a newline must be signed"); + + let registry_json = read_signed_registry(&target_signed_registry_path) + .verify_to_json(&genesis_signer.create_verifier()) + .expect("the signed registry must carry a valid genesis signature") + .to_string(); + assert_eq!(authored_registry_json, registry_json); + } + + #[test] + fn signs_the_version_following_the_signed_registry_at_the_target_path() { + let temp_dir = get_temp_dir("sign_next_version"); + let (genesis_secret_key_path, genesis_signer) = write_genesis_secret_key(&temp_dir); + let target_signed_registry_path = + registry_with_allowed_keys(&temp_dir, &genesis_secret_key_path, &[1]); + let registry = CircuitVerificationKeyRegistry { + version: INITIAL_REGISTRY_VERSION + 1, + entries: vec![entry(2, CircuitVerificationKeyStatus::Allowed)], + }; + let to_sign_registry_path = temp_dir.join("registry-to-sign.json"); + std::fs::write( + &to_sign_registry_path, + serde_json::to_string(®istry).unwrap(), + ) + .unwrap(); + + CircuitKeyRegistryTools::sign( + &to_sign_registry_path, + &target_signed_registry_path, + &genesis_secret_key_path, + ) + .expect("the version following the signed registry must be signed"); + + let verified_registry = read_signed_registry(&target_signed_registry_path) + .verify(&genesis_signer.create_verifier()) + .expect("the written signed registry must carry a valid genesis signature"); + assert_eq!(registry, verified_registry); + } + + #[test] + fn fails_on_a_version_not_following_the_signed_registry_at_the_target_path() { + let temp_dir = get_temp_dir("sign_wrong_version"); + let (genesis_secret_key_path, _) = write_genesis_secret_key(&temp_dir); + let target_signed_registry_path = + registry_with_allowed_keys(&temp_dir, &genesis_secret_key_path, &[1]); + let registry_before = read_signed_registry(&target_signed_registry_path); + let to_sign_registry_path = temp_dir.join("registry-to-sign.json"); + + for version in [INITIAL_REGISTRY_VERSION, INITIAL_REGISTRY_VERSION + 2] { + let registry = CircuitVerificationKeyRegistry { + version, + entries: vec![], + }; + std::fs::write( + &to_sign_registry_path, + serde_json::to_string(®istry).unwrap(), + ) + .unwrap(); + + CircuitKeyRegistryTools::sign( + &to_sign_registry_path, + &target_signed_registry_path, + &genesis_secret_key_path, + ) + .expect_err("a version not following the signed registry must fail signing"); + + assert_eq!( + registry_before, + read_signed_registry(&target_signed_registry_path) + ); + } + } + + #[test] + fn fails_on_a_first_registry_not_at_the_initial_version() { + let temp_dir = get_temp_dir("sign_first_version"); + let (genesis_secret_key_path, _) = write_genesis_secret_key(&temp_dir); + let registry = CircuitVerificationKeyRegistry { + version: INITIAL_REGISTRY_VERSION + 1, + entries: vec![], + }; + let to_sign_registry_path = temp_dir.join("registry.json"); + let target_signed_registry_path = temp_dir.join("signed-registry.json"); + std::fs::write( + &to_sign_registry_path, + serde_json::to_string(®istry).unwrap(), + ) + .unwrap(); + + CircuitKeyRegistryTools::sign( + &to_sign_registry_path, + &target_signed_registry_path, + &genesis_secret_key_path, + ) + .expect_err("a first registry not at the initial version must fail signing"); + + assert!(!target_signed_registry_path.exists()); + } + + #[test] + fn fails_on_a_registry_with_an_inverted_epoch_range() { + let temp_dir = get_temp_dir("sign_inverted"); + let (genesis_secret_key_path, _) = write_genesis_secret_key(&temp_dir); + let registry = CircuitVerificationKeyRegistry { + version: 1, + entries: vec![CircuitVerificationKeyEntry { + start_epoch: Epoch(20), + end_epoch: Some(Epoch(10)), + ..entry(1, CircuitVerificationKeyStatus::Allowed) + }], + }; + let to_sign_registry_path = temp_dir.join("registry.json"); + std::fs::write( + &to_sign_registry_path, + serde_json::to_string(®istry).unwrap(), + ) + .unwrap(); + + CircuitKeyRegistryTools::sign( + &to_sign_registry_path, + &temp_dir.join("signed-registry.json"), + &genesis_secret_key_path, + ) + .expect_err("a registry with an inverted epoch range must fail signing"); + } + + #[test] + fn fails_on_an_invalid_registry_file() { + let temp_dir = get_temp_dir("sign_invalid"); + let (genesis_secret_key_path, _) = write_genesis_secret_key(&temp_dir); + let to_sign_registry_path = temp_dir.join("registry.json"); + std::fs::write(&to_sign_registry_path, "not a registry").unwrap(); + + CircuitKeyRegistryTools::sign( + &to_sign_registry_path, + &temp_dir.join("signed-registry.json"), + &genesis_secret_key_path, + ) + .expect_err("an invalid registry file must fail signing"); + } + } + + mod write_atomically { + use super::*; + + #[test] + fn leaves_no_temporary_file_behind() { + let temp_dir = get_temp_dir("write_atomically_no_leftover"); + let (genesis_secret_key_path, _) = write_genesis_secret_key(&temp_dir); + let registry_path = temp_dir.join("registry.json"); + + CircuitKeyRegistryTools::add_entry( + ®istry_path, + &genesis_secret_key_path, + entry(1, CircuitVerificationKeyStatus::Allowed), + ) + .unwrap(); + + assert!(!registry_path.with_extension("tmp").exists()); + } + + #[test] + fn replaces_the_content_of_an_existing_registry() { + let temp_dir = get_temp_dir("write_atomically_replaces"); + let genesis_signer = + GenesisSigner::from_ed25519(GenesisEd25519Signer::create_deterministic_signer()); + let registry_path = temp_dir.join("registry.json"); + std::fs::write(®istry_path, "a much longer previous content").unwrap(); + + CircuitKeyRegistryTools::bootstrap( + &GenesisEd25519Signer::create_deterministic_signer() + .secret_key() + .to_json_hex() + .unwrap(), + &[], + ®istry_path, + ) + .unwrap(); + + read_signed_registry(®istry_path) + .verify(&genesis_signer.create_verifier()) + .expect("the replaced registry must carry a valid genesis signature"); + } + } + + mod bootstrap { + use super::*; + + fn digests(certificate_circuit_byte: u8) -> CircuitVerificationKeyDigests { + CircuitVerificationKeyDigests { + certificate_circuit: hex::encode([certificate_circuit_byte; 32]).parse().unwrap(), + ivc_circuit: hex::encode([9; 32]).parse().unwrap(), + } + } + + fn named_digests( + name: &str, + certificate_circuit_byte: u8, + ) -> (String, CircuitVerificationKeyDigests) { + (name.to_string(), digests(certificate_circuit_byte)) + } + + fn digests_from_k( + protocol_parameters: Option<&ProtocolParameters>, + ) -> StdResult { + Ok(digests( + protocol_parameters.map_or(0, |parameters| parameters.k as u8), + )) + } + + #[test] + fn digests_of_each_protocol_parameter_set_are_computed_from_it_and_named_after_its_k_and_m() + { + let computed = CircuitKeyRegistryTools::compute_bootstrap_digests( + &[ + ProtocolParameters::new(5, 9, 0.5), + ProtocolParameters::new(7, 10, 0.5), + ], + digests_from_k, + ) + .unwrap(); + + assert_eq!( + vec![ + named_digests("certificate-circuit k=5 m=9", 5), + named_digests("certificate-circuit k=7 m=10", 7), + ], + computed + ); + } + + #[test] + fn digests_without_protocol_parameters_are_the_production_ones() { + let computed = + CircuitKeyRegistryTools::compute_bootstrap_digests(&[], digests_from_k).unwrap(); + + assert_eq!(vec![named_digests("certificate-circuit", 0)], computed); + } + + #[test] + fn entries_allow_each_distinct_certificate_circuit_key_then_the_ivc_circuit_key() { + let entries = CircuitKeyRegistryTools::bootstrap_entries(&[ + named_digests("certificate-circuit k=5 m=9", 1), + named_digests("certificate-circuit k=7 m=10", 2), + named_digests("certificate-circuit k=5 m=9 again", 1), + ]); + + assert_eq!( + vec![ + ("certificate-circuit k=5 m=9", [1; 32]), + ("certificate-circuit k=7 m=10", [2; 32]), + ("ivc-circuit", [9; 32]), + ], + entries + .iter() + .map(|entry| (entry.name.as_str(), *entry.digest.as_bytes())) + .collect::>() + ); + assert!(entries.iter().all(|entry| { + entry.status == CircuitVerificationKeyStatus::Allowed + && entry.start_epoch == Epoch(0) + && entry.end_epoch.is_none() + })); + } + + #[test] + fn bootstraps_a_verifiable_registry_whitelisting_the_production_circuit_keys_without_protocol_parameters() + { + let temp_dir = get_temp_dir("bootstrap"); + let genesis_secret_key_hex = GenesisEd25519Signer::create_deterministic_signer() + .secret_key() + .to_json_hex() + .unwrap(); + let target_registry_path = temp_dir.join("registry.json"); + + CircuitKeyRegistryTools::bootstrap(&genesis_secret_key_hex, &[], &target_registry_path) + .unwrap(); + + let verified_registry = read_signed_registry(&target_registry_path) + .verify( + &GenesisSigner::from_ed25519( + GenesisEd25519Signer::create_deterministic_signer(), + ) + .create_verifier(), + ) + .expect("the bootstrapped registry must carry a valid genesis signature"); + assert_eq!(INITIAL_REGISTRY_VERSION, verified_registry.version); + assert_eq!( + vec![ + ( + "certificate-circuit", + CircuitVerificationKeyDigest::for_production_certificate_circuit().unwrap() + ), + ( + "ivc-circuit", + CircuitVerificationKeyDigest::for_ivc_circuit().unwrap() + ), + ], + verified_registry + .entries + .iter() + .map(|entry| (entry.name.as_str(), entry.digest)) + .collect::>() + ); + assert!(verified_registry.entries.iter().all(|entry| { + entry.status == CircuitVerificationKeyStatus::Allowed + && entry.start_epoch == Epoch(0) + && entry.end_epoch.is_none() + })); + } + } +} diff --git a/mithril-aggregator/src/tools/mod.rs b/mithril-aggregator/src/tools/mod.rs index 6387f9b2c70..c1635c87a96 100644 --- a/mithril-aggregator/src/tools/mod.rs +++ b/mithril-aggregator/src/tools/mod.rs @@ -1,4 +1,6 @@ mod certificates_hash_migrator; +#[cfg(feature = "future_snark")] +mod circuit_key_registry; mod era; mod genesis; pub mod kubo_rpc_client; @@ -9,6 +11,8 @@ pub mod url_sanitizer; mod vacuum_tracker; pub use certificates_hash_migrator::CertificatesHashMigrator; +#[cfg(feature = "future_snark")] +pub use circuit_key_registry::CircuitKeyRegistryTools; pub use era::EraTools; #[cfg(feature = "future_snark")] pub use genesis::GenesisSignedPayload; diff --git a/mithril-common/src/certificate_chain/circuit_verification_key_certifier.rs b/mithril-common/src/certificate_chain/circuit_verification_key_certifier.rs new file mode 100644 index 00000000000..78a590fe44c --- /dev/null +++ b/mithril-common/src/certificate_chain/circuit_verification_key_certifier.rs @@ -0,0 +1,19 @@ +//! Certification of the circuit verification keys of SNARK certificates. + +use async_trait::async_trait; + +use mithril_stm::CircuitVerificationKeyDigest; + +use crate::StdResult; +use crate::entities::Epoch; + +/// Certifies the circuit verification key digests carried by a SNARK certificate. +/// +/// Implemented over the genesis-signed circuit verification key registry, which lives in its own +/// crate, so the certificate verifier only depends on the check itself. +#[cfg_attr(target_family = "wasm", async_trait(?Send))] +#[cfg_attr(not(target_family = "wasm"), async_trait)] +pub trait CircuitVerificationKeyCertifier: Sync + Send { + /// Check that every digest is allowed for the given epoch. + async fn check(&self, digests: &[CircuitVerificationKeyDigest], epoch: Epoch) -> StdResult<()>; +} diff --git a/mithril-common/src/certificate_chain/mod.rs b/mithril-common/src/certificate_chain/mod.rs index 07ce00ab5cb..d13c8f65422 100644 --- a/mithril-common/src/certificate_chain/mod.rs +++ b/mithril-common/src/certificate_chain/mod.rs @@ -3,9 +3,13 @@ mod certificate_genesis; mod certificate_retriever; mod certificate_verifier; +#[cfg(feature = "future_snark")] +mod circuit_verification_key_certifier; pub use certificate_genesis::CertificateGenesisProducer; pub use certificate_retriever::{CertificateRetriever, CertificateRetrieverError}; pub use certificate_verifier::{ CertificateVerifier, CertificateVerifierError, MithrilCertificateVerifier, }; +#[cfg(feature = "future_snark")] +pub use circuit_verification_key_certifier::CircuitVerificationKeyCertifier; diff --git a/mithril-common/src/crypto_helper/circuit_key_registry/certifier.rs b/mithril-common/src/crypto_helper/circuit_key_registry/certifier.rs deleted file mode 100644 index e13e725377d..00000000000 --- a/mithril-common/src/crypto_helper/circuit_key_registry/certifier.rs +++ /dev/null @@ -1,566 +0,0 @@ -//! Certifier of circuit verification key digests against the genesis-signed registry. - -use std::sync::Arc; - -use anyhow::{Context, anyhow}; -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use thiserror::Error; -use tokio::sync::RwLock; - -use mithril_stm::CircuitVerificationKeyDigest; - -use crate::crypto_helper::GenesisVerifier; -use crate::entities::Epoch; -use crate::{StdError, StdResult}; - -use super::{CircuitVerificationKeyRegistry, CircuitVerificationKeyRegistryRetriever}; - -/// Minimum accepted registry version. -/// -/// Bumped at release time whenever a revocation ships, it bounds rollback attacks replaying an -/// older, genuinely signed registry that would resurrect a revoked key. -pub const MINIMUM_REGISTRY_VERSION: u64 = 1; - -/// Time to live in seconds of the registry cached by -/// [CachedCircuitVerificationKeyCertifier]. -/// -/// Once elapsed, the registry is retrieved and verified again, so a registry updated while a -/// node is running (e.g. a revocation) is picked up without a restart. -pub const REGISTRY_CACHE_TIME_TO_LIVE_IN_SECONDS: i64 = 3600; - -/// Errors raised by a [CircuitVerificationKeyCertifier] when obtaining a trusted registry. -#[derive(Error, Debug)] -pub enum CircuitVerificationKeyCertifierError { - /// The signed registry could not be retrieved from its source. - #[error("circuit verification key registry retrieval failed")] - RegistryRetrieval(#[source] StdError), - - /// The genesis signature of the retrieved registry is invalid, or its signed payload cannot - /// be parsed. - /// - /// A registry published for another network is also rejected here, as each network signs its - /// own registry with its own genesis key. - #[error("circuit verification key registry has an invalid genesis signature")] - InvalidRegistrySignature(#[source] StdError), - - /// The retrieved registry version is below the compiled minimum. - #[error( - "circuit verification key registry version {version} is below the minimum accepted version {minimum_version}" - )] - RegistryVersionBelowMinimum { - /// Version declared by the retrieved registry. - version: u64, - /// Minimum version accepted by this build. - minimum_version: u64, - }, - - /// The refreshed registry version is below the previously verified one. - #[error( - "circuit verification key registry version {version} is below the previously verified version {cached_version}" - )] - RegistryVersionRollback { - /// Version declared by the refreshed registry. - version: u64, - /// Version of the previously verified registry. - cached_version: u64, - }, -} - -/// Certifies circuit verification key digests against the genesis-signed registry. -#[cfg_attr(target_family = "wasm", async_trait(?Send))] -#[cfg_attr(not(target_family = "wasm"), async_trait)] -pub trait CircuitVerificationKeyCertifier: Sync + Send { - /// Obtain the verified registry the digests are checked against. - async fn get_verified_registry(&self) -> StdResult; - - /// Check that every digest is whitelisted and not revoked for the given epoch. - async fn check(&self, digests: &[CircuitVerificationKeyDigest], epoch: Epoch) -> StdResult<()> { - let registry = self.get_verified_registry().await?; - - registry - .check(digests, epoch) - .map_err(|e| anyhow!(e)) - .with_context(|| "Circuit verification key certification failed") - } -} - -/// A [CircuitVerificationKeyCertifier] retrieving and verifying the registry (genesis signature -/// and minimum version) at every use. -/// -/// Wrap it in a [CachedCircuitVerificationKeyCertifier] to avoid retrieving the registry at -/// every check. Fail-closed: any retrieval or verification failure fails the check. -pub struct MithrilCircuitVerificationKeyCertifier { - registry_retriever: Arc, - genesis_verifier: Arc, -} - -impl MithrilCircuitVerificationKeyCertifier { - /// Build a certifier from a registry retriever and the genesis verifier holding the registry - /// signing key, which scopes the registry to its network. - pub fn new( - registry_retriever: Arc, - genesis_verifier: Arc, - ) -> Self { - Self { - registry_retriever, - genesis_verifier, - } - } -} - -#[cfg_attr(target_family = "wasm", async_trait(?Send))] -#[cfg_attr(not(target_family = "wasm"), async_trait)] -impl CircuitVerificationKeyCertifier for MithrilCircuitVerificationKeyCertifier { - async fn get_verified_registry(&self) -> StdResult { - let signed_registry = self - .registry_retriever - .retrieve_signed_registry() - .await - .map_err(|e| CircuitVerificationKeyCertifierError::RegistryRetrieval(e.into()))?; - - let registry = signed_registry - .verify(&self.genesis_verifier) - .map_err(CircuitVerificationKeyCertifierError::InvalidRegistrySignature)?; - if registry.version < MINIMUM_REGISTRY_VERSION { - return Err( - CircuitVerificationKeyCertifierError::RegistryVersionBelowMinimum { - version: registry.version, - minimum_version: MINIMUM_REGISTRY_VERSION, - } - .into(), - ); - } - - Ok(registry) - } -} - -/// A verified registry together with the time it was last obtained. -struct VerifiedRegistryCache { - /// The verified registry. - registry: CircuitVerificationKeyRegistry, - - /// Time the registry was last obtained and verified. - refreshed_at: DateTime, -} - -/// A [CircuitVerificationKeyCertifier] decorator caching the verified registry for -/// [REGISTRY_CACHE_TIME_TO_LIVE_IN_SECONDS]. -/// -/// Once elapsed, the registry is obtained again from the decorated certifier, so a registry -/// updated while the node runs (e.g. a revocation) is picked up without a restart. Fail-closed: -/// a failed refresh fails the check, and a refresh cannot lower the registry version. -pub struct CachedCircuitVerificationKeyCertifier { - certifier: Arc, - cache_time_to_live_in_seconds: i64, - verified_registry_cache: RwLock>, -} - -impl CachedCircuitVerificationKeyCertifier { - /// Build a caching decorator over the given certifier. - pub fn new(certifier: Arc) -> Self { - Self { - certifier, - cache_time_to_live_in_seconds: REGISTRY_CACHE_TIME_TO_LIVE_IN_SECONDS, - verified_registry_cache: RwLock::new(None), - } - } - - #[cfg(test)] - fn with_cache_time_to_live_in_seconds(mut self, cache_time_to_live_in_seconds: i64) -> Self { - self.cache_time_to_live_in_seconds = cache_time_to_live_in_seconds; - self - } - - /// Whether the cached registry is still within its time to live. - /// - /// A negative age (the clock jumped backwards) is treated as stale, so it forces a refresh - /// instead of keeping the cache fresh until the clock catches up. - fn is_cache_fresh(&self, cache: &VerifiedRegistryCache) -> bool { - let age_in_seconds = (Utc::now() - cache.refreshed_at).num_seconds(); - - (0..self.cache_time_to_live_in_seconds).contains(&age_in_seconds) - } -} - -#[cfg_attr(target_family = "wasm", async_trait(?Send))] -#[cfg_attr(not(target_family = "wasm"), async_trait)] -impl CircuitVerificationKeyCertifier for CachedCircuitVerificationKeyCertifier { - async fn get_verified_registry(&self) -> StdResult { - { - let cache = self.verified_registry_cache.read().await; - if let Some(cache) = cache.as_ref() - && self.is_cache_fresh(cache) - { - return Ok(cache.registry.clone()); - } - } - - let mut cache = self.verified_registry_cache.write().await; - if let Some(cache) = cache.as_ref() - && self.is_cache_fresh(cache) - { - return Ok(cache.registry.clone()); - } - - let registry = self.certifier.get_verified_registry().await?; - if let Some(previous_cache) = cache.as_ref() - && registry.version < previous_cache.registry.version - { - return Err( - CircuitVerificationKeyCertifierError::RegistryVersionRollback { - version: registry.version, - cached_version: previous_cache.registry.version, - } - .into(), - ); - } - *cache = Some(VerifiedRegistryCache { - registry: registry.clone(), - refreshed_at: Utc::now(), - }); - - Ok(registry) - } -} - -#[cfg(test)] -mod tests { - use rand_chacha::ChaCha20Rng; - use rand_core::SeedableRng; - - use crate::crypto_helper::circuit_key_registry::retriever::MockCircuitVerificationKeyRegistryRetriever; - use crate::crypto_helper::{ - CircuitVerificationKeyEntry, CircuitVerificationKeyRegistryError, - CircuitVerificationKeyRegistryRetrieverError, CircuitVerificationKeyRejection, - CircuitVerificationKeyRejectionReason, CircuitVerificationKeyStatus, GenesisEd25519Signer, - GenesisSigner, SignedCircuitVerificationKeyRegistry, - }; - use crate::test::double::FakeCircuitVerificationKeyRegistryRetriever; - - use super::*; - - fn digest(seed: u8) -> CircuitVerificationKeyDigest { - hex::encode([seed; 32]).parse().unwrap() - } - - fn genesis_signer() -> GenesisSigner { - GenesisSigner::from_ed25519(GenesisEd25519Signer::create_deterministic_signer()) - } - - fn registry_allowing( - digests: &[CircuitVerificationKeyDigest], - ) -> CircuitVerificationKeyRegistry { - CircuitVerificationKeyRegistry { - version: MINIMUM_REGISTRY_VERSION, - entries: digests - .iter() - .map(|digest| CircuitVerificationKeyEntry { - digest: *digest, - name: "circuit".to_string(), - status: CircuitVerificationKeyStatus::Allowed, - start_epoch: Epoch(0), - end_epoch: None, - comment: None, - }) - .collect(), - } - } - - mod mithril_certifier { - use super::*; - - fn certifier_over( - registry: CircuitVerificationKeyRegistry, - genesis_signer: &GenesisSigner, - ) -> MithrilCircuitVerificationKeyCertifier { - let signed_registry = - SignedCircuitVerificationKeyRegistry::try_new(registry, genesis_signer).unwrap(); - MithrilCircuitVerificationKeyCertifier::new( - Arc::new( - FakeCircuitVerificationKeyRegistryRetriever::from_signed_registry( - signed_registry, - ), - ), - Arc::new(genesis_signer.create_verifier()), - ) - } - - #[tokio::test] - async fn check_succeeds_with_a_whitelisted_digest() { - let genesis_signer = genesis_signer(); - let certifier = certifier_over(registry_allowing(&[digest(1)]), &genesis_signer); - - certifier.check(&[digest(1)], Epoch(10)).await.unwrap(); - } - - #[tokio::test] - async fn check_propagates_registry_check_errors() { - let genesis_signer = genesis_signer(); - let certifier = certifier_over(registry_allowing(&[digest(1)]), &genesis_signer); - - let error = certifier.check(&[digest(9)], Epoch(10)).await.unwrap_err(); - - assert_eq!( - error.downcast_ref::(), - Some(&CircuitVerificationKeyRegistryError::Rejected { - epoch: Epoch(10), - rejections: vec![CircuitVerificationKeyRejection { - digest: digest(9), - reason: CircuitVerificationKeyRejectionReason::NotWhitelisted, - }], - }), - "the registry check error must be preserved, got: {error}" - ); - } - - #[tokio::test] - async fn check_fails_closed_when_retrieval_fails() { - let genesis_signer = genesis_signer(); - let certifier = MithrilCircuitVerificationKeyCertifier::new( - Arc::new(FakeCircuitVerificationKeyRegistryRetriever::that_fails()), - Arc::new(genesis_signer.create_verifier()), - ); - - let error = certifier.check(&[digest(1)], Epoch(10)).await.unwrap_err(); - - assert!( - matches!( - error.downcast_ref::(), - Some(CircuitVerificationKeyCertifierError::RegistryRetrieval(_)) - ), - "a retrieval failure must fail the check, got: {error}" - ); - } - - #[tokio::test] - async fn check_rejects_a_registry_signed_by_another_genesis_key() { - let genesis_signer = genesis_signer(); - let other_genesis_signer = GenesisSigner::from_ed25519( - GenesisEd25519Signer::create_test_signer(ChaCha20Rng::from_seed([7u8; 32])), - ); - let signed_registry = SignedCircuitVerificationKeyRegistry::try_new( - registry_allowing(&[digest(1)]), - &other_genesis_signer, - ) - .unwrap(); - let certifier = MithrilCircuitVerificationKeyCertifier::new( - Arc::new( - FakeCircuitVerificationKeyRegistryRetriever::from_signed_registry( - signed_registry, - ), - ), - Arc::new(genesis_signer.create_verifier()), - ); - - let error = certifier.check(&[digest(1)], Epoch(10)).await.unwrap_err(); - - assert!( - matches!( - error.downcast_ref::(), - Some(CircuitVerificationKeyCertifierError::InvalidRegistrySignature(_)) - ), - "a registry signed by another genesis key must be rejected, got: {error}" - ); - } - - #[tokio::test] - async fn check_rejects_a_registry_version_below_the_minimum() { - let genesis_signer = genesis_signer(); - let mut registry = registry_allowing(&[digest(1)]); - registry.version = MINIMUM_REGISTRY_VERSION - 1; - let certifier = certifier_over(registry, &genesis_signer); - - let error = certifier.check(&[digest(1)], Epoch(10)).await.unwrap_err(); - - assert!( - matches!( - error.downcast_ref::(), - Some( - CircuitVerificationKeyCertifierError::RegistryVersionBelowMinimum { - version: 0, - minimum_version: MINIMUM_REGISTRY_VERSION, - } - ) - ), - "a registry version below the minimum must be rejected, got: {error}" - ); - } - - #[tokio::test] - async fn check_retrieves_and_verifies_the_registry_at_every_use() { - let genesis_signer = genesis_signer(); - let signed_registry = SignedCircuitVerificationKeyRegistry::try_new( - registry_allowing(&[digest(1)]), - &genesis_signer, - ) - .unwrap(); - let mut registry_retriever = MockCircuitVerificationKeyRegistryRetriever::new(); - registry_retriever - .expect_retrieve_signed_registry() - .times(2) - .returning(move || Ok(signed_registry.clone())); - let certifier = MithrilCircuitVerificationKeyCertifier::new( - Arc::new(registry_retriever), - Arc::new(genesis_signer.create_verifier()), - ); - - certifier.check(&[digest(1)], Epoch(10)).await.unwrap(); - certifier.check(&[digest(1)], Epoch(11)).await.unwrap(); - } - } - - mod cached_certifier { - use super::*; - - fn cached_certifier_over_retriever( - registry_retriever: MockCircuitVerificationKeyRegistryRetriever, - genesis_signer: &GenesisSigner, - ) -> CachedCircuitVerificationKeyCertifier { - CachedCircuitVerificationKeyCertifier::new(Arc::new( - MithrilCircuitVerificationKeyCertifier::new( - Arc::new(registry_retriever), - Arc::new(genesis_signer.create_verifier()), - ), - )) - } - - #[tokio::test] - async fn check_retrieves_and_verifies_the_registry_only_once_within_the_time_to_live() { - let genesis_signer = genesis_signer(); - let signed_registry = SignedCircuitVerificationKeyRegistry::try_new( - registry_allowing(&[digest(1)]), - &genesis_signer, - ) - .unwrap(); - let mut registry_retriever = MockCircuitVerificationKeyRegistryRetriever::new(); - registry_retriever - .expect_retrieve_signed_registry() - .times(1) - .return_once(move || Ok(signed_registry)); - let certifier = cached_certifier_over_retriever(registry_retriever, &genesis_signer); - - certifier.check(&[digest(1)], Epoch(10)).await.unwrap(); - certifier.check(&[digest(1)], Epoch(11)).await.unwrap(); - } - - #[test] - fn a_cache_refreshed_in_the_future_is_stale() { - let genesis_signer = genesis_signer(); - let certifier = CachedCircuitVerificationKeyCertifier::new(Arc::new( - MithrilCircuitVerificationKeyCertifier::new( - Arc::new(FakeCircuitVerificationKeyRegistryRetriever::that_fails()), - Arc::new(genesis_signer.create_verifier()), - ), - )); - let cache = VerifiedRegistryCache { - registry: registry_allowing(&[digest(1)]), - refreshed_at: Utc::now() + chrono::Duration::hours(2), - }; - - assert!( - !certifier.is_cache_fresh(&cache), - "a cache refreshed in the future (backwards clock jump) must be stale" - ); - } - - #[tokio::test] - async fn check_refreshes_the_registry_after_the_cache_time_to_live_expires() { - let genesis_signer = genesis_signer(); - let signed_registry = SignedCircuitVerificationKeyRegistry::try_new( - registry_allowing(&[digest(1)]), - &genesis_signer, - ) - .unwrap(); - let mut registry_retriever = MockCircuitVerificationKeyRegistryRetriever::new(); - registry_retriever - .expect_retrieve_signed_registry() - .times(2) - .returning(move || Ok(signed_registry.clone())); - let certifier = cached_certifier_over_retriever(registry_retriever, &genesis_signer) - .with_cache_time_to_live_in_seconds(-1); - - certifier.check(&[digest(1)], Epoch(10)).await.unwrap(); - certifier.check(&[digest(1)], Epoch(11)).await.unwrap(); - } - - #[tokio::test] - async fn check_rejects_a_refreshed_registry_with_a_lower_version() { - let genesis_signer = genesis_signer(); - let mut newer_registry = registry_allowing(&[digest(1)]); - newer_registry.version = MINIMUM_REGISTRY_VERSION + 1; - let newer_signed_registry = - SignedCircuitVerificationKeyRegistry::try_new(newer_registry, &genesis_signer) - .unwrap(); - let older_signed_registry = SignedCircuitVerificationKeyRegistry::try_new( - registry_allowing(&[digest(1)]), - &genesis_signer, - ) - .unwrap(); - let mut registry_retriever = MockCircuitVerificationKeyRegistryRetriever::new(); - registry_retriever - .expect_retrieve_signed_registry() - .times(1) - .return_once(move || Ok(newer_signed_registry)); - registry_retriever - .expect_retrieve_signed_registry() - .times(1) - .return_once(move || Ok(older_signed_registry)); - let certifier = cached_certifier_over_retriever(registry_retriever, &genesis_signer) - .with_cache_time_to_live_in_seconds(-1); - - certifier.check(&[digest(1)], Epoch(10)).await.unwrap(); - let error = certifier.check(&[digest(1)], Epoch(11)).await.unwrap_err(); - - assert!( - matches!( - error.downcast_ref::(), - Some( - CircuitVerificationKeyCertifierError::RegistryVersionRollback { - version: 1, - cached_version: 2, - } - ) - ), - "a refreshed registry with a lower version must be rejected, got: {error}" - ); - } - - #[tokio::test] - async fn check_fails_closed_when_the_refresh_fails() { - let genesis_signer = genesis_signer(); - let signed_registry = SignedCircuitVerificationKeyRegistry::try_new( - registry_allowing(&[digest(1)]), - &genesis_signer, - ) - .unwrap(); - let mut registry_retriever = MockCircuitVerificationKeyRegistryRetriever::new(); - registry_retriever - .expect_retrieve_signed_registry() - .times(1) - .return_once(move || Ok(signed_registry)); - registry_retriever - .expect_retrieve_signed_registry() - .times(1) - .return_once(|| { - Err(CircuitVerificationKeyRegistryRetrieverError(anyhow!( - "registry source unreachable" - ))) - }); - let certifier = cached_certifier_over_retriever(registry_retriever, &genesis_signer) - .with_cache_time_to_live_in_seconds(-1); - - certifier.check(&[digest(1)], Epoch(10)).await.unwrap(); - let error = certifier.check(&[digest(1)], Epoch(11)).await.unwrap_err(); - - assert!( - matches!( - error.downcast_ref::(), - Some(CircuitVerificationKeyCertifierError::RegistryRetrieval(_)) - ), - "a failed refresh must fail the check, got: {error}" - ); - } - } -} diff --git a/mithril-common/src/crypto_helper/circuit_key_registry/mod.rs b/mithril-common/src/crypto_helper/circuit_key_registry/mod.rs deleted file mode 100644 index 18d8d7c5c38..00000000000 --- a/mithril-common/src/crypto_helper/circuit_key_registry/mod.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Genesis-signed registry of the circuit verification keys trusted for SNARK certificates. -//! -//! The registry whitelists circuit verification key digests over inclusive epoch ranges and -//! supports revoking them retroactively, e.g. after a circuit vulnerability. It is published in -//! the repository per network, retrieved at runtime and verified against the Ed25519 half of -//! the genesis verification key before use. - -mod certifier; -mod registry; -mod retriever; - -pub use certifier::*; -pub use mithril_stm::{CIRCUIT_VERIFICATION_KEY_DIGEST_SIZE, CircuitVerificationKeyDigest}; -pub use registry::*; -pub use retriever::*; diff --git a/mithril-common/src/crypto_helper/circuit_key_registry/retriever.rs b/mithril-common/src/crypto_helper/circuit_key_registry/retriever.rs deleted file mode 100644 index 3e1afddacd8..00000000000 --- a/mithril-common/src/crypto_helper/circuit_key_registry/retriever.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! Retrieval of the signed circuit verification key registry from its published source. - -#[cfg(not(target_family = "wasm"))] -use std::path::PathBuf; - -#[cfg(not(target_family = "wasm"))] -use anyhow::Context; -use async_trait::async_trait; -use thiserror::Error; - -use crate::StdError; -#[cfg(not(target_family = "wasm"))] -use crate::StdResult; - -use super::SignedCircuitVerificationKeyRegistry; - -/// [CircuitVerificationKeyRegistryRetriever] related errors. -#[derive(Debug, Error)] -#[error("Error when retrieving circuit verification key registry")] -pub struct CircuitVerificationKeyRegistryRetrieverError(#[source] pub StdError); - -/// Retrieves the signed circuit verification key registry published at the root of the repository. -/// -/// Implementations return the signed document unverified: the genesis signature and version -/// checks belong to the caller, so an untrusted transport cannot bypass them. -#[cfg_attr(test, mockall::automock)] -#[cfg_attr(target_family = "wasm", async_trait(?Send))] -#[cfg_attr(not(target_family = "wasm"), async_trait)] -pub trait CircuitVerificationKeyRegistryRetriever: Sync + Send { - /// Retrieve the signed registry from its source. - async fn retrieve_signed_registry( - &self, - ) -> Result; -} - -/// A [CircuitVerificationKeyRegistryRetriever] reading the signed registry JSON from a local file. -#[cfg(not(target_family = "wasm"))] -pub struct FileCircuitVerificationKeyRegistryRetriever { - registry_file_path: PathBuf, -} - -#[cfg(not(target_family = "wasm"))] -impl FileCircuitVerificationKeyRegistryRetriever { - /// Build a retriever reading the given signed registry JSON file. - pub fn new(registry_file_path: PathBuf) -> Self { - Self { registry_file_path } - } - - /// Read the signed registry JSON file and parse it. - fn read_and_parse_registry_file( - registry_file_path: &PathBuf, - ) -> StdResult { - let json = std::fs::read_to_string(registry_file_path).with_context(|| { - format!( - "Failed to read signed registry file at '{}'", - registry_file_path.display() - ) - })?; - serde_json::from_str(&json).with_context(|| { - format!( - "Failed to parse signed registry file at '{}'", - registry_file_path.display() - ) - }) - } -} - -#[cfg(not(target_family = "wasm"))] -#[async_trait] -impl CircuitVerificationKeyRegistryRetriever for FileCircuitVerificationKeyRegistryRetriever { - async fn retrieve_signed_registry( - &self, - ) -> Result - { - let registry_file_path = self.registry_file_path.clone(); - tokio::task::spawn_blocking(move || Self::read_and_parse_registry_file(®istry_file_path)) - .await - .map_err(|e| CircuitVerificationKeyRegistryRetrieverError(e.into()))? - .map_err(CircuitVerificationKeyRegistryRetrieverError) - } -} - -#[cfg(test)] -mod tests { - use crate::crypto_helper::{ - CircuitVerificationKeyRegistry, GenesisEd25519Signer, GenesisSigner, - }; - use crate::temp_dir_create; - - use super::*; - - fn signed_registry() -> SignedCircuitVerificationKeyRegistry { - let genesis_signer = - GenesisSigner::from_ed25519(GenesisEd25519Signer::create_deterministic_signer()); - SignedCircuitVerificationKeyRegistry::try_new( - CircuitVerificationKeyRegistry { - version: 1, - entries: vec![], - }, - &genesis_signer, - ) - .unwrap() - } - - #[tokio::test] - async fn file_retriever_reads_a_signed_registry_json_file() { - let temp_dir = temp_dir_create!(); - let registry_file_path = temp_dir.join("signed-registry.json"); - let signed_registry = signed_registry(); - std::fs::write( - ®istry_file_path, - serde_json::to_string(&signed_registry).unwrap(), - ) - .unwrap(); - - let retrieved = FileCircuitVerificationKeyRegistryRetriever::new(registry_file_path) - .retrieve_signed_registry() - .await - .unwrap(); - - assert_eq!(signed_registry, retrieved); - } - - #[tokio::test] - async fn file_retriever_fails_on_a_missing_file() { - let temp_dir = temp_dir_create!(); - - FileCircuitVerificationKeyRegistryRetriever::new(temp_dir.join("missing.json")) - .retrieve_signed_registry() - .await - .expect_err("a missing registry file must fail retrieval"); - } - - #[tokio::test] - async fn file_retriever_fails_on_an_invalid_json_file() { - let temp_dir = temp_dir_create!(); - let registry_file_path = temp_dir.join("signed-registry.json"); - std::fs::write(®istry_file_path, "not a signed registry").unwrap(); - - FileCircuitVerificationKeyRegistryRetriever::new(registry_file_path) - .retrieve_signed_registry() - .await - .expect_err("an invalid registry file must fail retrieval"); - } -} diff --git a/mithril-common/src/crypto_helper/mod.rs b/mithril-common/src/crypto_helper/mod.rs index 31c6d49f6b0..79fd6e8a5a2 100644 --- a/mithril-common/src/crypto_helper/mod.rs +++ b/mithril-common/src/crypto_helper/mod.rs @@ -1,8 +1,6 @@ //! Tools and types to abstract the use of the [Mithril STM library](https://mithril.network/rust-doc/mithril_stm/index.html) mod cardano; -#[cfg(feature = "future_snark")] -mod circuit_key_registry; mod codec; mod conversions; pub mod ed25519; @@ -20,8 +18,6 @@ pub use cardano::{ ProtocolInitializerErrorWrapper, ProtocolRegistrationErrorWrapper, SerDeShelleyFileFormat, SignerRegistrationParameters, Sum6KesBytes, }; -#[cfg(feature = "future_snark")] -pub use circuit_key_registry::*; pub use codec::*; pub use ed25519_alias::{era::*, manifest::*, protocol_configuration::*}; pub use genesis::*; @@ -31,6 +27,8 @@ pub use mithril_merkle_tree::{ }; #[cfg(feature = "future_snark")] pub use mithril_stm::SnarkProverSetupWarmer; +#[cfg(feature = "future_snark")] +pub use mithril_stm::{CIRCUIT_VERIFICATION_KEY_DIGEST_SIZE, CircuitVerificationKeyDigest}; pub use types::*; /// The current protocol version diff --git a/mithril-common/src/test/double/mod.rs b/mithril-common/src/test/double/mod.rs index e95f5685b85..31fe8acc671 100644 --- a/mithril-common/src/test/double/mod.rs +++ b/mithril-common/src/test/double/mod.rs @@ -4,8 +4,6 @@ mod api_version; mod certificate_retriever; -#[cfg(feature = "future_snark")] -mod circuit_key_registry_retriever; mod dummies; pub mod fake_data; pub mod fake_keys; @@ -13,8 +11,6 @@ pub(super) mod precomputed_kes_key; pub use api_version::DummyApiVersionDiscriminantSource; pub use certificate_retriever::FakeCertificaterRetriever; -#[cfg(feature = "future_snark")] -pub use circuit_key_registry_retriever::FakeCircuitVerificationKeyRegistryRetriever; /// A trait for giving a type a dummy value. ///