diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 4c011da5..d6570394 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -47,7 +47,7 @@ jobs: - name: Wait for wrangler dev to be ready run: | for i in $(seq 1 30); do - if curl -sf http://localhost:8787/logs/dev2026h1a/ct/v1/get-roots > /dev/null 2>&1; then + if curl -sf http://localhost:8787/logs/dev2026h2a/ct/v1/get-roots > /dev/null 2>&1; then echo "wrangler dev is ready" exit 0 fi @@ -61,7 +61,7 @@ jobs: run: cargo test -p integration_tests --test static_ct_api --verbose env: BASE_URL: http://localhost:8787 - LOG_NAME: dev2026h1a + LOG_NAME: dev2026h2a integration-tlog-mirror: name: TLog Mirror Integration Tests diff --git a/AGENTS.md b/AGENTS.md index 6e0a5dfb..f7c3de02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ npx wrangler -e=dev dev & # From workspace root: cargo test -p integration_tests --test static_ct_api # Override defaults: -BASE_URL=http://localhost:8787 LOG_NAME=dev2026h1a cargo test -p integration_tests --test static_ct_api +BASE_URL=http://localhost:8787 LOG_NAME=dev2026h2a cargo test -p integration_tests --test static_ct_api # Worker deploy npx wrangler -e=${ENV} deploy diff --git a/Cargo.lock b/Cargo.lock index 02784a2d..934eeb8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1581,6 +1581,7 @@ dependencies = [ "ed25519-dalek", "flate2", "hex", + "jsonschema", "ml-dsa", "p256", "pkcs8", @@ -3068,7 +3069,6 @@ dependencies = [ "byteorder", "chrono", "der", - "ed25519-dalek", "length_prefixed", "p256", "serde", diff --git a/crates/ct_worker/.dev.vars b/crates/ct_worker/.dev.vars index 4f537dc9..92d80689 100644 --- a/crates/ct_worker/.dev.vars +++ b/crates/ct_worker/.dev.vars @@ -1,9 +1,3 @@ -SIGNING_KEY_dev2025h1a="-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQguu9K86g+++gKa7ag\ntkPw5E3xPhTeSkj69l0VL06EeQGhRANCAAQLOWnBI0PojH8rWoAoitlJ+Ip6iQl4\nWycheJdCWsXF2NzbOLM5aFMGpz3Bwm5egkGCzrLbhGW7z9p3FAI0N08o\n-----END PRIVATE KEY-----\n" -WITNESS_KEY_dev2025h1a="-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIHwiErJNKCNGZL+Osj+O8MqSMiwPP4kdcC4iTpojV9Od\n-----END PRIVATE KEY-----\n" -SIGNING_KEY_dev2025h2a="-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgR2CdNf+JR6afd0gb\n+lMSINqCqiLDb7L88lo1qhxBynOhRANCAARLjKsvuNqvDER1Jasmnfm55/vz1Rgu\nZr8XTHtt8GlbYpac3nK4MTleB44Ap5YzdGnJwJkXbFEYCnaIcUJrg+2o\n-----END PRIVATE KEY-----\n" -WITNESS_KEY_dev2025h2a="-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEINKYH1WadDgPJEXYzLx0OWzNoi4hRcpUnYpoWrTc7BDO\n-----END PRIVATE KEY-----\n" -SIGNING_KEY_dev2026h1a="-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMcpVNLWTILGapBcQ\n0e59OCX+MC3ik6S/o3EzrBvISi2hRANCAATuqQUZCcCoG0yQWPiXy11zQwhUCNjw\nQb7fWqyzNGBZSgeDeUXB1+F1J3x6Nv9wb+PWj91XRYKN5zMpBwoZXrfz\n-----END PRIVATE KEY-----\n" -WITNESS_KEY_dev2026h1a="-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIGttRSMUB4BfaxIWodXTMiGqLnBMaNAmOdms0gVelXvn\n-----END PRIVATE KEY-----\n" SIGNING_KEY_dev2026h2a="-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgt14ClEtNlyhyy8IQ\njYA1gV0KH35xcHbaJ5g5tU7TbGqhRANCAAR1lWiAPxLrYQ5OjAWIwaYRDckh2+GD\nS2pyO25lj/lWJg94IY2MY/CaRbrIuWGUWjBRJczMjDBkajeaFC//dpG9\n-----END PRIVATE KEY-----\n" WITNESS_KEY_dev2026h2a="-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIFBSB+nqtrOlOg5f3GiVYlt9Q1ni9s+ooqPBDyciEQzw\n-----END PRIVATE KEY-----\n" SIGNING_KEY_dev2027h1a="-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQggM2mn5ZSIIMwO7XG\nn2t6qcJWBUCx2rO2F6nAdY6OdIahRANCAARQiB2uy0Xl37DU8SROPUaQugrkqwRI\nw3JFQfZql6u7y++P68b5mad7vQShq5Js0kZ0YPV6rRlVJ5elhe2NQ5Dp\n-----END PRIVATE KEY-----\n" diff --git a/crates/ct_worker/README.md b/crates/ct_worker/README.md index 1bfd182e..e9db9640 100644 --- a/crates/ct_worker/README.md +++ b/crates/ct_worker/README.md @@ -20,42 +20,6 @@ The Batcher receives requests (keeping them open) and groups the entries into ba After persisting log state, the Sequencer returns sequenced entry metadata (7) to the Batcher, which in turn sends entry metadata to waiting Frontend requests and writes batch metadata to the deduplication cache in Workers KV. When the Frontend receives the response, it returns a Signed Certificate Timestamp (SCT) to the client (8). -## Test logs - -Two prototype logs are available for testing, with configuration in `wrangler.jsonc` and `config.cftest.json` and roots from `roots.default.pem`. - - curl -s https://static-ct.cloudflareresearch.com/logs/cftest2025h1a/metadata | jq - { - "description": "Cloudflare Research 'cftest2025h1a' log", - "log_type": "test", - "log_id": "7DSwkhPo35hYEZa4DVlPq6Pm/bG4aOw/kqhHvYd6z/k=", - "key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE8LxK0sAKYODiZe9gDeak7agggQ0wvBOeEMSi7cLlFzcTlm1AexxsC04r/4rBIhf8liQqyRTrL3u1jpz6NJ4tLg==", - "witness_key": "MCowBQYDK2VwAyEAWTVSsOnsIYq+LZ6CUxgI8ONvJvE+YSF27N9BXZ02EP8=", - "mmd": 86400, - "submission_url": "https://static-ct.cloudflareresearch.com/logs/cftest2025h1a/", - "monitoring_url": "https://static-ct-public-cftest2025h1a.cloudflareresearch.com/", - "temporal_interval": { - "start_inclusive": "2025-01-01T00:00:00Z", - "end_exclusive": "2025-07-01T00:00:00Z" - } - } - - curl -s https://static-ct.cloudflareresearch.com/logs/cftest2025h2a/metadata | jq - { - "description": "Cloudflare Research 'cftest2025h2a' log", - "log_type": "test", - "log_id": "2KJiliJSBM2181NJWC5O1mWiRRsPJ6i2iWE2s7n8Bwg=", - "key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEYipauBOPEktPb0JVpkRQq6wtRDRIj8GmKYvzM0Lpw1oSh9Uis9khpPCH6xyrDstk019AHuCq19KT5f+/MkY/yA==", - "witness_key": "MCowBQYDK2VwAyEA8jhNnqw2LXtyjb0Os+R3eiKfxnsP8tnke5iZZ16nBbU=", - "mmd": 86400, - "submission_url": "https://static-ct.cloudflareresearch.com/logs/cftest2025h2a/", - "monitoring_url": "https://static-ct-public-cftest2025h2a.cloudflareresearch.com/", - "temporal_interval": { - "start_inclusive": "2025-07-01T00:00:00Z", - "end_exclusive": "2026-01-01T00:00:00Z" - } - } - ## Deployment ### Local deployment @@ -81,33 +45,37 @@ Follow these instructions to spin up a CT log on your local machine using the `d prefix=$(head -n1 $file | grep -o "pre-") cat $file | while (set -o pipefail; openssl x509 -outform DER 2>/dev/null | base64); do :; done |\ sed '/^$/d' | sed 's/.*/"&"/' | jq -sc '{"chain":.}' |\ - curl -s "http://localhost:8787/logs/dev2025h1a/ct/v1/add-${prefix}chain" -d@- & + curl -s "http://localhost:8787/logs/dev2026h2a/ct/v1/add-${prefix}chain" -d@- & done rm -r $tmpdir ``` Checkpoints and other static data can also be retrieved through the worker (or directly from the R2 bucket): - curl -s "http://localhost:8787/logs/dev2025h1a/checkpoint" + curl -s "http://localhost:8787/logs/dev2026h2a/checkpoint" + + Metadata necessary for writing to or consuming from logs is available at /metadata.json. + + curl -s "http://localhost:8787/logs/dev2026h2a/metadata.json" - Metadata necessary for writing to or consuming from logs is available at /metadata. + The operator list is available at /operator-list.json. - curl -s "http://localhost:8787/logs/dev2025h1a/metadata" + curl -s "http://localhost:8787/operator-list.json" Prometheus metrics are exposed _publicly_ at /metrics. - curl -s "http://localhost:8787/logs/dev2025h1a/metrics" + curl -s "http://localhost:8787/logs/dev2026h2a/metrics" ### Deployment to a workers.dev subdomain Follow these instructions to deploy a CT log with the `dev` configuration to Cloudflare's network. -Run the following for each of the `dev2025h1a` and `dev2025h2a` log shards to configure resources (or use `scripts/create-log.sh`): +Run the following for each configured log shard to configure resources (or use `scripts/create-log.sh`): 1. Set log shard name and deployment environment. The [location hint][location-hint] is optional. ```bash -export LOG_NAME=dev2025h1a +export LOG_NAME=dev2026h2a export CLOUDFLARE_ACCOUNT_ID=some-account-id-here export ENV=dev export LOCATION=wnam # optional @@ -149,9 +117,9 @@ npx wrangler -e=${ENV} tail ### Deployment to a custom domain -Follow these instructions to deploy to a custom domain, suitable for running a public CT log. We'll use the `cftest` environment as an example, which was used to deploy the [test logs][#test-logs]. +Follow these instructions to deploy to a custom domain, suitable for running a public CT log. -1. Create a new [deployment environment](https://developers.cloudflare.com/workers/wrangler/environments/) in `wrangler.jsonc` by copying or editing the existing `cftest` environment. +1. Create a new [deployment environment](https://developers.cloudflare.com/workers/wrangler/environments/) in `wrangler.jsonc`. 1. Create a file `config.${ENV}.json` with the configuration for the log shards. @@ -159,8 +127,8 @@ Follow these instructions to deploy to a custom domain, suitable for running a p 1. First set environment variables to specify the log shard name and deployment environment as below and then follow the [instructions above](#deployment-to-a-workersdev-subdomain) to create resources for each log shard. - export LOG_NAME=cftest2025h1a - export ENV=cftest + export LOG_NAME=example2027h1 + export ENV=example 1. Configure R2 buckets via Cloudflare dashboard. The monitoring APIs are served directly from the bucket, so configure for public access with caching and compression. diff --git a/crates/ct_worker/build.rs b/crates/ct_worker/build.rs index 8a149084..f8b323fb 100644 --- a/crates/ct_worker/build.rs +++ b/crates/ct_worker/build.rs @@ -5,7 +5,7 @@ use chrono::Months; use config::AppConfig; -use config::LogType; +use config::IntendedUse; use std::env; use std::fs; use url::Url; @@ -23,11 +23,11 @@ fn main() { && (3..R2_BUCKET_PREFIX_LEN).contains(&name.len()), "invalid shard name '{name}'. Shard names only contain lowercase letters, numbers, and be between 3 and {R2_BUCKET_PREFIX_LEN} characters long." ); - if params.log_type != Some(LogType::Test) { + if params.intended_use == IntendedUse::Production { // Chrome's CT policy (https://googlechrome.github.io/CertificateTransparency/log_policy.html) states: - // "The certificate expiry ranges for CT Logs must be no longer than one calendar year and should be no shorter than six months." + // "each log's expiry range should be between 3 and 12 months." assert!( - (params.temporal_interval.start_inclusive + Months::new(6) + (params.temporal_interval.start_inclusive + Months::new(3) ..=params.temporal_interval.start_inclusive + Months::new(12)) .contains(¶ms.temporal_interval.end_exclusive), "{name} invalid temporal interval: [{}, {})", @@ -46,10 +46,8 @@ fn main() { ); } - check_url(¶ms.submission_url); - if !params.monitoring_url.is_empty() { - check_url(¶ms.monitoring_url); - } + check_url(¶ms.submission_endpoint.url); + check_url(¶ms.monitoring_endpoint.url); } // Get and validate roots from an embedded roots file, which must exist if diff --git a/crates/ct_worker/config.cftest.json b/crates/ct_worker/config.cftest.json deleted file mode 100644 index 81f7a4b1..00000000 --- a/crates/ct_worker/config.cftest.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "logging_level": "info", - "logs": { - "cftest2025h1a": { - "description": "Cloudflare Research 'cftest2025h1a' log", - "log_type": "test", - "submission_url": "https://static-ct.cloudflareresearch.com/logs/cftest2025h1a/", - "monitoring_url": "https://static-ct-public-cftest2025h1a.cloudflareresearch.com/", - "temporal_interval": { - "start_inclusive": "2025-01-01T00:00:00Z", - "end_exclusive": "2025-07-01T00:00:00Z" - }, - "max_sequence_skips": 1, - "sequence_interval_millis": 750, - "sequence_skip_threshold_millis": 250, - "location_hint": "enam" - }, - "cftest2025h2a": { - "description": "Cloudflare Research 'cftest2025h2a' log", - "log_type": "test", - "submission_url": "https://static-ct.cloudflareresearch.com/logs/cftest2025h2a/", - "monitoring_url": "https://static-ct-public-cftest2025h2a.cloudflareresearch.com/", - "temporal_interval": { - "start_inclusive": "2025-07-01T00:00:00Z", - "end_exclusive": "2026-01-01T00:00:00Z" - }, - "max_sequence_skips": 1, - "sequence_interval_millis": 750, - "sequence_skip_threshold_millis": 250, - "location_hint": "enam" - } - } -} \ No newline at end of file diff --git a/crates/ct_worker/config.dev.json b/crates/ct_worker/config.dev.json index 38de6b91..f950bd3c 100644 --- a/crates/ct_worker/config.dev.json +++ b/crates/ct_worker/config.dev.json @@ -1,54 +1,41 @@ { "logging_level": "info", + "operator_name": "Cloudflare", "logs": { "e2etestshard": { - "description": "e2e test shard", - "log_type": "test", - "submission_url": "http://localhost:8787/logs/e2etestshard/", + "friendly_name": "e2e test shard", + "intended_use": "test", + "status": "active", + "status_timestamp": "2026-09-23T00:00:00Z", + "submission_endpoint": { "url": "http://localhost:8787/logs/e2etestshard/" }, + "monitoring_endpoint": { "url": "http://localhost:8787/logs/e2etestshard/" }, "temporal_interval": { "start_inclusive": "2026-01-01T00:00:00Z", "end_exclusive": "2076-01-01T00:00:00Z" }, "location_hint": "enam" }, - "dev2025h1a": { - "description": "Dev 2025h1a", - "log_type": "test", - "submission_url": "http://localhost:8787/logs/dev2025h1a/", - "temporal_interval": { - "start_inclusive": "2025-01-01T00:00:00Z", - "end_exclusive": "2025-07-01T00:00:00Z" - }, - "location_hint": "enam" - }, - "dev2025h2a": { - "description": "Dev 2025h2a", - "log_type": "test", - "submission_url": "http://localhost:8787/logs/dev2025h2a/", - "temporal_interval": { - "start_inclusive": "2025-07-01T00:00:00Z", - "end_exclusive": "2026-01-01T00:00:00Z" - }, - "max_sequence_skips": 1, - "sequence_interval_millis": 750, - "sequence_skip_threshold_millis": 250, - "location_hint": "enam" - }, - "dev2026h1a": { - "description": "Dev 2026h1a", - "log_type": "test", - "reject_expired": false, - "submission_url": "http://localhost:8787/logs/dev2026h1a/", + "readonlytest": { + "friendly_name": "read-only test shard", + "intended_use": "test", + "status": "readonly", + "status_timestamp": "2026-09-23T00:00:00Z", + "submission_endpoint": { "url": "http://localhost:8787/logs/readonlytest/" }, + "monitoring_endpoint": { "url": "http://localhost:8787/logs/readonlytest/" }, "temporal_interval": { "start_inclusive": "2026-01-01T00:00:00Z", - "end_exclusive": "2026-07-01T00:00:00Z" + "end_exclusive": "2076-01-01T00:00:00Z" }, "location_hint": "enam" }, "dev2026h2a": { - "description": "Dev 2026h2a", - "log_type": "test", - "submission_url": "http://localhost:8787/logs/dev2026h2a/", + "friendly_name": "Dev 2026h2a", + "intended_use": "test", + "status": "active", + "status_timestamp": "2026-07-01T00:00:00Z", + "reject_expired": false, + "submission_endpoint": { "url": "http://localhost:8787/logs/dev2026h2a/" }, + "monitoring_endpoint": { "url": "http://localhost:8787/logs/dev2026h2a/" }, "temporal_interval": { "start_inclusive": "2026-07-01T00:00:00Z", "end_exclusive": "2027-01-01T00:00:00Z" @@ -59,9 +46,13 @@ "location_hint": "enam" }, "dev2027h1a": { - "description": "Dev 2027h1a", - "log_type": "test", - "submission_url": "http://localhost:8787/logs/dev2027h1a/", + "friendly_name": "Dev 2027h1a", + "intended_use": "test", + "status": "active", + "status_timestamp": "2026-09-23T00:00:00Z", + "submission_endpoint": { "url": "http://localhost:8787/logs/dev2027h1a/" }, + "monitoring_endpoint": { "url": "http://localhost:8787/logs/dev2027h1a/" }, + "include_in_operator_list": true, "temporal_interval": { "start_inclusive": "2027-01-01T00:00:00Z", "end_exclusive": "2027-07-01T00:00:00Z" @@ -69,9 +60,13 @@ "location_hint": "enam" }, "dev2027h2a": { - "description": "Dev 2027h2a", - "log_type": "test", - "submission_url": "http://localhost:8787/logs/dev2027h2a/", + "friendly_name": "Dev 2027h2a", + "intended_use": "test", + "status": "active", + "status_timestamp": "2026-09-23T00:00:00Z", + "submission_endpoint": { "url": "http://localhost:8787/logs/dev2027h2a/" }, + "monitoring_endpoint": { "url": "http://localhost:8787/logs/dev2027h2a/" }, + "include_in_operator_list": true, "temporal_interval": { "start_inclusive": "2027-07-01T00:00:00Z", "end_exclusive": "2028-01-01T00:00:00Z" diff --git a/crates/ct_worker/config.raio.json b/crates/ct_worker/config.raio.json deleted file mode 100644 index d580fcc3..00000000 --- a/crates/ct_worker/config.raio.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "logging_level": "info", - "logs": { - "raio2025h2b": { - "description": "Cloudflare 'Raio2025h2b' log", - "submission_url": "https://ct.cloudflare.com/logs/raio2025h2b/", - "monitoring_url": "https://raio2025h2b.ct.cloudflare.com/", - "temporal_interval": { - "start_inclusive": "2025-07-01T00:00:00Z", - "end_exclusive": "2026-01-01T00:00:00Z" - }, - "max_sequence_skips": 1, - "sequence_interval_millis": 750, - "sequence_skip_threshold_millis": 250, - "location_hint": "wnam" - }, - "raio2026h1a": { - "description": "Cloudflare 'Raio2026h1a' log", - "submission_url": "https://ct.cloudflare.com/logs/raio2026h1a/", - "monitoring_url": "https://raio2026h1a.ct.cloudflare.com/", - "temporal_interval": { - "start_inclusive": "2026-01-01T00:00:00Z", - "end_exclusive": "2026-07-01T00:00:00Z" - }, - "max_sequence_skips": 1, - "sequence_interval_millis": 750, - "sequence_skip_threshold_millis": 250, - "location_hint": "wnam" - }, - "raio2026h2a": { - "description": "Cloudflare 'Raio2026h2a' log", - "submission_url": "https://ct.cloudflare.com/logs/raio2026h2a/", - "monitoring_url": "https://raio2026h2a.ct.cloudflare.com/", - "temporal_interval": { - "start_inclusive": "2026-07-01T00:00:00Z", - "end_exclusive": "2027-01-01T00:00:00Z" - }, - "max_sequence_skips": 1, - "sequence_interval_millis": 750, - "sequence_skip_threshold_millis": 250, - "location_hint": "wnam" - }, - "raio2027h1a": { - "description": "Cloudflare 'Raio2027h1a' log", - "submission_url": "https://ct.cloudflare.com/logs/raio2027h1a/", - "monitoring_url": "https://raio2027h1a.ct.cloudflare.com/", - "temporal_interval": { - "start_inclusive": "2027-01-01T00:00:00Z", - "end_exclusive": "2027-07-01T00:00:00Z" - }, - "max_sequence_skips": 1, - "sequence_interval_millis": 750, - "sequence_skip_threshold_millis": 250, - "location_hint": "wnam" - }, - "raio2027h2a": { - "description": "Cloudflare 'Raio2027h2a' log", - "submission_url": "https://ct.cloudflare.com/logs/raio2027h2a/", - "monitoring_url": "https://raio2027h2a.ct.cloudflare.com/", - "temporal_interval": { - "start_inclusive": "2027-07-01T00:00:00Z", - "end_exclusive": "2028-01-01T00:00:00Z" - }, - "max_sequence_skips": 1, - "sequence_interval_millis": 750, - "sequence_skip_threshold_millis": 250, - "location_hint": "wnam" - } - } -} \ No newline at end of file diff --git a/crates/ct_worker/config.schema.json b/crates/ct_worker/config.schema.json index 93628d3d..e725fecc 100644 --- a/crates/ct_worker/config.schema.json +++ b/crates/ct_worker/config.schema.json @@ -13,6 +13,10 @@ ], "description": "Log verbosity." }, + "operator_name": { + "type": "string", + "description": "Human-readable name published in the operator log list." + }, "logs": { "type": "object", "description": "Dictionary CT log shard names to configurations.", @@ -22,26 +26,38 @@ "type": "object", "additionalProperties": false, "properties": { - "description": { + "friendly_name": { "type": "string", - "description": "Description of the log." + "description": "Brief human-readable name for the log." }, - "log_type": { - "description": "The purpose of this log, e.g. test.", + "intended_use": { + "description": "The operator's intended use for the log.", "type": "string", "enum": [ - "prod", + "production", "test", - "monitoring_only" + "decommissioned" ] }, - "submission_url": { + "status": { "type": "string", - "description": "URL for log submissions." + "enum": [ + "active", + "readonly", + "inactive" + ], + "description": "The intended operational state of the log." }, - "monitoring_url": { + "status_timestamp": { "type": "string", - "description": "URL for log monitoring. Omit when no monitoring service is configured." + "format": "date-time", + "description": "When the current status was set." + }, + "submission_endpoint": { + "$ref": "#/definitions/endpoint" + }, + "monitoring_endpoint": { + "$ref": "#/definitions/endpoint" }, "temporal_interval": { "type": "object", @@ -124,21 +140,42 @@ "default": 60, "description": "How long to wait in between runs of the partial tile cleaner. For static CT, the cleaner can clean 498 tiles (127,488 entries) per run before hitting the Workers limit of 1000 subrequests, so the default of once every 60 seconds should keep up with a log that grows at 2000 entries/second." }, - "read_only": { + "include_in_operator_list": { "type": "boolean", "default": false, - "description": "Disable add-(pre-)chain requests to prevent a log from accepting new submissions and issuing SCTs. This can be used to limit the blast radius of log failures." + "description": "Include this log's metadata URL in the operator log list." } }, "required": [ + "friendly_name", + "intended_use", + "status", + "status_timestamp", "temporal_interval", - "submission_url" + "submission_endpoint", + "monitoring_endpoint" ] } } } }, "required": [ + "operator_name", "logs" - ] + ], + "definitions": { + "endpoint": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url" + ] + } + } } diff --git a/crates/ct_worker/config/src/lib.rs b/crates/ct_worker/config/src/lib.rs index 42c5a356..03dd19ae 100644 --- a/crates/ct_worker/config/src/lib.rs +++ b/crates/ct_worker/config/src/lib.rs @@ -15,26 +15,43 @@ pub struct TemporalInterval { #[derive(Deserialize, Debug)] pub struct AppConfig { pub logging_level: Option, + pub operator_name: String, pub logs: HashMap, } #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "lowercase")] -pub enum LogType { - Prod, +pub enum IntendedUse { + Production, Test, - MonitoringOnly, + Decommissioned, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum LogStatus { + Active, + Readonly, + Inactive, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct EndpointInfo { + pub url: String, } #[derive(Deserialize, Debug)] #[allow(clippy::struct_excessive_bools)] pub struct LogParams { - pub description: Option, - pub log_type: Option, - #[serde(default)] - pub monitoring_url: String, - pub submission_url: String, + pub friendly_name: String, + pub intended_use: IntendedUse, + pub status: LogStatus, + pub status_timestamp: DateTime, + pub submission_endpoint: EndpointInfo, + pub monitoring_endpoint: EndpointInfo, pub temporal_interval: TemporalInterval, + #[serde(default)] + pub include_in_operator_list: bool, pub location_hint: Option, #[serde(default = "default_u64::<1000>")] pub sequence_interval_millis: u64, @@ -55,8 +72,6 @@ pub struct LogParams { pub reject_expired: bool, #[serde(default = "default_u64::<60>")] pub clean_interval_secs: u64, - #[serde(default = "default_bool::")] - pub read_only: bool, } fn default_bool() -> bool { @@ -82,6 +97,20 @@ mod tests { serde_json::from_str(include_str!("../../config.dev.json")).unwrap(); assert!(config.logs["e2etestshard"].reject_expired); - assert!(!config.logs["dev2026h1a"].reject_expired); + assert!(!config.logs["dev2026h2a"].reject_expired); + } + + #[test] + fn operator_list_contains_only_selected_shards() { + let config: AppConfig = + serde_json::from_str(include_str!("../../config.dev.json")).unwrap(); + let mut logs = config + .logs + .iter() + .filter_map(|(name, params)| params.include_in_operator_list.then_some(name.as_str())) + .collect::>(); + logs.sort_unstable(); + + assert_eq!(logs, ["dev2027h1a", "dev2027h2a"]); } } diff --git a/crates/ct_worker/roots.cftest.pem b/crates/ct_worker/roots.cftest.pem deleted file mode 100644 index 589121e7..00000000 --- a/crates/ct_worker/roots.cftest.pem +++ /dev/null @@ -1,82 +0,0 @@ -# letsencrypt-stg-root-x1.pem ------BEGIN CERTIFICATE----- -MIIFmDCCA4CgAwIBAgIQU9C87nMpOIFKYpfvOHFHFDANBgkqhkiG9w0BAQsFADBm -MQswCQYDVQQGEwJVUzEzMDEGA1UEChMqKFNUQUdJTkcpIEludGVybmV0IFNlY3Vy -aXR5IFJlc2VhcmNoIEdyb3VwMSIwIAYDVQQDExkoU1RBR0lORykgUHJldGVuZCBQ -ZWFyIFgxMB4XDTE1MDYwNDExMDQzOFoXDTM1MDYwNDExMDQzOFowZjELMAkGA1UE -BhMCVVMxMzAxBgNVBAoTKihTVEFHSU5HKSBJbnRlcm5ldCBTZWN1cml0eSBSZXNl -YXJjaCBHcm91cDEiMCAGA1UEAxMZKFNUQUdJTkcpIFByZXRlbmQgUGVhciBYMTCC -AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALbagEdDTa1QgGBWSYkyMhsc -ZXENOBaVRTMX1hceJENgsL0Ma49D3MilI4KS38mtkmdF6cPWnL++fgehT0FbRHZg -jOEr8UAN4jH6omjrbTD++VZneTsMVaGamQmDdFl5g1gYaigkkmx8OiCO68a4QXg4 -wSyn6iDipKP8utsE+x1E28SA75HOYqpdrk4HGxuULvlr03wZGTIf/oRt2/c+dYmD -oaJhge+GOrLAEQByO7+8+vzOwpNAPEx6LW+crEEZ7eBXih6VP19sTGy3yfqK5tPt -TdXXCOQMKAp+gCj/VByhmIr+0iNDC540gtvV303WpcbwnkkLYC0Ft2cYUyHtkstO -fRcRO+K2cZozoSwVPyB8/J9RpcRK3jgnX9lujfwA/pAbP0J2UPQFxmWFRQnFjaq6 -rkqbNEBgLy+kFL1NEsRbvFbKrRi5bYy2lNms2NJPZvdNQbT/2dBZKmJqxHkxCuOQ -FjhJQNeO+Njm1Z1iATS/3rts2yZlqXKsxQUzN6vNbD8KnXRMEeOXUYvbV4lqfCf8 -mS14WEbSiMy87GB5S9ucSV1XUrlTG5UGcMSZOBcEUpisRPEmQWUOTWIoDQ5FOia/ -GI+Ki523r2ruEmbmG37EBSBXdxIdndqrjy+QVAmCebyDx9eVEGOIpn26bW5LKeru -mJxa/CFBaKi4bRvmdJRLAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMB -Af8EBTADAQH/MB0GA1UdDgQWBBS182Xy/rAKkh/7PH3zRKCsYyXDFDANBgkqhkiG -9w0BAQsFAAOCAgEAncDZNytDbrrVe68UT6py1lfF2h6Tm2p8ro42i87WWyP2LK8Y -nLHC0hvNfWeWmjZQYBQfGC5c7aQRezak+tHLdmrNKHkn5kn+9E9LCjCaEsyIIn2j -qdHlAkepu/C3KnNtVx5tW07e5bvIjJScwkCDbP3akWQixPpRFAsnP+ULx7k0aO1x -qAeaAhQ2rgo1F58hcflgqKTXnpPM02intVfiVVkX5GXpJjK5EoQtLceyGOrkxlM/ -sTPq4UrnypmsqSagWV3HcUlYtDinc+nukFk6eR4XkzXBbwKajl0YjztfrCIHOn5Q -CJL6TERVDbM/aAPly8kJ1sWGLuvvWYzMYgLzDul//rUF10gEMWaXVZV51KpS9DY/ -5CunuvCXmEQJHo7kGcViT7sETn6Jz9KOhvYcXkJ7po6d93A/jy4GKPIPnsKKNEmR -xUuXY4xRdh45tMJnLTUDdC9FIU0flTeO9/vNpVA8OPU1i14vCz+MU8KX1bV3GXm/ -fxlB7VBBjX9v5oUep0o/j68R/iDlCOM4VVfRa8gX6T2FU7fNdatvGro7uQzIvWof -gN9WUwCbEMBy/YhBSrXycKA8crgGg3x1mIsopn88JKwmMBa68oS7EHM9w7C4y71M -7DiA+/9Qdp9RBWJpTS9i/mDnJg1xvo8Xz49mrrgfmcAXTCJqXi24NatI3Oc= ------END CERTIFICATE----- - -# letsencrypt-stg-root-x2.pem ------BEGIN CERTIFICATE----- -MIICTjCCAdSgAwIBAgIRAIPgc3k5LlLVLtUUvs4K/QcwCgYIKoZIzj0EAwMwaDEL -MAkGA1UEBhMCVVMxMzAxBgNVBAoTKihTVEFHSU5HKSBJbnRlcm5ldCBTZWN1cml0 -eSBSZXNlYXJjaCBHcm91cDEkMCIGA1UEAxMbKFNUQUdJTkcpIEJvZ3VzIEJyb2Nj -b2xpIFgyMB4XDTIwMDkwNDAwMDAwMFoXDTQwMDkxNzE2MDAwMFowaDELMAkGA1UE -BhMCVVMxMzAxBgNVBAoTKihTVEFHSU5HKSBJbnRlcm5ldCBTZWN1cml0eSBSZXNl -YXJjaCBHcm91cDEkMCIGA1UEAxMbKFNUQUdJTkcpIEJvZ3VzIEJyb2Njb2xpIFgy -MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEOvS+w1kCzAxYOJbA06Aw0HFP2tLBLKPo -FQqR9AMskl1nC2975eQqycR+ACvYelA8rfwFXObMHYXJ23XLB+dAjPJVOJ2OcsjT -VqO4dcDWu+rQ2VILdnJRYypnV1MMThVxo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYD -VR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU3tGjWWQOwZo2o0busBB2766XlWYwCgYI -KoZIzj0EAwMDaAAwZQIwRcp4ZKBsq9XkUuN8wfX+GEbY1N5nmCRc8e80kUkuAefo -uc2j3cICeXo1cOybQ1iWAjEA3Ooawl8eQyR4wrjCofUE8h44p0j7Yl/kBlJZT8+9 -vbtH7QiVzeKCOTQPINyRql6P ------END CERTIFICATE----- - -# DigiCert CT Test Root ------BEGIN CERTIFICATE----- -MIIB8jCCAXigAwIBAgIRAIyOQJBSOUS0rZpkdkjxtDwwCgYIKoZIzj0EAwMwOTEX -MBUGA1UEChMORGlnaUNlcnQsIEluYy4xHjAcBgNVBAMTFURpZ2lDZXJ0IENUIFRl -c3QgUm9vdDAgFw0yMzAxMTkxNDM0MDNaGA8yMDUzMDExOTE0MzQwM1owOTEXMBUG -A1UEChMORGlnaUNlcnQsIEluYy4xHjAcBgNVBAMTFURpZ2lDZXJ0IENUIFRlc3Qg -Um9vdDB2MBAGByqGSM49AgEGBSuBBAAiA2IABABQuhdtznMJNe6yNwml0T158MQ2 -KkQyunEHgOMpxeXY6Io9fXnMtkdj2qP9CCqhND2kp93f/oqPMw2PcEjar2v3HiOI -jdzBsm7ecw0JWy0Zz4/8Lmpla3FAhHS5BoKTuKNCMEAwDgYDVR0PAQH/BAQDAgGG -MA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFONDnZfhq7kEGhdBqpL8TJE0+cHn -MAoGCCqGSM49BAMDA2gAMGUCMQCVr77mcjhS5tiV+2mjkOTH+44wj4mphb5Q8MPd -NMBAMQzdGhRpANjB+TNLLNjXd3oCMAesBJGQBLbv1sQvbHEn0ie9oTl32+IxUUpu -7I+a+N4XXDZyKsVNL54hI61d+sS6cg== ------END CERTIFICATE----- - -# Raytonne Staging ------BEGIN CERTIFICATE----- -MIICUzCCAdigAwIBAgIJOqCV9Q6lUL/FMAoGCCqGSM49BAMDMG4xCzAJBgNVBAYT -AkNOMRIwEAYDVQQIDAnmsrPljZfnnIExJzAlBgNVBAoMHuays+WNl+eRnui2uOWV -hui0uOaciemZkOWFrOWPuDEiMCAGA1UEAwwZ55Ge6La44ri65a6b77yIU3RhZ2lu -Z++8iTAeFw0yMTA3MDEwMDAwMDBaFw0zNjAxMDEwMDAwMDBaMG4xCzAJBgNVBAYT -AkNOMRIwEAYDVQQIDAnmsrPljZfnnIExJzAlBgNVBAoMHuays+WNl+eRnui2uOWV -hui0uOaciemZkOWFrOWPuDEiMCAGA1UEAwwZ55Ge6La44ri65a6b77yIU3RhZ2lu -Z++8iTB2MBAGByqGSM49AgEGBSuBBAAiA2IABEcACuitImXwh1UnPQtSdSFezvuk -DrB1zrZ62DrXqQDhM3YtAf4d4CAgwDnFhdjjitN0haSBqV2/WLsaummwPIr86mur -t8t+PZbIAor6eFdUm0dTNj2qMykbKMtVkjEeDaNCMEAwDwYDVR0TAQH/BAUwAwEB -/zAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0OBBYEFGsylsgxPDWNk3pv6GEjpknNULzO -MAoGCCqGSM49BAMDA2kAMGYCMQDBjfgJnRx32lWxA6ktDaqxxtGuHdRm/+InJ0NO -862fDHVLiNSMWluqk2ET18DMxFoCMQDLQ53QrXgs8W6MlzVtrcTnylUCYIUVPKTA -Lohqkfpo2uYCpwZjTNVOrAfw5ij1ivk= ------END CERTIFICATE----- diff --git a/crates/ct_worker/src/frontend_worker.rs b/crates/ct_worker/src/frontend_worker.rs index f3b48295..e4312b4c 100644 --- a/crates/ct_worker/src/frontend_worker.rs +++ b/crates/ct_worker/src/frontend_worker.rs @@ -4,7 +4,7 @@ //! Entrypoint for the static CT submission APIs. use crate::{CONFIG, StaticCTSequenceMetadata, init_sentry, load_roots, load_signing_key}; -use config::{LogType, TemporalInterval}; +use config::{EndpointInfo, IntendedUse, LogStatus, TemporalInterval}; use generic_log_worker::{ ENTRY_ENDPOINT, ObjectBucket, batcher_id_from_lookup_key, deserialize, frontend::request_metrics, @@ -39,23 +39,38 @@ use tower_service::Service; // maximum allowed in Chrome's policy, 60 seconds, to allow future flexibility. // For details, see https://github.com/C2SP/C2SP/issues/79. const MAX_MERGE_DELAY_SECS: usize = 60; +const LOG_METADATA_SCHEMA: &str = + "https://googlechrome.github.io/CertificateTransparency/log_schema_v2.json"; +const OPERATOR_LIST_SCHEMA: &str = + "https://googlechrome.github.io/CertificateTransparency/operator_list_schema_v1.json"; #[serde_as] #[derive(Serialize)] -struct LogV3JsonResponse<'a> { - #[serde(skip_serializing_if = "Option::is_none")] - description: &'a Option, - #[serde(skip_serializing_if = "Option::is_none")] - log_type: Option, +struct LogMetadataResponse<'a> { + #[serde(rename = "$schema")] + schema: &'static str, #[serde_as(as = "Base64")] log_id: &'a [u8], #[serde_as(as = "Base64")] key: &'a [u8], - mmd: usize, - submission_url: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - monitoring_url: Option<&'a str>, + friendly_name: &'a str, + log_spec: &'static str, + mmd_seconds: usize, + intended_use: IntendedUse, + tls_only: bool, temporal_interval: &'a TemporalInterval, + status: LogStatus, + status_timestamp: &'a chrono::DateTime, + submission_endpoint: &'a EndpointInfo, + monitoring_endpoint: &'a EndpointInfo, +} + +#[derive(Serialize)] +struct OperatorListResponse { + #[serde(rename = "$schema")] + schema: &'static str, + operator_name: &'static str, + logs: Vec, } /// Start is the first code run when the Wasm module is loaded. @@ -84,7 +99,8 @@ async fn main( .route("/logs/{log}/ct/v1/get-roots", get(get_roots)) .route("/logs/{log}/ct/v1/add-chain", post(add_chain)) .route("/logs/{log}/ct/v1/add-pre-chain", post(add_pre_chain)) - .route("/logs/{log}/log.v3.json", get(log_v3_json)) + .route("/logs/{log}/metadata.json", get(log_metadata)) + .route("/operator-list.json", get(operator_list)) .route("/logs/{log}/sequencer_id", get(sequencer_id)) .layer(middleware::from_fn_with_state( (env.clone(), metrics::FrontendWorkerMetrics::new(®istry)), @@ -143,7 +159,7 @@ enum AppError { InternalServerError(String), BadRequest(String), UnknownLog, - ReadonlyLog, + SubmissionsClosed, } impl From for AppError { @@ -169,28 +185,19 @@ impl IntoResponse for AppError { match self { Self::InternalServerError(msg) => { log::error!("Internal error: {msg}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "Internal error", - ) - .into_response() - } - Self::BadRequest(e) => { - ( - StatusCode::BAD_REQUEST, - format!("Bad request{}{e}", if e.is_empty() { "" } else { ": " }) - ).into_response() - } - Self::UnknownLog => { - (StatusCode::BAD_REQUEST, "Unknown log").into_response() - } - Self::ReadonlyLog => { - ( - StatusCode::SERVICE_UNAVAILABLE, - [(header::RETRY_AFTER, "300")], - "The log is temporarily in read-only mode during maintenance. Please try again after 5 minutes." - ).into_response() + (StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response() } + Self::BadRequest(e) => ( + StatusCode::BAD_REQUEST, + format!("Bad request{}{e}", if e.is_empty() { "" } else { ": " }), + ) + .into_response(), + Self::UnknownLog => (StatusCode::BAD_REQUEST, "Unknown log").into_response(), + Self::SubmissionsClosed => ( + StatusCode::SERVICE_UNAVAILABLE, + "The log is not accepting submissions.", + ) + .into_response(), } } } @@ -229,9 +236,9 @@ async fn add_pre_chain( add_chain_or_pre_chain(body, &env, &log, true).await } -/// `GET /logs/{log}/log.v3.json` +/// `GET /logs/{log}/metadata.json` #[worker::send] -async fn log_v3_json( +async fn log_metadata( State(env): State, PathParams { log }: PathParams, ) -> ApiResult { @@ -244,22 +251,47 @@ async fn log_v3_json( Ok(( StatusCode::OK, [(header::CONTENT_TYPE, "application/json")], - serde_json::to_string(&LogV3JsonResponse { - description: ¶ms.description, - log_type: params.log_type, + serde_json::to_string(&LogMetadataResponse { + schema: LOG_METADATA_SCHEMA, log_id, key: key.as_bytes(), - submission_url: ¶ms.submission_url, - monitoring_url: (!params.monitoring_url.is_empty()) - .then_some(params.monitoring_url.as_str()), - mmd: MAX_MERGE_DELAY_SECS, + friendly_name: ¶ms.friendly_name, + log_spec: "static-ct-api", + mmd_seconds: MAX_MERGE_DELAY_SECS, + intended_use: params.intended_use, + tls_only: true, temporal_interval: ¶ms.temporal_interval, + status: params.status, + status_timestamp: ¶ms.status_timestamp, + submission_endpoint: ¶ms.submission_endpoint, + monitoring_endpoint: ¶ms.monitoring_endpoint, }) .unwrap(), ) .into_response()) } +/// `GET /operator-list.json` +async fn operator_list() -> impl IntoResponse { + let mut logs = CONFIG + .logs + .values() + .filter(|params| params.include_in_operator_list) + .map(|params| { + format!( + "{}/metadata.json", + params.submission_endpoint.url.trim_end_matches('/') + ) + }) + .collect::>(); + logs.sort(); + Json(OperatorListResponse { + schema: OPERATOR_LIST_SCHEMA, + operator_name: &CONFIG.operator_name, + logs, + }) +} + /// `GET /logs/{log}/sequencer_id` #[worker::send] async fn sequencer_id( @@ -281,8 +313,8 @@ async fn add_chain_or_pre_chain( expect_precert: bool, ) -> ApiResult> { let params = &CONFIG.logs[log]; - if params.read_only { - return Err(AppError::ReadonlyLog); + if params.status != LogStatus::Active { + return Err(AppError::SubmissionsClosed); } let req: AddChainRequest = match serde_json::from_slice(&body) { Ok(req) => req, diff --git a/crates/ct_worker/src/lib.rs b/crates/ct_worker/src/lib.rs index 1c724322..97ec3778 100644 --- a/crates/ct_worker/src/lib.rs +++ b/crates/ct_worker/src/lib.rs @@ -4,7 +4,7 @@ #![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] use crate::ccadb_roots_cron::{CCADB_ROOTS_NAMESPACE, ccadb_roots_filename, update_ccadb_roots}; -use config::{AppConfig, LogType}; +use config::{AppConfig, IntendedUse}; use ed25519_dalek::SigningKey as Ed25519SigningKey; use p256::{ecdsa::SigningKey as EcdsaSigningKey, pkcs8::DecodePrivateKey}; use signed_note::KeyName; @@ -92,7 +92,8 @@ pub(crate) fn load_origin(name: &str) -> KeyName { // The origin line MUST be the submission prefix of the log as a schema-less URL with no trailing slashes. KeyName::new( CONFIG.logs[name] - .submission_url + .submission_endpoint + .url .trim_start_matches("http://") .trim_start_matches("https://") .trim_end_matches('/') @@ -144,7 +145,7 @@ async fn load_roots(env: &Env, name: &str) -> Result> { // which the Workers runtime would cancel as a cross-request deadlock. let mut pool = CertPool::default(); - if log_config.log_type == Some(LogType::Test) { + if log_config.intended_use == IntendedUse::Test { let pem = include_bytes!(concat!(env!("OUT_DIR"), "/roots.pem")); // load_pem_chain fails on empty input: https://github.com/RustCrypto/formats/pull/1965 if !pem.is_empty() { diff --git a/crates/ct_worker/wrangler.jsonc b/crates/ct_worker/wrangler.jsonc index 9b925278..3972aede 100644 --- a/crates/ct_worker/wrangler.jsonc +++ b/crates/ct_worker/wrangler.jsonc @@ -34,18 +34,6 @@ "id": "e4aab0c89ebe492c9c81c444c78a31ff", "binding": "ccadb_roots" }, - { - "id": "3a23691917c844d59aea47fdf964850f", - "binding": "cache_dev2025h1a" - }, - { - "id": "38045c90369140b091b3b68451dc6c29", - "binding": "cache_dev2025h2a" - }, - { - "id": "00297460599846869decff3d015191ce", - "binding": "cache_dev2026h1a" - }, { "id": "f3a1a27d611b4c4bab366d7211133e3d", "binding": "cache_dev2026h2a" @@ -60,18 +48,6 @@ } ], "r2_buckets": [ - { - "bucket_name": "static-ct-public-dev2025h1a", - "binding": "public_dev2025h1a" - }, - { - "bucket_name": "static-ct-public-dev2025h2a", - "binding": "public_dev2025h2a" - }, - { - "bucket_name": "static-ct-public-dev2026h1a", - "binding": "public_dev2026h1a" - }, { "bucket_name": "static-ct-public-dev2026h2a", "binding": "public_dev2026h2a" @@ -129,198 +105,6 @@ "head_sampling_rate": 0.1 } } - }, - "cftest": { - "account_id": "022362f27de5264a50ce60cb23293c9f", - "build": { - // Change '--release' to '--dev' to compile with debug symbols. - // DEPLOY_ENV is used in build.rs to select per-environment config and roots. - // RUSTFLAGS: force legacy Wasm EH (see dev env comment). - "command": "cargo install -q worker-build@0.8.5 && DEPLOY_ENV=cftest RUSTFLAGS='-Cllvm-args=-wasm-use-legacy-eh' worker-build --release --panic-unwind" - }, - "route": { - "pattern": "static-ct.cloudflareresearch.com", - "custom_domain": true - }, - "version_metadata": { - "binding": "VERSION_METADATA" - }, - "kv_namespaces": [ - { - "id": "e4aab0c89ebe492c9c81c444c78a31ff", - "binding": "ccadb_roots" - }, - { - "id": "252e2e33dda544ce9660ca16d48afd50", - "binding": "cache_cftest2025h1a" - }, - { - "id": "b68bffb07b954aa09c6f4225ab00d0b7", - "binding": "cache_cftest2025h2a" - } - ], - "r2_buckets": [ - { - "bucket_name": "static-ct-public-cftest2025h1a", - "binding": "public_cftest2025h1a" - }, - { - "bucket_name": "static-ct-public-cftest2025h2a", - "binding": "public_cftest2025h2a" - } - ], - "durable_objects": { - "bindings": [ - { - "name": "SEQUENCER", - "class_name": "Sequencer" - }, - { - "name": "BATCHER", - "class_name": "Batcher" - }, - { - "name": "CLEANER", - "class_name": "Cleaner" - } - ] - }, - "migrations": [ - { - // tag should be unique for each entry - "tag": "v1", - "new_sqlite_classes": [ - "Sequencer", - "Batcher" - ] - }, - { - "tag": "v2", - "new_sqlite_classes": [ - "Cleaner" - ] - } - ], - "observability": { - "logs": { - "enabled": true, - "head_sampling_rate": 1, - "invocation_logs": true, - "persist": true - }, - "traces": { - "enabled": true, - "head_sampling_rate": 0.1 - } - }, - "logpush": true - }, - "raio": { - "account_id": "0b7358d77426ae6413d56c1cc0499b4f", - "build": { - // Change '--release' to '--dev' to compile with debug symbols. - // DEPLOY_ENV is used in build.rs to select per-environment config and roots. - // RUSTFLAGS: force legacy Wasm EH (see dev env comment). - "command": "cargo install -q worker-build@0.8.5 && DEPLOY_ENV=raio RUSTFLAGS='-Cllvm-args=-wasm-use-legacy-eh' worker-build --release --panic-unwind" - }, - "routes": [ - "https://ct.cloudflare.com/logs/raio2025h2b/*", - "https://ct.cloudflare.com/logs/raio2026h1a/*", - "https://ct.cloudflare.com/logs/raio2026h2a/*", - "https://ct.cloudflare.com/logs/raio2027h1a/*", - "https://ct.cloudflare.com/logs/raio2027h2a/*" - ], - "version_metadata": { - "binding": "VERSION_METADATA" - }, - "kv_namespaces": [ - { - "id": "12fa8928b30541d6b5c26ddf173c9acd", - "binding": "ccadb_roots" - }, - { - "id": "1eb0775c277d4adc98ac7526e3e7d305", - "binding": "cache_raio2025h2b" - }, - { - "id": "0a8eba39d36f4dd9bbb8fb9cb734b236", - "binding": "cache_raio2026h1a" - }, - { - "id": "6faa76b0b42844e3abf6ca2ba3197174", - "binding": "cache_raio2026h2a" - }, - { - "id": "e384a380de7841fd8491a5c381f90b6a", - "binding": "cache_raio2027h1a" - }, - { - "id": "52a50301b7c24d3a950354d8e658bb28", - "binding": "cache_raio2027h2a" - } - ], - "r2_buckets": [ - { - "bucket_name": "static-ct-public-raio2025h2b", - "binding": "public_raio2025h2b" - }, - { - "bucket_name": "static-ct-public-raio2026h1a", - "binding": "public_raio2026h1a" - }, - { - "bucket_name": "static-ct-public-raio2026h2a", - "binding": "public_raio2026h2a" - }, - { - "bucket_name": "static-ct-public-raio2027h1a", - "binding": "public_raio2027h1a" - }, - { - "bucket_name": "static-ct-public-raio2027h2a", - "binding": "public_raio2027h2a" - } - ], - "durable_objects": { - "bindings": [ - { - "name": "SEQUENCER", - "class_name": "Sequencer" - }, - { - "name": "BATCHER", - "class_name": "Batcher" - }, - { - "name": "CLEANER", - "class_name": "Cleaner" - } - ] - }, - "migrations": [ - { - // tag should be unique for each entry - "tag": "v1", - "new_sqlite_classes": [ - "Sequencer", - "Batcher", - "Cleaner" - ] - } - ], - "observability": { - "logs": { - "enabled": true, - "head_sampling_rate": 0.05, - "invocation_logs": true, - "persist": true - }, - "traces": { - "enabled": true, - "head_sampling_rate": 0.05 - } - }, - "logpush": true } } } diff --git a/crates/integration_tests/Cargo.toml b/crates/integration_tests/Cargo.toml index fca0797b..fae113c0 100644 --- a/crates/integration_tests/Cargo.toml +++ b/crates/integration_tests/Cargo.toml @@ -49,6 +49,7 @@ tokio = { version = "1", features = ["full"] } # Used only by the integration test binaries in tests/, not by the shared # support library in src/. flate2.workspace = true +jsonschema.workspace = true ml-dsa.workspace = true pkcs8.workspace = true tlog_cosignature.workspace = true diff --git a/crates/integration_tests/schemas/log_schema_v2.json b/crates/integration_tests/schemas/log_schema_v2.json new file mode 100644 index 00000000..adf9cbab --- /dev/null +++ b/crates/integration_tests/schemas/log_schema_v2.json @@ -0,0 +1,211 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "id": "https://googlechrome.github.io/CertificateTransparency/log_schema_v2.json", + "title": "CT Log Metadata", + "description": "Detailed metadata for a single Certificate Transparency log, as published by the log operator.", + "type": "object", + "properties": { + "$schema": { + "description": "A URI reference to the schema that this document conforms to.", + "type": "string", + "format": "uri" + }, + "log_id": { + "description": "The SHA-256 hash of the log's public key, base64 encoded.", + "type": "string", + "pattern": "^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$", + "minLength": 44, + "maxLength": 44 + }, + "key": { + "description": "The log's public key, base64 encoded (DER-encoded SubjectPublicKeyInfo).", + "type": "string", + "pattern": "^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$" + }, + "friendly_name": { + "description": "A brief, human-readable name for the log.", + "type": "string" + }, + "log_spec": { + "description": "The protocol implemented by this log. 'rfc6962' for RFC 6962 logs; 'static-ct-api' for logs implementing the C2SP Static CT API specification.", + "type": "string", + "enum": [ + "rfc6962", + "static-ct-api" + ] + }, + "mmd_seconds": { + "description": "The Maximum Merge Delay in seconds: the maximum time the log promises to incorporate a submitted certificate into the tree.", + "type": "integer", + "minimum": 0 + }, + "intended_use": { + "description": "The operator's declared intended use for this log. 'production' logs are eligible for inclusion in CT programs. 'test' logs are for operator or ecosystem testing and should not be included. 'decommissioned' logs have been permanently shut down by the operator (distinct from 'retired', which is a status that can only be designated by a UA).", + "type": "string", + "enum": [ + "production", + "test", + "decommissioned" + ] + }, + "tls_only": { + "description": "If true, this log only accepts certificates containing the ServerAuth EKU. If false (or absent), the log accepts certificates regardless of EKU.", + "type": "boolean" + }, + "temporal_interval": { + "description": "The window of certificate expiry dates this log accepts.", + "type": "object", + "properties": { + "start_inclusive": { + "type": "string", + "format": "date-time" + }, + "end_exclusive": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "start_inclusive", + "end_exclusive" + ] + }, + "status": { + "description": "The overall operational status of the log as understood by the operator. 'active' — the log is accepting submissions and serving data. 'readonly' — submissions are closed but monitoring/tile endpoints remain available (e.g. after a log's temporal window closes). 'inactive' — all endpoints are offline. This field reflects the operator's intended state, not real-time availability.", + "type": "string", + "enum": [ + "active", + "readonly", + "inactive" + ] + }, + "status_timestamp": { + "description": "The timestamp (ISO 8601) when the current top-level status was last set.", + "type": "string", + "format": "date-time" + }, + "planned_changes": { + "description": "An ordered list of planned future status transitions for this log (e.g. going read-only, then fully inactive). Consumers can use this to prepare for upcoming changes without out-of-band communication.", + "type": "array", + "items": { + "type": "object", + "properties": { + "new_status": { + "description": "The status the log will transition to.", + "type": "string", + "enum": [ + "active", + "readonly", + "inactive" + ] + }, + "effective_date": { + "description": "The date/time (ISO 8601) at which the status change is expected to take effect.", + "type": "string", + "format": "date-time" + }, + "comment": { + "description": "A human-readable explanation of this planned change.", + "type": "string" + } + }, + "required": [ + "new_status", + "effective_date" + ] + } + }, + "final_tree_head": { + "description": "For decommissioned logs: the cryptographically verifiable final state of the log tree, as published by the operator.", + "type": "object", + "properties": { + "sha256_root_hash": { + "type": "string", + "pattern": "^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$", + "minLength": 44, + "maxLength": 44 + }, + "tree_size": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "sha256_root_hash", + "tree_size" + ] + }, + "log_software": { + "description": "Optional information about the software powering this log.", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "name" + ] + } + }, + "required": [ + "log_id", + "key", + "friendly_name", + "log_spec", + "mmd_seconds", + "intended_use", + "temporal_interval", + "status", + "status_timestamp" + ], + "if": { + "properties": { + "log_spec": { + "const": "rfc6962" + } + } + }, + "then": { + "properties": { + "endpoint": { + "$ref": "#/definitions/EndpointInfo" + } + }, + "required": [ + "endpoint" + ] + }, + "else": { + "properties": { + "submission_endpoint": { + "$ref": "#/definitions/EndpointInfo" + }, + "monitoring_endpoint": { + "$ref": "#/definitions/EndpointInfo" + } + }, + "required": [ + "submission_endpoint", + "monitoring_endpoint" + ] + }, + "definitions": { + "EndpointInfo": { + "description": "URL for a single log endpoint. Operational status is expressed at the top-level log object, not per endpoint.", + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url" + ] + } + } +} diff --git a/crates/integration_tests/schemas/operator_list_schema_v1.json b/crates/integration_tests/schemas/operator_list_schema_v1.json new file mode 100644 index 00000000..b73e1a2b --- /dev/null +++ b/crates/integration_tests/schemas/operator_list_schema_v1.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "id": "https://googlechrome.github.io/CertificateTransparency/operator_list_schema_v1.json", + "title": "CT Log Operator List", + "description": "A lightweight manifest published by a CT Log Operator, listing the metadata URLs for each log (or log family) they operate. CT Programs consume this file to discover individual log metadata without requiring out-of-band communication.", + "type": "object", + "properties": { + "$schema": { + "description": "A URI reference to the schema that this document conforms to.", + "type": "string", + "format": "uri" + }, + "operator_name": { + "description": "The human-readable name of the CT Log Operator.", + "type": "string" + }, + "logs": { + "description": "An array of URLs, each pointing to the log-metadata.json file for a log (or log family) operated by this operator. This list only needs to change when a new log is added.", + "type": "array", + "items": { + "type": "string", + "format": "uri" + } + } + }, + "required": [ + "operator_name", + "logs" + ] +} diff --git a/crates/integration_tests/src/assertions.rs b/crates/integration_tests/src/assertions.rs index 3ea1b73b..08845e21 100644 --- a/crates/integration_tests/src/assertions.rs +++ b/crates/integration_tests/src/assertions.rs @@ -20,7 +20,7 @@ use tlog_entry::{LogEntry, TileIterator}; use tlog_tiles::{PathElem, PreloadedTlogTileReader, TileHashReader, TlogTile}; use x509_cert::{Certificate, der::Decode, der::Encode}; -use crate::client::{AddChainResponse, CtClient, LogV3JsonResponse}; +use crate::client::{AddChainResponse, CtClient, LogMetadataResponse}; // --------------------------------------------------------------------------- // SCT structure @@ -66,7 +66,7 @@ pub fn leaf_index_from_sct(sct: &AddChainResponse) -> Result { /// Verify that the ECDSA P-256 signature in the SCT is valid over the correct /// RFC 6962 signed data. /// -/// `log_meta` is the response from `GET /logs/:log/log.v3.json`. +/// `log_meta` is the response from `GET /logs/:log/metadata.json`. /// `leaf_der` is the DER-encoded leaf certificate (for a plain cert chain, /// this is the first element of the `chain` array posted to `add-chain`). /// `issuer_der` is the DER-encoded issuer certificate. @@ -76,7 +76,7 @@ pub fn leaf_index_from_sct(sct: &AddChainResponse) -> Result { /// Returns an error if signature verification fails or any encoding step fails. pub fn assert_sct_signature( sct: &AddChainResponse, - log_meta: &LogV3JsonResponse, + log_meta: &LogMetadataResponse, leaf_der: &[u8], issuer_der: &[u8], ) -> Result<()> { @@ -201,7 +201,7 @@ pub struct VerifiedCheckpoint { /// Returns an error if the checkpoint cannot be fetched or the signature is invalid. pub async fn fetch_and_verify_checkpoint( client: &CtClient, - log_meta: &LogV3JsonResponse, + log_meta: &LogMetadataResponse, witness_key_der: Option<&[u8]>, ) -> Result { let checkpoint_bytes = client @@ -233,7 +233,7 @@ pub async fn fetch_and_verify_checkpoint( pub fn verify_checkpoint_bytes( checkpoint_bytes: &[u8], log_name: &str, - log_meta: &LogV3JsonResponse, + log_meta: &LogMetadataResponse, witness_key_der: Option<&[u8]>, now_millis: u64, ) -> Result { @@ -242,7 +242,8 @@ pub fn verify_checkpoint_bytes( // The origin is derived from the submission URL (schema-less, no trailing slash). let origin = log_meta - .submission_url + .submission_endpoint + .url .trim_start_matches("http://") .trim_start_matches("https://") .trim_end_matches('/'); @@ -440,7 +441,7 @@ async fn fetch_tile_with_retry(client: &CtClient, path: &str) -> Result> /// Panics if no attempt was ever made (impossible given `MAX_RETRIES > 0`). pub async fn fetch_checkpoint_until_size( client: &CtClient, - log_meta: &LogV3JsonResponse, + log_meta: &LogMetadataResponse, min_size: u64, ) -> Result { const MAX_RETRIES: u32 = 12; diff --git a/crates/integration_tests/src/client.rs b/crates/integration_tests/src/client.rs index 6446f29d..d444aeab 100644 --- a/crates/integration_tests/src/client.rs +++ b/crates/integration_tests/src/client.rs @@ -23,10 +23,10 @@ pub fn base_url() -> String { std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:8787".to_string()) } -/// Log shard name to test against. Defaults to `dev2026h1a`. +/// Log shard name to test against. Defaults to `dev2026h2a`. #[must_use] pub fn log_name() -> String { - std::env::var("LOG_NAME").unwrap_or_else(|_| "dev2026h1a".to_string()) + std::env::var("LOG_NAME").unwrap_or_else(|_| "dev2026h2a".to_string()) } /// Full URL prefix for a given log: `{base_url}/logs/{log_name}`. @@ -47,23 +47,42 @@ pub struct GetRootsResponse { pub certificates: Vec>, } -/// Response body from `GET /logs/:log/log.v3.json`. +/// Response body from `GET /logs/:log/metadata.json`. #[serde_as] #[derive(Deserialize, Debug)] -pub struct LogV3JsonResponse { - pub description: Option, - pub log_type: Option, +pub struct LogMetadataResponse { + #[serde(rename = "$schema")] + pub schema: String, #[serde_as(as = "Base64")] pub log_id: Vec, #[serde_as(as = "Base64")] pub key: Vec, - pub mmd: u64, - pub submission_url: String, - pub monitoring_url: Option, + pub friendly_name: String, + pub log_spec: String, + pub mmd_seconds: u64, + pub intended_use: String, + pub tls_only: bool, pub temporal_interval: TemporalInterval, + pub status: String, + pub status_timestamp: String, + pub submission_endpoint: EndpointInfo, + pub monitoring_endpoint: EndpointInfo, } -/// Temporal interval within a `LogV3JsonResponse`. +#[derive(Deserialize, Debug)] +pub struct EndpointInfo { + pub url: String, +} + +#[derive(Deserialize, Debug)] +pub struct OperatorListResponse { + #[serde(rename = "$schema")] + pub schema: String, + pub operator_name: String, + pub logs: Vec, +} + +/// Temporal interval within a `LogMetadataResponse`. #[derive(Deserialize, Debug)] pub struct TemporalInterval { pub start_inclusive: String, @@ -111,7 +130,7 @@ impl CtClient { } } - /// Creates a client for the default log (from `LOG_NAME` env / `dev2026h1a`). + /// Creates a client for the default log (from `LOG_NAME` env / `dev2026h2a`). #[must_use] pub fn default_log() -> Self { Self::new(log_name()) @@ -136,19 +155,48 @@ impl CtClient { resp.json().await.context("parsing get-roots response") } - /// `GET /logs/:log/log.v3.json` - pub async fn get_log_v3_json(&self) -> Result { + /// `GET /logs/:log/metadata.json` + pub async fn get_metadata(&self) -> Result { + serde_json::from_value(self.get_metadata_json().await?) + .context("parsing metadata.json response") + } + + /// Returns the raw response from `GET /logs/:log/metadata.json`. + pub async fn get_metadata_json(&self) -> Result { + let resp = self + .client + .get(self.url("metadata.json")) + .send() + .await + .context("GET metadata.json")?; + let status = resp.status(); + if !status.is_success() { + bail!("GET metadata.json returned {status}"); + } + resp.json().await.context("parsing metadata.json response") + } + + /// `GET /operator-list.json` + pub async fn get_operator_list(&self) -> Result { + serde_json::from_value(self.get_operator_list_json().await?) + .context("parsing operator-list.json response") + } + + /// Returns the raw response from `GET /operator-list.json`. + pub async fn get_operator_list_json(&self) -> Result { let resp = self .client - .get(self.url("log.v3.json")) + .get(format!("{}/operator-list.json", base_url())) .send() .await - .context("GET log.v3.json")?; + .context("GET operator-list.json")?; let status = resp.status(); if !status.is_success() { - bail!("GET log.v3.json returned {status}"); + bail!("GET operator-list.json returned {status}"); } - resp.json().await.context("parsing log.v3.json response") + resp.json() + .await + .context("parsing operator-list.json response") } /// `POST /logs/:log/ct/v1/add-chain` @@ -202,8 +250,8 @@ impl CtClient { .with_context(|| format!("R2 object missing: {path}")); } - let metadata = self.get_log_v3_json().await?; - get_raw_http(&self.client, metadata.monitoring_url.as_deref(), path).await + let metadata = self.get_metadata().await?; + get_raw_http(&self.client, &metadata.monitoring_endpoint.url, path).await } /// `GET /logs/:log/{path}` — returns the HTTP status code (does not fail on 4xx/5xx). @@ -220,10 +268,9 @@ impl CtClient { async fn get_raw_http( client: &reqwest::Client, - monitoring_url: Option<&str>, + monitoring_url: &str, path: &str, ) -> Result> { - let monitoring_url = monitoring_url.context("log does not advertise a monitoring URL")?; let url = format!("{}/{path}", monitoring_url.trim_end_matches('/')); let resp = client .get(&url) diff --git a/crates/integration_tests/src/lib.rs b/crates/integration_tests/src/lib.rs index 999869eb..0ec77f4e 100644 --- a/crates/integration_tests/src/lib.rs +++ b/crates/integration_tests/src/lib.rs @@ -6,7 +6,7 @@ //! These tests run against a live `wrangler dev` instance. //! Set `BASE_URL` to point at the server; defaults to `http://localhost:8787`. //! -//! CT tests: set `LOG_NAME` to choose the log shard (default: `dev2026h1a`). +//! CT tests: set `LOG_NAME` to choose the log shard (default: `dev2026h2a`). pub mod assertions; pub mod client; diff --git a/crates/integration_tests/tests/static_ct_api.rs b/crates/integration_tests/tests/static_ct_api.rs index 050fdadd..ed3d356b 100644 --- a/crates/integration_tests/tests/static_ct_api.rs +++ b/crates/integration_tests/tests/static_ct_api.rs @@ -5,7 +5,7 @@ //! //! These tests require a running `wrangler dev` instance. //! Set `BASE_URL` to point at the server; defaults to `http://localhost:8787`. -//! Set `LOG_NAME` to choose which log shard; defaults to `dev2026h1a`. +//! Set `LOG_NAME` to choose which log shard; defaults to `dev2026h2a`. //! //! # Running //! @@ -21,7 +21,7 @@ //! //! To run against a different shard or URL: //! ```text -//! BASE_URL=http://localhost:8787 LOG_NAME=dev2026h1a cargo test -p integration_tests --test static_ct_api +//! BASE_URL=http://localhost:8787 LOG_NAME=dev2026h2a cargo test -p integration_tests --test static_ct_api //! ``` use std::time::Duration; @@ -31,12 +31,19 @@ use integration_tests::{ assert_leaf_in_checkpoint, assert_sct_signature, assert_sct_structure, fetch_and_verify_checkpoint, fetch_checkpoint_until_size, leaf_index_from_sct, }, - client::{CtClient, base_url}, + client::{CtClient, LogMetadataResponse, OperatorListResponse}, fixtures::{empty_chain, garbage_chain, make_chains, make_expired_chain}, - local_r2, }; use tokio::sync::OnceCell; +const LOG_METADATA_SCHEMA: &str = include_str!("../schemas/log_schema_v2.json"); +const OPERATOR_LIST_SCHEMA: &str = include_str!("../schemas/operator_list_schema_v1.json"); + +fn assert_matches_schema(instance: &serde_json::Value, schema: &str) { + let schema = serde_json::from_str(schema).expect("valid vendored JSON schema"); + jsonschema::validate(&schema, instance).expect("response matches JSON schema"); +} + // --------------------------------------------------------------------------- // Initialization guard // --------------------------------------------------------------------------- @@ -52,7 +59,7 @@ use tokio::sync::OnceCell; /// specific test ordering. /// /// Tests that only need stateless metadata endpoints (`get-roots`, -/// `log.v3.json`, `unknown_log`) should NOT call this — they work immediately +/// `metadata.json`, `unknown_log`) should NOT call this — they work immediately /// and calling it would slow them down unnecessarily. static INITIALIZED: OnceCell<()> = OnceCell::const_new(); @@ -68,7 +75,7 @@ async fn ensure_initialized() { // Fetch log metadata (needed for checkpoint verification). // Retry until the frontend is reachable. let meta = loop { - match client.get_log_v3_json().await { + match client.get_metadata().await { Ok(m) => break m, Err(_) => tokio::time::sleep(RETRY_DELAY).await, } @@ -136,25 +143,38 @@ async fn get_roots_returns_valid_certs() { } } -/// `GET /logs/:log/log.v3.json` returns 200 with all required fields. +/// `GET /logs/:log/metadata.json` returns 200 with all required fields. #[tokio::test] -async fn log_v3_json_returns_valid_metadata() { +async fn metadata_json_returns_valid_metadata() { use p256::pkcs8::{DecodePublicKey, EncodePublicKey}; use sha2::{Digest, Sha256}; let client = CtClient::default_log(); - let meta = client.get_log_v3_json().await.expect("log.v3.json failed"); + let metadata = client + .get_metadata_json() + .await + .expect("metadata.json failed"); + assert_matches_schema(&metadata, LOG_METADATA_SCHEMA); + let meta: LogMetadataResponse = + serde_json::from_value(metadata).expect("valid metadata response"); + assert_eq!( + meta.schema, + "https://googlechrome.github.io/CertificateTransparency/log_schema_v2.json" + ); assert_eq!(meta.log_id.len(), 32, "log_id must be 32 bytes"); assert!(!meta.key.is_empty(), "key must be non-empty"); - assert!(meta.mmd > 0, "mmd must be positive"); - assert!( - !meta.submission_url.is_empty(), - "submission_url must be set" - ); - if local_r2::is_loopback_base_url(&base_url()) { - assert!(meta.monitoring_url.is_none()); - } + assert!(!meta.friendly_name.is_empty()); + assert_eq!(meta.log_spec, "static-ct-api"); + assert!(meta.mmd_seconds > 0, "mmd_seconds must be positive"); + assert_eq!(meta.intended_use, "test"); + assert!(meta.tls_only); + assert!(["active", "readonly", "inactive"].contains(&meta.status.as_str())); + assert!(meta.status_timestamp.ends_with('Z')); + assert!(!meta.submission_endpoint.url.is_empty()); + assert!(!meta.monitoring_endpoint.url.is_empty()); + assert!(!meta.temporal_interval.start_inclusive.is_empty()); + assert!(!meta.temporal_interval.end_exclusive.is_empty()); // Key must be a valid P-256 SPKI. p256::ecdsa::VerifyingKey::from_public_key_der(&meta.key) @@ -171,6 +191,30 @@ async fn log_v3_json_returns_valid_metadata() { ); } +#[tokio::test] +async fn operator_list_returns_metadata_urls() { + let operator_list = CtClient::default_log() + .get_operator_list_json() + .await + .expect("operator-list.json failed"); + assert_matches_schema(&operator_list, OPERATOR_LIST_SCHEMA); + let operator_list: OperatorListResponse = + serde_json::from_value(operator_list).expect("valid operator list response"); + + assert_eq!( + operator_list.schema, + "https://googlechrome.github.io/CertificateTransparency/operator_list_schema_v1.json" + ); + assert_eq!(operator_list.operator_name, "Cloudflare"); + assert_eq!( + operator_list.logs, + [ + "http://localhost:8787/logs/dev2027h1a/metadata.json", + "http://localhost:8787/logs/dev2027h2a/metadata.json", + ] + ); +} + /// Requesting an unknown log name returns 400. #[tokio::test] async fn unknown_log_returns_400() { @@ -182,6 +226,16 @@ async fn unknown_log_returns_400() { assert_eq!(status, 400, "expected 400 for unknown log"); } +#[tokio::test] +async fn readonly_log_rejects_submissions() { + let client = CtClient::new("readonlytest"); + let (status, _) = client + .add_chain(empty_chain()) + .await + .expect("add-chain request"); + assert_eq!(status, 503); +} + /// Persisted log objects are not exposed by the submission Worker. #[tokio::test] async fn checkpoint_is_not_served_by_frontend() { @@ -230,7 +284,7 @@ async fn add_chain_returns_sct_with_valid_signature() { ensure_initialized().await; let client = CtClient::default_log(); let chains = make_chains(&client.log).expect("generating chain fixtures"); - let meta = client.get_log_v3_json().await.expect("log.v3.json"); + let meta = client.get_metadata().await.expect("metadata.json"); let (status, sct) = client .add_chain(chains.chain.clone()) @@ -250,7 +304,7 @@ async fn add_pre_chain_returns_valid_sct() { ensure_initialized().await; let client = CtClient::default_log(); let chains = make_chains(&client.log).expect("generating chain fixtures"); - let meta = client.get_log_v3_json().await.expect("log.v3.json"); + let meta = client.get_metadata().await.expect("metadata.json"); let (status, sct) = client .add_pre_chain(chains.pre_chain.clone()) @@ -299,7 +353,7 @@ async fn add_chain_sct_appears_in_checkpoint() { ensure_initialized().await; let client = CtClient::default_log(); let chains = make_chains(&client.log).expect("generating chain fixtures"); - let meta = client.get_log_v3_json().await.expect("log.v3.json"); + let meta = client.get_metadata().await.expect("metadata.json"); let (status, sct) = client .add_chain(chains.chain.clone()) @@ -330,7 +384,7 @@ async fn add_chain_leaf_verifiable_in_tree() { ensure_initialized().await; let client = CtClient::default_log(); let chains = make_chains(&client.log).expect("generating chain fixtures"); - let meta = client.get_log_v3_json().await.expect("log.v3.json"); + let meta = client.get_metadata().await.expect("metadata.json"); let (status, sct) = client .add_chain(chains.chain.clone()) @@ -357,7 +411,7 @@ async fn add_pre_chain_leaf_verifiable_in_tree() { ensure_initialized().await; let client = CtClient::default_log(); let chains = make_chains(&client.log).expect("generating chain fixtures"); - let meta = client.get_log_v3_json().await.expect("log.v3.json"); + let meta = client.get_metadata().await.expect("metadata.json"); let (status, sct) = client .add_pre_chain(chains.pre_chain.clone()) @@ -385,7 +439,7 @@ async fn add_pre_chain_leaf_verifiable_in_tree() { async fn checkpoint_signature_is_valid() { ensure_initialized().await; let client = CtClient::default_log(); - let meta = client.get_log_v3_json().await.expect("log.v3.json"); + let meta = client.get_metadata().await.expect("metadata.json"); fetch_and_verify_checkpoint(&client, &meta, None) .await diff --git a/crates/static_ct_api/Cargo.toml b/crates/static_ct_api/Cargo.toml index 8e5be7fc..7de77703 100644 --- a/crates/static_ct_api/Cargo.toml +++ b/crates/static_ct_api/Cargo.toml @@ -21,9 +21,6 @@ wasm-opt = false [lib] crate-type = ["rlib"] -[dev-dependencies] -ed25519-dalek.workspace = true - [dependencies] base64.workspace = true byteorder.workspace = true diff --git a/crates/static_ct_api/src/static_ct.rs b/crates/static_ct_api/src/static_ct.rs index 4ffad6c6..1c38fb81 100644 --- a/crates/static_ct_api/src/static_ct.rs +++ b/crates/static_ct_api/src/static_ct.rs @@ -18,57 +18,6 @@ //! //! # Examples //! -//! ## Opening and verifying a checkpoint -//! ``` -//! use base64::prelude::*; -//! use p256::{pkcs8::DecodePublicKey, ecdsa::VerifyingKey as EcdsaVerifyingKey}; -//! use ed25519_dalek::VerifyingKey as Ed25519VerifyingKey; -//! use signed_note::{Ed25519NoteVerifier, VerifierList, KeyName}; -//! use static_ct_api::RFC6962NoteVerifier; -//! -//! let origin = KeyName::new("static-ct-dev.cloudflareresearch.com/logs/dev2024h2b".into()).unwrap(); -//! let checkpoint: &str = "static-ct-dev.cloudflareresearch.com/logs/dev2024h2b -//! 5 -//! YsndMEZccH1fI4kviHLu/Z1Ye3MgKkDwUHluUAOYuoY= -//! -//! — grease.invalid DLzQSDHFSzQAoz8nHm/h+UEP9JGkNhwVb9IP1sW3lvI+zQ== -//! — static-ct-dev.cloudflareresearch.com/logs/dev2024h2b sFXEux8xfyu4r8oNjISiP7KHW+We4qeOjAtSpKFgGUiD9agTzD81XyNWGMw= -//! — static-ct-dev.cloudflareresearch.com/logs/dev2024h2b 30nmRgAAAZSU01GqBAMASDBGAiEAps+yrlD9GB9pxdNomlfgABvNTI+NGlMFEsiJTynTkqwCIQDcxRtu9jY1gjLV1S+W55rCrr2yvl1PqSPY2UWh3dZ+eQ== -//! — static-ct-dev.cloudflareresearch.com/logs/dev2024h2b P6OcbFTzjZ8KFH9Oi3qOwgVdtJI5XiPcCbtLDeB/GrpzhtvSIZKAq8QgmAL5YwW6wFgpcp4PYuAhbQQ87R1S2nVAqAM= -//! "; -//! -//! // Log verification key from `curl /metadata | jq -r ".key"` -//! let rfc6962_verifier = { -//! let vkey_bytes = &BASE64_STANDARD.decode( -//! "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAES4yrL7jarwxEdSWrJp35uef789UYLma/F0x7bfBpW2KWnN5yuDE5XgeOAKeWM3RpycCZF2xRGAp2iHFCa4PtqA==" -//! ).unwrap(); -//! let ecdsa_vkey = EcdsaVerifyingKey::from_public_key_der(vkey_bytes).unwrap(); -//! RFC6962NoteVerifier::new(origin.clone(), &ecdsa_vkey).unwrap() -//! }; -//! -//! // Witness verification key from `curl /metadata | jq -r ".witness_key"`. -//! let witness_verifier = { -//! let vkey_bytes = &BASE64_STANDARD.decode( -//! "MCowBQYDK2VwAyEARN4KXLGKQrfUUGU1zwbFvEN1AckVY76d4CnuNRc20vI=" -//! ).unwrap(); -//! let ed25519_vkey = Ed25519VerifyingKey::from_public_key_der(vkey_bytes).unwrap(); -//! let ed25519_verifier = signed_note::new_encoded_ed25519_verifier_key(&origin, &ed25519_vkey); -//! Ed25519NoteVerifier::new_from_encoded_key(&ed25519_verifier).unwrap() -//! }; -//! -//! // Timestamp to use for verification, which must be at least as recent as the timestamp of the checkpoint. -//! let now: u64 = 1_737_664_860_920; -//! -//! // Make a list of the verifiers that MUST apear on the checkpoint, and load the checkpoint -//! let verifiers = VerifierList::new(vec![Box::new(rfc6962_verifier), Box::new(witness_verifier)]); -//! let (_checkpoint, _timestamp) = tlog_checkpoint::open_checkpoint( -//! "static-ct-dev.cloudflareresearch.com/logs/dev2024h2b", -//! &verifiers, -//! now, -//! checkpoint.as_bytes(), -//! ).unwrap(); -//! ``` -//! //! ## Verifying only the log signature on a checkpoint //! //! ```