diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 863d99b21b8..c622ae014ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -598,7 +598,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: @@ -879,7 +879,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 307bd82e70a..82836012b20 100644 --- a/.github/workflows/nightly-dispatcher.yml +++ b/.github/workflows/nightly-dispatcher.yml @@ -45,7 +45,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 e295322f4e8..6897c8148f8 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 -p mithril-stm -p mithril-common -p mithril-aggregator -p mithril-signer -p mithril-relay + test-args: --features future_snark -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 865d70d5a89..0801aefbf76 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 HTTPS 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. - Moved the download of the SRS of the trusted setup from the STM library to the Mithril aggregator, removing the HTTP client and the TLS features from the library and its consumers. - Hardened the SRS download of the Mithril aggregator: a cached SRS is verified against its pinned hash before use, the prover warm-up retries with a doubling delay and gives up on a failure no attempt resolves, and each download attempt is bounded and never leaves HTTPS. - Support for an IVC follower aggregator joining the network at any time, with a new optional `certificate_chain_aggregator_endpoint` configuration parameter of the Mithril aggregator to synchronize the full certificate chain from a distinct aggregator, defaulting to the leader aggregator endpoint. diff --git a/Cargo.lock b/Cargo.lock index d0393e18267..77cbc96bb8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4214,7 +4214,7 @@ dependencies = [ [[package]] name = "mithril-aggregator" -version = "0.10.11" +version = "0.10.12" dependencies = [ "anyhow", "async-trait", @@ -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", @@ -4408,6 +4409,30 @@ dependencies = [ "walkdir", ] +[[package]] +name = "mithril-circuit-key-registry" +version = "0.1.1" +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" @@ -4512,7 +4537,7 @@ dependencies = [ [[package]] name = "mithril-common" -version = "0.7.25" +version = "0.7.26" dependencies = [ "anyhow", "async-trait", 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..8bb3cc1913e 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 **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 39fa05fadf2..8ab2bc178ac 100644 --- a/docs/website/root/manual/develop/nodes/mithril-aggregator.md +++ b/docs/website/root/manual/develop/nodes/mithril-aggregator.md @@ -527,6 +527,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 @@ -711,3 +717,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/cardano-node/mithril-cardano-node-internal-database/Cargo.toml b/internal/cardano-node/mithril-cardano-node-internal-database/Cargo.toml index 476fd29c204..b1608e3c4db 100644 --- a/internal/cardano-node/mithril-cardano-node-internal-database/Cargo.toml +++ b/internal/cardano-node/mithril-cardano-node-internal-database/Cargo.toml @@ -15,7 +15,7 @@ anyhow = { workspace = true } async-trait = { workspace = true } digest = { workspace = true } hex = { workspace = true } -mithril-common = { path = "../../../mithril-common", version = "0.7.25" } +mithril-common = { path = "../../../mithril-common", version = "0.7.26" } serde = { workspace = true } serde_json = { workspace = true } sha2 = "0.10.9" diff --git a/internal/mithril-aggregator-client/Cargo.toml b/internal/mithril-aggregator-client/Cargo.toml index 740a5cccbbf..05623ce7b6c 100644 --- a/internal/mithril-aggregator-client/Cargo.toml +++ b/internal/mithril-aggregator-client/Cargo.toml @@ -13,7 +13,7 @@ include = ["**/*.rs", "Cargo.toml", "README.md"] [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } -mithril-common = { path = "../../mithril-common", version = "0.7.25" } +mithril-common = { path = "../../mithril-common", version = "0.7.26" } reqwest = { workspace = true } semver = { workspace = true } serde = { workspace = true } diff --git a/internal/mithril-aggregator-discovery/Cargo.toml b/internal/mithril-aggregator-discovery/Cargo.toml index 17fb21158a1..c72b0fb32f6 100644 --- a/internal/mithril-aggregator-discovery/Cargo.toml +++ b/internal/mithril-aggregator-discovery/Cargo.toml @@ -14,7 +14,7 @@ include = ["**/*.rs", "Cargo.toml", "README.md", ".gitignore"] anyhow = { workspace = true } async-trait = { workspace = true } mithril-aggregator-client = { path = "../mithril-aggregator-client", version = "0.2.4" } -mithril-common = { path = "../../mithril-common", version = "0.7.25" } +mithril-common = { path = "../../mithril-common", version = "0.7.26" } rand = { version = "0.10.2" } reqwest = { workspace = true } serde = { workspace = true } diff --git a/internal/mithril-circuit-key-registry/Cargo.toml b/internal/mithril-circuit-key-registry/Cargo.toml new file mode 100644 index 00000000000..42d8dcdbb47 --- /dev/null +++ b/internal/mithril-circuit-key-registry/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "mithril-circuit-key-registry" +description = "Genesis-signed registry of the circuit verification keys trusted for Mithril SNARK certificates." +version = "0.1.1" +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] +# Enables the registry, which requires the future SNARK implementation of the `mithril-common` dependency +future_snark = ["mithril-common/future_snark"] + +# TLS backend of the registry download +# Exactly one of these must be enabled +native-tls = ["reqwest/native-tls"] +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.26" } +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..3f685023b53 --- /dev/null +++ b/internal/mithril-circuit-key-registry/src/certifier.rs @@ -0,0 +1,839 @@ +//! 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, + }, +} + +/// Provides the verified circuit verification key registry the digests are checked against. +/// +/// Implemented by the certifiers of this crate, so a certifier can decorate another one, e.g. to +/// cache the verified registry. +#[cfg_attr(target_family = "wasm", async_trait(?Send))] +#[cfg_attr(not(target_family = "wasm"), async_trait)] +pub trait CircuitVerificationKeyRegistryProvider: Sync + Send { + /// Obtain the verified registry. + async fn get_verified_registry(&self) -> StdResult; +} + +/// 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, + } + } + + /// 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 CircuitVerificationKeyRegistryProvider 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()))?; + + signed_registry + .verify(&self.genesis_verifier) + .map_err(|e| CircuitVerificationKeyCertifierError::InvalidRegistrySignature(e).into()) + } +} + +#[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 [CircuitVerificationKeyRegistryProvider] 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 provider, 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 { + /// Provider of the verified registry. + provider: Arc, + + /// 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 provider. + pub fn new(provider: Arc, logger: Logger) -> Self { + Self { + provider, + 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 + } + + /// 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 CircuitVerificationKeyRegistryProvider for CachedCircuitVerificationKeyCertifier { + 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.provider.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) + } + } + } +} + +#[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( + Arc::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( + Arc::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 ae449c15e26..b23bea71f46 100644 --- a/mithril-aggregator/Cargo.toml +++ b/mithril-aggregator/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mithril-aggregator" -version = "0.10.11" +version = "0.10.12" description = "A Mithril Aggregator server" authors = { workspace = true } edition = { workspace = true } @@ -11,11 +11,18 @@ repository = { workspace = true } [features] default = ["rustls"] -rustls = ["reqwest/rustls"] +rustls = ["reqwest/rustls", "mithril-circuit-key-registry?/rustls"] # The SRS download needs a TLS backend, which the removal of the `mithril-stm` TLS features no # longer enforces anywhere else. -future_snark = ["mithril-common/future_snark", "dep:ciborium", "dep:rand_core", "rustls"] +future_snark = [ + "mithril-common/future_snark", + "dep:ciborium", + "dep:rand_core", + "rustls", + "dep:mithril-circuit-key-registry", + "mithril-circuit-key-registry/future_snark", +] [dependencies] anyhow = { workspace = true } @@ -30,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-client/Cargo.toml b/mithril-client/Cargo.toml index 3414b7b4407..615d9eb763e 100644 --- a/mithril-client/Cargo.toml +++ b/mithril-client/Cargo.toml @@ -63,7 +63,7 @@ chrono = { workspace = true } flume = { version = "0.12.0", optional = true } futures = "0.3.32" mithril-aggregator-client = { path = "../internal/mithril-aggregator-client", version = "0.2.4" } -mithril-common = { path = "../mithril-common", version = "0.7.25", default-features = false } +mithril-common = { path = "../mithril-common", version = "0.7.26", default-features = false } reqwest = { workspace = true, default-features = false, features = ["charset", "http2", "query", "stream", "system-proxy"] } serde = { workspace = true } serde_json = { workspace = true } diff --git a/mithril-common/Cargo.toml b/mithril-common/Cargo.toml index a96e8160913..d4e9db30a59 100644 --- a/mithril-common/Cargo.toml +++ b/mithril-common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mithril-common" -version = "0.7.25" +version = "0.7.26" description = "Common types, interfaces, and utilities for Mithril nodes." authors = { workspace = true } edition = { workspace = true } 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 6a7a9ea0164..aa560064516 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,8 +27,9 @@ pub use mithril_merkle_tree::{ }; #[cfg(feature = "future_snark")] pub use mithril_stm::{ - MIDNIGHT_SRS_URL_K22, NoTrustedSetupDownload, SnarkProverSetupWarmer, TrustedSetupDownloader, - TrustedSetupError, TrustedSetupProvider, + CIRCUIT_VERIFICATION_KEY_DIGEST_SIZE, CircuitVerificationKeyDigest, MIDNIGHT_SRS_URL_K22, + NoTrustedSetupDownload, SnarkProverSetupWarmer, TrustedSetupDownloader, TrustedSetupError, + TrustedSetupProvider, }; pub use types::*; 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. ///