diff --git a/scripts/soak/run_cache_ablation_experiment.sh b/scripts/soak/run_cache_ablation_experiment.sh index 8f948f8..7d332cc 100755 --- a/scripts/soak/run_cache_ablation_experiment.sh +++ b/scripts/soak/run_cache_ablation_experiment.sh @@ -38,7 +38,7 @@ Environment: EXPERIMENT_RUN_ROOT shared run root/state root; default PACKAGE_ROOT EXPERIMENT_DIR experiment metadata output directory CASE_RUNS delta runs per case; default 10 - EXPERIMENT_CASE_SET default or cache-only; default runs the original 4-case matrix + EXPERIMENT_CASE_SET default, cache-only, crypto-sig or sig-pp-compare; default runs the original 4-case matrix RUN_START_INTERVAL_SECS fixed start cadence for all runs; default 600 FIRST_RUN_DELAY_SECS delay before the first scheduled run; default 0 SNAPSHOT_EXTRA_ARGS extra rpki args for snapshot warmup @@ -95,7 +95,10 @@ case_count() { case "$EXPERIMENT_CASE_SET" in default) printf '%s' 4 ;; cache-only) printf '%s' 3 ;; - *) die "EXPERIMENT_CASE_SET must be default or cache-only: $EXPERIMENT_CASE_SET" ;; + crypto-sig) printf '%s' 4 ;; + sig-pp-compare) printf '%s' 2 ;; + baseline-combo) printf '%s' 2 ;; + *) die "EXPERIMENT_CASE_SET must be default, cache-only, crypto-sig or sig-pp-compare: $EXPERIMENT_CASE_SET" ;; esac } @@ -108,6 +111,14 @@ case_id_for_index() { cache-only:1) printf '%s' "pp-only" ;; cache-only:2) printf '%s' "object-only" ;; cache-only:3) printf '%s' "pp-object-only" ;; + crypto-sig:1) printf '%s' "baseline" ;; + crypto-sig:2) printf '%s' "sig-only" ;; + crypto-sig:3) printf '%s' "pp-object" ;; + crypto-sig:4) printf '%s' "pp-object-sig" ;; + sig-pp-compare:1) printf '%s' "sig-only" ;; + sig-pp-compare:2) printf '%s' "pp-only" ;; + baseline-combo:1) printf '%s' "baseline" ;; + baseline-combo:2) printf '%s' "pp-object-sig" ;; *) die "unknown case index: $1 for set $EXPERIMENT_CASE_SET" ;; esac } @@ -121,6 +132,14 @@ case_name_for_index() { cache-only:1) printf '%s' "pp-cache-only" ;; cache-only:2) printf '%s' "object-cache-only" ;; cache-only:3) printf '%s' "pp-cache-object-cache-only" ;; + crypto-sig:1) printf '%s' "all-cache-off" ;; + crypto-sig:2) printf '%s' "crypto-sig-cache-only" ;; + crypto-sig:3) printf '%s' "pp-cache-object-cache" ;; + crypto-sig:4) printf '%s' "pp-cache-object-crypto-sig-cache" ;; + sig-pp-compare:1) printf '%s' "crypto-sig-cache-only" ;; + sig-pp-compare:2) printf '%s' "pp-cache-only" ;; + baseline-combo:1) printf '%s' "all-cache-off" ;; + baseline-combo:2) printf '%s' "pp-cache-object-crypto-sig-cache" ;; *) die "unknown case index: $1 for set $EXPERIMENT_CASE_SET" ;; esac } @@ -134,14 +153,22 @@ case_extra_args_for_index() { cache-only:1) printf '%s' "--enable-publication-point-validation-cache" ;; cache-only:2) printf '%s' "--enable-roa-validation-cache" ;; cache-only:3) printf '%s' "--enable-publication-point-validation-cache --enable-roa-validation-cache" ;; + crypto-sig:1) printf '%s' "" ;; + crypto-sig:2) printf '%s' "--enable-crypto-signature-cache" ;; + crypto-sig:3) printf '%s' "--enable-publication-point-validation-cache --enable-roa-validation-cache" ;; + crypto-sig:4) printf '%s' "--enable-publication-point-validation-cache --enable-roa-validation-cache --enable-crypto-signature-cache" ;; + sig-pp-compare:1) printf '%s' "--enable-crypto-signature-cache" ;; + sig-pp-compare:2) printf '%s' "--enable-publication-point-validation-cache" ;; + baseline-combo:1) printf '%s' "" ;; + baseline-combo:2) printf '%s' "--enable-publication-point-validation-cache --enable-roa-validation-cache --enable-crypto-signature-cache" ;; *) die "unknown case index: $1 for set $EXPERIMENT_CASE_SET" ;; esac } case_child_cert_cache_for_index() { case "$EXPERIMENT_CASE_SET:$1" in - default:4|cache-only:2|cache-only:3) printf '%s' "1" ;; - default:1|default:2|default:3|cache-only:1) printf '%s' "0" ;; + default:4|cache-only:2|cache-only:3|crypto-sig:3|crypto-sig:4|baseline-combo:2) printf '%s' "1" ;; + default:1|default:2|default:3|cache-only:1|crypto-sig:1|crypto-sig:2|sig-pp-compare:1|sig-pp-compare:2|baseline-combo:1) printf '%s' "0" ;; *) die "unknown case index: $1 for set $EXPERIMENT_CASE_SET" ;; esac } @@ -163,6 +190,23 @@ elif case_set == "cache-only": {"caseId": "object-only", "caseName": "object-cache-only", "extraArgs": "--enable-roa-validation-cache", "enableChildCertificateValidationCache": True}, {"caseId": "pp-object-only", "caseName": "pp-cache-object-cache-only", "extraArgs": "--enable-publication-point-validation-cache --enable-roa-validation-cache", "enableChildCertificateValidationCache": True}, ] +elif case_set == "crypto-sig": + cases = [ + {"caseId": "baseline", "caseName": "all-cache-off", "extraArgs": "", "enableChildCertificateValidationCache": False}, + {"caseId": "sig-only", "caseName": "crypto-sig-cache-only", "extraArgs": "--enable-crypto-signature-cache", "enableChildCertificateValidationCache": False}, + {"caseId": "pp-object", "caseName": "pp-cache-object-cache", "extraArgs": "--enable-publication-point-validation-cache --enable-roa-validation-cache", "enableChildCertificateValidationCache": True}, + {"caseId": "pp-object-sig", "caseName": "pp-cache-object-crypto-sig-cache", "extraArgs": "--enable-publication-point-validation-cache --enable-roa-validation-cache --enable-crypto-signature-cache", "enableChildCertificateValidationCache": True}, + ] +elif case_set == "sig-pp-compare": + cases = [ + {"caseId": "sig-only", "caseName": "crypto-sig-cache-only", "extraArgs": "--enable-crypto-signature-cache", "enableChildCertificateValidationCache": False}, + {"caseId": "pp-only", "caseName": "pp-cache-only", "extraArgs": "--enable-publication-point-validation-cache", "enableChildCertificateValidationCache": False}, + ] +elif case_set == "baseline-combo": + cases = [ + {"caseId": "baseline", "caseName": "all-cache-off", "extraArgs": "", "enableChildCertificateValidationCache": False}, + {"caseId": "pp-object-sig", "caseName": "pp-cache-object-crypto-sig-cache", "extraArgs": "--enable-publication-point-validation-cache --enable-roa-validation-cache --enable-crypto-signature-cache", "enableChildCertificateValidationCache": True}, + ] else: raise SystemExit(f"unknown case set: {case_set}") print(json.dumps(cases, ensure_ascii=False, indent=8)) @@ -345,6 +389,7 @@ else: "publication_point_cache_index_load", "publication_point_cache_index_refresh", "roa_validation_cache", + "crypto_signature_cache_observe", ]: if key in stage: record[key] = stage.get(key) @@ -360,6 +405,11 @@ else: "child_certificate_cache_miss_not_found", "fresh_publication_points", "fresh_manifest_files_total", + "crypto_signature_cache_observe_calls", + "crypto_signature_cache_observe_would_hit", + "crypto_signature_cache_observe_new_keys", + "crypto_signature_cache_verify_executed", + "crypto_signature_cache_verify_skipped", ] record["cacheCounts"] = {k: analysis_counts.get(k) for k in interesting if k in analysis_counts} record["repoSyncStats"] = summary.get("repoSyncStats") diff --git a/scripts/verification/README.md b/scripts/verification/README.md index af0347a..958fc80 100644 --- a/scripts/verification/README.md +++ b/scripts/verification/README.md @@ -29,7 +29,7 @@ - `vrps.csv`、`vaps.csv` - `stage-timing.json`、`validation-events.jsonl` - `validation-contract.json`、`verification-meta.json` -- `compare/ccr-state-compare.json`、`compare/ccr-state-compare.md` +- `compare/ccr-state-digest.json`、`compare/ccr-state-digest.md` - `stdout.log`、`stderr.log`、`process-time.txt`、`wrapper-timing.json` 通过条件是 compare JSON 中 `stateDigestMatch=true`。`producedAt` 不参与比较。 diff --git a/src/cli.rs b/src/cli.rs index b41341d..ddd6620 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -55,6 +55,7 @@ struct RunStageTiming { enable_child_certificate_validation_cache: bool, publication_point_cache_observe_only: bool, enable_publication_point_validation_cache: bool, + crypto_signature_cache_observe: Option, enable_transport_request_prefetch: bool, report_build_ms: u64, report_write_ms: Option, @@ -145,6 +146,8 @@ pub struct CliArgs { pub enable_child_certificate_validation_cache: bool, pub publication_point_cache_observe_only: bool, pub enable_publication_point_validation_cache: bool, + pub crypto_signature_cache_observe_only: bool, + pub enable_crypto_signature_cache: bool, pub enable_transport_request_prefetch: bool, pub ccr_out_path: Option, pub vrps_csv_out_path: Option, @@ -222,6 +225,12 @@ Options: Evaluate publication-point cache eligibility without changing results --enable-publication-point-validation-cache Experimental: reuse complete publication-point validation projections + --crypto-signature-cache-observe-only + Measure crypto signature cache key hit rates and verify durations + without changing validation behavior (default: off) + --enable-crypto-signature-cache + Experimental: skip cryptographic signature verification on cache + hits (positive conclusions only; default: off) --enable-transport-request-prefetch Experimental: prefetch previous run transport repo requests before tree traversal --ccr-out Write CCR DER ContentInfo to this path (optional) @@ -315,6 +324,8 @@ pub fn parse_args(argv: &[String]) -> Result { let mut enable_roa_validation_cache: bool = false; let mut enable_child_certificate_validation_cache: bool = false; let mut publication_point_cache_observe_only: bool = false; + let mut crypto_signature_cache_observe_only: bool = false; + let mut enable_crypto_signature_cache: bool = false; let mut enable_publication_point_validation_cache: bool = false; let mut enable_transport_request_prefetch: bool = false; let mut ccr_out_path: Option = None; @@ -579,6 +590,12 @@ pub fn parse_args(argv: &[String]) -> Result { "--enable-publication-point-validation-cache" => { enable_publication_point_validation_cache = true; } + "--crypto-signature-cache-observe-only" => { + crypto_signature_cache_observe_only = true; + } + "--enable-crypto-signature-cache" => { + enable_crypto_signature_cache = true; + } "--enable-transport-request-prefetch" => { enable_transport_request_prefetch = true; } @@ -808,6 +825,8 @@ pub fn parse_args(argv: &[String]) -> Result { || enable_child_certificate_validation_cache || publication_point_cache_observe_only || enable_publication_point_validation_cache + || crypto_signature_cache_observe_only + || enable_crypto_signature_cache || enable_transport_request_prefetch || rsync_local_dir.is_some() || rsync_command.is_some() @@ -1121,6 +1140,8 @@ pub fn parse_args(argv: &[String]) -> Result { enable_child_certificate_validation_cache, publication_point_cache_observe_only, enable_publication_point_validation_cache, + crypto_signature_cache_observe_only, + enable_crypto_signature_cache, enable_transport_request_prefetch, ccr_out_path, vrps_csv_out_path, @@ -2176,6 +2197,8 @@ pub fn run(argv: &[String]) -> Result<(), String> { args.enable_child_certificate_validation_cache = false; args.publication_point_cache_observe_only = false; args.enable_publication_point_validation_cache = false; + args.crypto_signature_cache_observe_only = false; + args.enable_crypto_signature_cache = false; args.enable_transport_request_prefetch = false; args.ccr_out_path = Some(prepared.artifacts.result_ccr.clone()); args.vrps_csv_out_path = Some(prepared.artifacts.vrps_csv.clone()); @@ -2231,6 +2254,7 @@ pub fn run(argv: &[String]) -> Result<(), String> { roa: args.enable_roa_validation_cache, child_certificate: args.enable_child_certificate_validation_cache, transport_prefetch: args.enable_transport_request_prefetch, + crypto_signature: args.enable_crypto_signature_cache, }, )?; crate::verification_only::write_validation_contract(path, &contract)?; @@ -2351,6 +2375,19 @@ pub fn run(argv: &[String]) -> Result<(), String> { store.as_ref(), ); let validation_started = std::time::Instant::now(); + let crypto_sig_cache = if args.crypto_signature_cache_observe_only + || args.enable_crypto_signature_cache + { + let cache_file = crate::crypto_sig_cache::default_cache_file_path(&args.db_path); + let cache = Arc::new(crate::crypto_sig_cache::CryptoSigCache::load_or_rebuild( + cache_file, + args.enable_crypto_signature_cache, + )); + crate::crypto_sig_cache::install_global(Arc::clone(&cache)); + Some(cache) + } else { + None + }; let collect_current_repo_objects = false; let out = if args.verification_only { let http = crate::verification_only::NetworkDisabledHttpFetcher; @@ -2778,6 +2815,36 @@ pub fn run(argv: &[String]) -> Result<(), String> { None }; let publication_point_cache_index_load = store.publication_point_cache_mmap_index_load_stats(); + let crypto_signature_cache_observe = crypto_sig_cache.as_ref().map(|cache| { + if let Err(e) = cache.persist() { + crate::progress_log::emit( + "crypto_signature_cache_persist", + serde_json::json!({ + "state": "failed", + "error": e, + }), + ); + } + crate::crypto_sig_cache::clear_global(); + cache.summary() + }); + if let (Some((_, t)), Some(summary)) = (timing.as_ref(), crypto_signature_cache_observe.as_ref()) + { + let (mut calls, mut would_hit, mut new_keys, mut executed, mut skipped) = + (0u64, 0u64, 0u64, 0u64, 0u64); + for stats in summary.per_point.values() { + calls += stats.calls; + would_hit += stats.would_hit; + new_keys += stats.new_keys; + executed += stats.verify_executed; + skipped += stats.verify_skipped; + } + t.record_count("crypto_signature_cache_observe_calls", calls); + t.record_count("crypto_signature_cache_observe_would_hit", would_hit); + t.record_count("crypto_signature_cache_observe_new_keys", new_keys); + t.record_count("crypto_signature_cache_verify_executed", executed); + t.record_count("crypto_signature_cache_verify_skipped", skipped); + } let timing_report_snapshot = timing .as_ref() .map(|(_, handle)| handle.report_snapshot(50)); @@ -2787,6 +2854,7 @@ pub fn run(argv: &[String]) -> Result<(), String> { enable_child_certificate_validation_cache: args.enable_child_certificate_validation_cache, publication_point_cache_observe_only: args.publication_point_cache_observe_only, enable_publication_point_validation_cache: args.enable_publication_point_validation_cache, + crypto_signature_cache_observe, enable_transport_request_prefetch: args.enable_transport_request_prefetch, report_build_ms, report_write_ms, diff --git a/src/cli/tests.rs b/src/cli/tests.rs index 831404a..ec3d85e 100644 --- a/src/cli/tests.rs +++ b/src/cli/tests.rs @@ -289,6 +289,56 @@ fn parse_accepts_publication_point_cache_flags() { assert!(args.enable_publication_point_validation_cache); } +#[test] +fn parse_disables_crypto_signature_cache_observe_only_by_default() { + let argv = vec![ + "rpki".to_string(), + "--db".to_string(), + "db".to_string(), + "--tal-path".to_string(), + "x.tal".to_string(), + "--ta-path".to_string(), + "x.cer".to_string(), + ]; + let args = parse_args(&argv).expect("parse args"); + assert!(!args.crypto_signature_cache_observe_only); + assert!(!args.enable_crypto_signature_cache); +} + +#[test] +fn parse_accepts_crypto_signature_cache_observe_only() { + let argv = vec![ + "rpki".to_string(), + "--db".to_string(), + "db".to_string(), + "--tal-path".to_string(), + "x.tal".to_string(), + "--ta-path".to_string(), + "x.cer".to_string(), + "--crypto-signature-cache-observe-only".to_string(), + ]; + let args = parse_args(&argv).expect("parse args"); + assert!(args.crypto_signature_cache_observe_only); + assert!(!args.enable_crypto_signature_cache); +} + +#[test] +fn parse_accepts_enable_crypto_signature_cache() { + let argv = vec![ + "rpki".to_string(), + "--db".to_string(), + "db".to_string(), + "--tal-path".to_string(), + "x.tal".to_string(), + "--ta-path".to_string(), + "x.cer".to_string(), + "--enable-crypto-signature-cache".to_string(), + ]; + let args = parse_args(&argv).expect("parse args"); + assert!(args.enable_crypto_signature_cache); + assert!(!args.crypto_signature_cache_observe_only); +} + #[test] fn parse_accepts_enable_transport_request_prefetch() { let argv = vec![ @@ -1807,6 +1857,7 @@ fn run_report_task_and_stage_timing_work() { enable_child_certificate_validation_cache: false, publication_point_cache_observe_only: false, enable_publication_point_validation_cache: false, + crypto_signature_cache_observe: None, enable_transport_request_prefetch: false, report_build_ms: report_output.report_build_ms, report_write_ms: report_output.report_write_ms, @@ -1913,6 +1964,7 @@ fn stage_timing_serializes_memory_telemetry() { enable_child_certificate_validation_cache: true, publication_point_cache_observe_only: false, enable_publication_point_validation_cache: false, + crypto_signature_cache_observe: None, enable_transport_request_prefetch: true, report_build_ms: 2, report_write_ms: None, diff --git a/src/crypto_sig_cache.rs b/src/crypto_sig_cache.rs new file mode 100644 index 0000000..fd9e429 --- /dev/null +++ b/src/crypto_sig_cache.rs @@ -0,0 +1,876 @@ +//! Crypto signature verification cache (feature #126). +//! +//! Caches the context-free cryptographic fact "this signature over these exact bytes verifies +//! under this public key" so that later runs can skip the RSA verification entirely. Only +//! positive conclusions are stored (no negative caching). The cache never replaces any other +//! validation semantics: revocation (CRL), time-window, and policy checks all run unchanged on +//! the hit path — the hook wraps exactly the crypto verify call and nothing else. +//! +//! Modes (CLI): +//! +//! - `--crypto-signature-cache-observe-only` (M1): compute keys, maintain the persistent set, +//! record hit/miss/timing statistics, always run the real verification. +//! - `--enable-crypto-signature-cache` (M2): on a cache hit, skip the real verification and +//! return success; on a miss, run the real verification and store the positive conclusion. +//! Statistics are recorded the same way; `verify_skipped`/`verify_executed` report what +//! actually happened. Both flags may be combined (reuse + statistics). +//! +//! The five choke points (see the development plan for the full caller inventory): +//! +//! - `CmsSignedObject`: `data_model::signed_object` CMS verify (ROA / ASPA / manifest on the +//! main validation path; GBR objects are not signature-verified by the tree runner). +//! - `EeCertFast`: `validation::cert_path::verify_ee_cert_signature_fast` (ring, TBS + signature +//! bytes from the signed-object embedded EE certificate; ROA / ASPA / manifest EE paths). +//! - `EeCertX509`: `validation::cert_path::verify_ee_cert_signature` (x509-parser; router +//! certificates and the `validate_ee_cert_path` family). +//! - `ChildCaCert`: `validation::ca_path::verify_child_signature` (x509-parser; subordinate CA +//! certificates). +//! - `Crl`: `data_model::crl::verify_signature_with_issuer_spki` (x509-parser; issuer CRLs). +//! +//! Trust-anchor self-signature verification is deliberately excluded (a handful of calls per +//! run). +//! +//! Cache key (M1 decision: verify-input bytes, not full object bytes — the key material is in +//! hand inside each choke point, while the repo-bytes content hash is only available at the +//! upper processing layer): +//! +//! ```text +//! key = SHA-256( "rpki-crypto-sig-cache-key-v1" | point-tag | "sha256WithRSAEncryption" +//! | SHA-256(verify input bytes) | SHA-256(signature bytes) +//! | SHA-256(verifier SPKI DER) ) +//! ``` +//! +//! The signature bytes are part of the key so that a different signature over the same input +//! under the same key can never inherit a positive verification fact. +//! +//! Storage: +//! +//! - Single small file next to the work DB (`.crypto-sig-cache`), never inside +//! the work DB, not touched by periodic snapshot reset (#091/#092): the reset paths only +//! remove/replace the work DB directory and the PP/child-cert cache artifacts, not siblings +//! of their own choosing. +//! - Binary format: 8-byte magic `RPKICSC2` | u64 record-count | records of +//! key[32] | verified_at_unix_secs(i64) | crypto_impl_version(u32) | reserved(u32). +//! A missing, corrupt, or magic-mismatched file (including the M1 `RPKICSC1` seen-set +//! format) is silently rebuilt empty. Writes are atomic (write-then-rename). +//! - The cache is persisted once at the end of the validation stage (same point as the M1 +//! seen-set). If a run fails mid-validation, entries added during that run are lost; +//! previously persisted entries are unaffected. +//! - Size cap: approximate上限 `capacity` entries (default 2,000,000; measured M1 APNIC +//! ~148k keys/run, all5 estimated ~750k). When an insert would exceed the cap, the whole +//! cache is cleared and rebuilt from scratch (full-rebuild eviction — the simplest policy; +//! concurrent inserts may briefly overshoot the cap by a few entries). Each eviction is +//! counted in `evictions_total`. +//! - Concurrency: entries live in 64 shards keyed by the first key byte, so parallel +//! validation workers rarely contend; statistics use relaxed atomics. + +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; + +use serde::Serialize; +use sha2::Digest; + +const CACHE_FILE_MAGIC: &[u8; 8] = b"RPKICSC2"; +const KEY_DOMAIN: &[u8] = b"rpki-crypto-sig-cache-key-v1"; +const ALG_ID: &[u8] = b"sha256WithRSAEncryption"; +const SHARD_COUNT: usize = 64; +const RECORD_BYTES: usize = 32 + 8 + 4 + 4; + +/// The schema version reported in summaries. The on-disk magic already carries the format +/// version; this constant is the semantic version of the key/value definitions. +pub const CACHE_SCHEMA_VERSION: u32 = 2; + +/// Default maximum number of cached positive conclusions (see module docs). +pub const DEFAULT_CAPACITY: usize = 2_000_000; + +/// Crypto implementation identifier stored in every value. Bump when the verification +/// backend changes so old entries can be invalidated wholesale. +/// `1` = ring 0.17 `RSA_PKCS1_2048_8192_SHA256` (direct ring calls and x509-parser's +/// `verify` feature, which is backed by ring in this crate). +pub const CRYPTO_IMPL_VERSION: u32 = 1; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CryptoSigVerifyPoint { + CmsSignedObject, + EeCertFast, + EeCertX509, + ChildCaCert, + Crl, +} + +impl CryptoSigVerifyPoint { + pub const ALL: [Self; 5] = [ + Self::CmsSignedObject, + Self::EeCertFast, + Self::EeCertX509, + Self::ChildCaCert, + Self::Crl, + ]; + + fn tag(self) -> u8 { + match self { + Self::CmsSignedObject => 1, + Self::EeCertFast => 2, + Self::EeCertX509 => 3, + Self::ChildCaCert => 4, + Self::Crl => 5, + } + } + + pub fn name(self) -> &'static str { + match self { + Self::CmsSignedObject => "cms_signed_object", + Self::EeCertFast => "ee_cert_fast", + Self::EeCertX509 => "ee_cert_x509", + Self::ChildCaCert => "child_ca_cert", + Self::Crl => "crl", + } + } + + fn from_index(index: usize) -> Self { + Self::ALL[index] + } +} + +/// Compute the cache key for one verification fact. +/// +/// `input_bytes` is the exact byte string fed to the verifier (CMS signedAttrs DER for +/// signature, TBSCertificate DER, TBSCertList DER), `signature_bytes` the signature value, +/// and `verifier_spki_der` the DER of the verifying SubjectPublicKeyInfo. +pub fn compute_cache_key( + point: CryptoSigVerifyPoint, + input_bytes: &[u8], + signature_bytes: &[u8], + verifier_spki_der: &[u8], +) -> [u8; 32] { + let mut h = sha2::Sha256::new(); + h.update(KEY_DOMAIN); + h.update([point.tag()]); + h.update(ALG_ID); + for part in [input_bytes, signature_bytes, verifier_spki_der] { + let part_hash = sha2::Sha256::digest(part); + h.update(part_hash); + } + h.finalize().into() +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CacheValue { + pub verified_at_unix_secs: i64, + pub crypto_impl_version: u32, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +pub struct CryptoSigVerifyPointStats { + pub calls: u64, + /// Key already present in the cache (positive conclusion available). + pub would_hit: u64, + /// Key not present; after a successful verification it is inserted. + pub new_keys: u64, + /// Real cryptographic verification executed (always equals `calls` in observe-only + /// mode; equals `new_keys` plus hit-but-observe calls otherwise). + pub verify_executed: u64, + /// Real verification skipped because of a cache hit (only > 0 when reuse is enabled). + pub verify_skipped: u64, + pub verify_nanos_on_would_hit: u64, + pub verify_nanos_on_new_key: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct CryptoSigCacheSummary { + pub schema_version: u32, + pub cache_file: String, + pub reuse_enabled: bool, + pub capacity: u64, + pub entries_loaded: u64, + pub entries_total: u64, + pub evictions_total: u64, + pub per_point: BTreeMap, +} + +#[derive(Default)] +struct PointCounters { + calls: AtomicU64, + would_hit: AtomicU64, + new_keys: AtomicU64, + verify_executed: AtomicU64, + verify_skipped: AtomicU64, + verify_nanos_on_would_hit: AtomicU64, + verify_nanos_on_new_key: AtomicU64, +} + +fn shard_index(key: &[u8; 32]) -> usize { + (key[0] as usize) % SHARD_COUNT +} + +/// The persistent positive-conclusion signature cache plus per-run statistics. +/// +/// See the module docs for storage format, eviction, and failure semantics. +pub struct CryptoSigCache { + cache_file: PathBuf, + reuse_enabled: bool, + capacity: usize, + entries_loaded: u64, + shards: Vec>>, + len: AtomicUsize, + evictions: AtomicU64, + eviction_lock: Mutex<()>, + points: [PointCounters; 5], +} + +impl CryptoSigCache { + /// Load the cache from `cache_file`; start empty when the file is missing, corrupt, or + /// has an unknown schema header. `reuse_enabled` selects the M2 hit-skips-verify + /// behavior; when false the cache is observe-only (M1 semantics). + pub fn load_or_rebuild(cache_file: PathBuf, reuse_enabled: bool) -> Self { + Self::load_or_rebuild_with_capacity(cache_file, reuse_enabled, DEFAULT_CAPACITY) + } + + pub fn load_or_rebuild_with_capacity( + cache_file: PathBuf, + reuse_enabled: bool, + capacity: usize, + ) -> Self { + let entries = load_cache_file(&cache_file).unwrap_or_default(); + let mut shards = Vec::with_capacity(SHARD_COUNT); + for _ in 0..SHARD_COUNT { + shards.push(Mutex::new(HashMap::new())); + } + let mut len = 0usize; + for (key, value) in entries { + // Respect the cap even when loading a foreign/oversized file: keep the first + // `capacity` records in file order. + if len >= capacity { + break; + } + shards[shard_index(&key)] + .lock() + .expect("crypto sig shard lock") + .insert(key, value); + len += 1; + } + Self { + cache_file, + reuse_enabled, + capacity, + entries_loaded: len as u64, + shards, + len: AtomicUsize::new(len), + evictions: AtomicU64::new(0), + eviction_lock: Mutex::new(()), + points: Default::default(), + } + } + + pub fn reuse_enabled(&self) -> bool { + self.reuse_enabled + } + + fn lookup(&self, key: &[u8; 32]) -> bool { + self.shards[shard_index(key)] + .lock() + .expect("crypto sig shard lock") + .contains_key(key) + } + + #[cfg(test)] + pub fn contains(&self, key: &[u8; 32]) -> bool { + self.lookup(key) + } + + /// Insert a positive conclusion (used by the verify path and by tests). + pub fn insert_positive(&self, key: [u8; 32]) { + let value = CacheValue { + verified_at_unix_secs: time::OffsetDateTime::now_utc().unix_timestamp(), + crypto_impl_version: CRYPTO_IMPL_VERSION, + }; + if self.len.load(Ordering::Relaxed) >= self.capacity { + self.evict_all(); + } + let mut shard = self.shards[shard_index(&key)] + .lock() + .expect("crypto sig shard lock"); + if shard.insert(key, value).is_none() { + self.len.fetch_add(1, Ordering::Relaxed); + } + } + + /// Full-rebuild eviction: clear every shard. See the module docs for the semantics. + fn evict_all(&self) { + let _guard = self.eviction_lock.lock().expect("crypto sig eviction lock"); + if self.len.load(Ordering::Relaxed) < self.capacity { + // Another thread already evicted. + return; + } + for shard in &self.shards { + shard.lock().expect("crypto sig shard lock").clear(); + } + self.len.store(0, Ordering::Relaxed); + self.evictions.fetch_add(1, Ordering::Relaxed); + } + + /// Cache-aware verification: observe-only or reuse depending on `reuse_enabled`. + /// The `verify` closure must perform exactly the real cryptographic verification. + pub fn verify( + &self, + point: CryptoSigVerifyPoint, + input_bytes: &[u8], + signature_bytes: &[u8], + verifier_spki_der: &[u8], + verify: impl FnOnce() -> Result<(), E>, + ) -> Result<(), E> { + let key = compute_cache_key(point, input_bytes, signature_bytes, verifier_spki_der); + let hit = self.lookup(&key); + let counters = &self.points[(point.tag() - 1) as usize]; + counters.calls.fetch_add(1, Ordering::Relaxed); + if hit { + counters.would_hit.fetch_add(1, Ordering::Relaxed); + if self.reuse_enabled { + counters.verify_skipped.fetch_add(1, Ordering::Relaxed); + return Ok(()); + } + } else { + counters.new_keys.fetch_add(1, Ordering::Relaxed); + } + counters.verify_executed.fetch_add(1, Ordering::Relaxed); + let started = std::time::Instant::now(); + let result = verify(); + let nanos = started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64; + if hit { + counters + .verify_nanos_on_would_hit + .fetch_add(nanos, Ordering::Relaxed); + } else { + counters + .verify_nanos_on_new_key + .fetch_add(nanos, Ordering::Relaxed); + if result.is_ok() { + self.insert_positive(key); + } + } + result + } + + /// Persist the cache atomically (write-then-rename). + pub fn persist(&self) -> Result<(), String> { + persist_cache_file(&self.cache_file, &self.shards) + } + + pub fn summary(&self) -> CryptoSigCacheSummary { + let mut per_point = BTreeMap::new(); + for (index, counters) in self.points.iter().enumerate() { + let stats = CryptoSigVerifyPointStats { + calls: counters.calls.load(Ordering::Relaxed), + would_hit: counters.would_hit.load(Ordering::Relaxed), + new_keys: counters.new_keys.load(Ordering::Relaxed), + verify_executed: counters.verify_executed.load(Ordering::Relaxed), + verify_skipped: counters.verify_skipped.load(Ordering::Relaxed), + verify_nanos_on_would_hit: counters + .verify_nanos_on_would_hit + .load(Ordering::Relaxed), + verify_nanos_on_new_key: counters + .verify_nanos_on_new_key + .load(Ordering::Relaxed), + }; + if stats.calls > 0 { + per_point.insert( + CryptoSigVerifyPoint::from_index(index).name().to_string(), + stats, + ); + } + } + CryptoSigCacheSummary { + schema_version: CACHE_SCHEMA_VERSION, + cache_file: self.cache_file.to_string_lossy().into_owned(), + reuse_enabled: self.reuse_enabled, + capacity: self.capacity as u64, + entries_loaded: self.entries_loaded, + entries_total: self.len.load(Ordering::Relaxed) as u64, + evictions_total: self.evictions.load(Ordering::Relaxed), + per_point, + } + } +} + +/// Default cache file location: a sibling of the work DB directory, mirroring +/// `default_pp_cache_index_dir` (`.crypto-sig-cache`). +pub fn default_cache_file_path(db_path: &Path) -> PathBuf { + let file_name = db_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("work-db"); + db_path.with_file_name(format!("{file_name}.crypto-sig-cache")) +} + +fn load_cache_file(path: &Path) -> Option> { + let bytes = std::fs::read(path).ok()?; + let header_len = CACHE_FILE_MAGIC.len() + 8; + if bytes.len() < header_len || &bytes[..CACHE_FILE_MAGIC.len()] != CACHE_FILE_MAGIC { + return None; + } + let mut count_bytes = [0u8; 8]; + count_bytes.copy_from_slice(&bytes[CACHE_FILE_MAGIC.len()..header_len]); + let count = u64::from_le_bytes(count_bytes) as usize; + let body = &bytes[header_len..]; + if body.len() != count.saturating_mul(RECORD_BYTES) { + return None; + } + let mut entries = HashMap::with_capacity(count); + for chunk in body.chunks_exact(RECORD_BYTES) { + let mut key = [0u8; 32]; + key.copy_from_slice(&chunk[..32]); + let mut secs_bytes = [0u8; 8]; + secs_bytes.copy_from_slice(&chunk[32..40]); + let mut version_bytes = [0u8; 4]; + version_bytes.copy_from_slice(&chunk[40..44]); + entries.insert( + key, + CacheValue { + verified_at_unix_secs: i64::from_le_bytes(secs_bytes), + crypto_impl_version: u32::from_le_bytes(version_bytes), + }, + ); + } + Some(entries) +} + +fn persist_cache_file( + path: &Path, + shards: &[Mutex>], +) -> Result<(), String> { + let mut bytes = Vec::new(); + bytes.extend_from_slice(CACHE_FILE_MAGIC); + // Placeholder for the record count; patched after collecting. + bytes.extend_from_slice(&[0u8; 8]); + let mut count = 0u64; + for shard in shards { + let map = shard.lock().expect("crypto sig shard lock"); + for (key, value) in map.iter() { + bytes.extend_from_slice(key); + bytes.extend_from_slice(&value.verified_at_unix_secs.to_le_bytes()); + bytes.extend_from_slice(&value.crypto_impl_version.to_le_bytes()); + bytes.extend_from_slice(&[0u8; 4]); + count += 1; + } + } + bytes[CACHE_FILE_MAGIC.len()..CACHE_FILE_MAGIC.len() + 8] + .copy_from_slice(&count.to_le_bytes()); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("create cache-file dir failed: {}: {e}", parent.display()))?; + } + let tmp_path = path.with_extension("tmp"); + std::fs::write(&tmp_path, &bytes) + .map_err(|e| format!("write cache file failed: {}: {e}", tmp_path.display()))?; + std::fs::rename(&tmp_path, path).map_err(|e| { + format!( + "rename cache file failed: {} -> {}: {e}", + tmp_path.display(), + path.display() + ) + })?; + Ok(()) +} + +static GLOBAL_CACHE: RwLock>> = RwLock::new(None); + +/// Install the process-global cache (called once from the CLI run path when +/// `--crypto-signature-cache-observe-only` and/or `--enable-crypto-signature-cache` is set). +pub fn install_global(cache: Arc) { + *GLOBAL_CACHE.write().expect("crypto sig cache lock") = Some(cache); +} + +/// Remove the process-global cache. Used by tests and by the CLI after the run finished. +pub fn clear_global() { + *GLOBAL_CACHE.write().expect("crypto sig cache lock") = None; +} + +pub fn global_cache() -> Option> { + GLOBAL_CACHE + .read() + .expect("crypto sig cache lock") + .clone() +} + +/// Choke-point hook: cache-aware wrapper around a real signature verification. +/// +/// With no cache installed this is a single read-lock check plus the verification itself; +/// verification semantics never change either way (a hit only skips the crypto primitive, +/// never the surrounding revocation/time/policy checks). +pub fn verify_with_cache( + point: CryptoSigVerifyPoint, + input_bytes: &[u8], + signature_bytes: &[u8], + verifier_spki_der: &[u8], + verify: impl FnOnce() -> Result<(), E>, +) -> Result<(), E> { + let Some(cache) = global_cache() else { + return verify(); + }; + cache.verify( + point, + input_bytes, + signature_bytes, + verifier_spki_der, + verify, + ) +} + +#[cfg(test)] +pub(crate) static GLOBAL_TEST_LOCK: Mutex<()> = Mutex::new(()); + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_cache(reuse_enabled: bool) -> (tempfile::TempDir, CryptoSigCache) { + let dir = tempfile::tempdir().expect("tempdir"); + let cache = CryptoSigCache::load_or_rebuild( + dir.path().join("work-db.crypto-sig-cache"), + reuse_enabled, + ); + (dir, cache) + } + + #[test] + fn cache_key_is_deterministic_and_domain_separated() { + let a = compute_cache_key( + CryptoSigVerifyPoint::CmsSignedObject, + b"input", + b"sig", + b"spki", + ); + let b = compute_cache_key( + CryptoSigVerifyPoint::CmsSignedObject, + b"input", + b"sig", + b"spki", + ); + assert_eq!(a, b); + + // Different verify point. + assert_ne!( + a, + compute_cache_key(CryptoSigVerifyPoint::Crl, b"input", b"sig", b"spki") + ); + // Different input bytes. + assert_ne!( + a, + compute_cache_key( + CryptoSigVerifyPoint::CmsSignedObject, + b"input2", + b"sig", + b"spki" + ) + ); + // Different signature bytes over the same input must not share a key. + assert_ne!( + a, + compute_cache_key( + CryptoSigVerifyPoint::CmsSignedObject, + b"input", + b"sig2", + b"spki" + ) + ); + // Different verifier key. + assert_ne!( + a, + compute_cache_key( + CryptoSigVerifyPoint::CmsSignedObject, + b"input", + b"sig", + b"spki2" + ) + ); + } + + #[test] + fn miss_verifies_and_writes_then_hit_skips_verify() { + let (_dir, cache) = temp_cache(true); + let mut executed = 0u64; + // First call: miss -> real verification runs and passes -> entry written. + cache + .verify( + CryptoSigVerifyPoint::EeCertFast, + b"tbs", + b"sig", + b"spki", + || -> Result<(), String> { + executed += 1; + Ok(()) + }, + ) + .expect("verify ok"); + assert_eq!(executed, 1); + // Second call: hit -> verification skipped entirely. + cache + .verify( + CryptoSigVerifyPoint::EeCertFast, + b"tbs", + b"sig", + b"spki", + || -> Result<(), String> { + executed += 1; + Ok(()) + }, + ) + .expect("verify ok"); + assert_eq!(executed, 1, "cache hit must skip the real verification"); + let summary = cache.summary(); + let stats = summary.per_point.get("ee_cert_fast").expect("stats present"); + assert_eq!(stats.calls, 2); + assert_eq!(stats.new_keys, 1); + assert_eq!(stats.would_hit, 1); + assert_eq!(stats.verify_executed, 1); + assert_eq!(stats.verify_skipped, 1); + assert!(summary.reuse_enabled); + } + + #[test] + fn preset_positive_key_skips_verify_that_would_fail() { + let (_dir, cache) = temp_cache(true); + // Tampered object bytes whose signature can never verify; pre-seed the positive + // conclusion directly (as if a previous run had verified the untampered object). + let key = compute_cache_key( + CryptoSigVerifyPoint::CmsSignedObject, + b"tampered-input", + b"tampered-sig", + b"spki", + ); + cache.insert_positive(key); + let mut executed = 0u64; + let result = cache.verify( + CryptoSigVerifyPoint::CmsSignedObject, + b"tampered-input", + b"tampered-sig", + b"spki", + || -> Result<(), String> { + executed += 1; + Err("signature does not verify".to_string()) + }, + ); + assert_eq!(result, Ok(()), "cache hit must return Ok without verifying"); + assert_eq!(executed, 0, "real verification must not run on a hit"); + } + + #[test] + fn failed_verify_is_not_cached_and_reruns_next_time() { + let (_dir, cache) = temp_cache(true); + let mut executed = 0u64; + for _ in 0..2 { + let result = cache.verify( + CryptoSigVerifyPoint::Crl, + b"tbs", + b"sig", + b"spki", + || -> Result<(), String> { + executed += 1; + Err("bad signature".to_string()) + }, + ); + assert!(result.is_err()); + } + assert_eq!(executed, 2, "no negative caching: every call re-verifies"); + let key = compute_cache_key(CryptoSigVerifyPoint::Crl, b"tbs", b"sig", b"spki"); + assert!(!cache.contains(&key)); + let summary = cache.summary(); + let stats = summary.per_point.get("crl").expect("stats present"); + assert_eq!(stats.new_keys, 2); + assert_eq!(stats.verify_executed, 2); + assert_eq!(stats.verify_skipped, 0); + } + + #[test] + fn observe_only_mode_counts_would_hit_but_still_verifies() { + let (_dir, cache) = temp_cache(false); + let key = compute_cache_key(CryptoSigVerifyPoint::ChildCaCert, b"tbs", b"sig", b"spki"); + cache.insert_positive(key); + let mut executed = 0u64; + cache + .verify( + CryptoSigVerifyPoint::ChildCaCert, + b"tbs", + b"sig", + b"spki", + || -> Result<(), String> { + executed += 1; + Ok(()) + }, + ) + .expect("verify ok"); + assert_eq!(executed, 1, "observe-only mode must still run the verification"); + let summary = cache.summary(); + assert!(!summary.reuse_enabled); + let stats = summary + .per_point + .get("child_ca_cert") + .expect("stats present"); + assert_eq!(stats.would_hit, 1); + assert_eq!(stats.verify_executed, 1); + assert_eq!(stats.verify_skipped, 0); + } + + #[test] + fn cache_file_roundtrips_entries_across_reload() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("work-db.crypto-sig-cache"); + let cache = CryptoSigCache::load_or_rebuild(path.clone(), true); + let key = compute_cache_key(CryptoSigVerifyPoint::EeCertFast, b"tbs", b"sig", b"spki"); + cache.insert_positive(key); + cache.persist().expect("persist"); + + let reloaded = CryptoSigCache::load_or_rebuild(path, true); + assert!(reloaded.contains(&key)); + let summary = reloaded.summary(); + assert_eq!(summary.entries_loaded, 1); + assert_eq!(summary.entries_total, 1); + assert_eq!(summary.schema_version, CACHE_SCHEMA_VERSION); + + // Reloaded entry drives a skip. + let mut executed = 0u64; + reloaded + .verify( + CryptoSigVerifyPoint::EeCertFast, + b"tbs", + b"sig", + b"spki", + || -> Result<(), String> { + executed += 1; + Ok(()) + }, + ) + .expect("verify ok"); + assert_eq!(executed, 0); + } + + #[test] + fn cache_file_rebuilds_on_missing_corrupt_or_mismatched_schema() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("work-db.crypto-sig-cache"); + + // Missing file. + let cache = CryptoSigCache::load_or_rebuild(path.clone(), true); + assert_eq!(cache.summary().entries_loaded, 0); + + // Garbage content. + std::fs::write(&path, b"not a valid cache file").expect("write garbage"); + let cache = CryptoSigCache::load_or_rebuild(path.clone(), true); + assert_eq!(cache.summary().entries_loaded, 0); + + // M1 seen-set format (RPKICSC1) must not load as a v2 cache: cold start. + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"RPKICSC1"); + bytes.extend_from_slice(&1u64.to_le_bytes()); + bytes.extend_from_slice(&[7u8; 32]); + std::fs::write(&path, &bytes).expect("write v1 file"); + let cache = CryptoSigCache::load_or_rebuild(path.clone(), true); + assert_eq!(cache.summary().entries_loaded, 0); + + // Valid v2 header but truncated body. + let mut bytes = Vec::new(); + bytes.extend_from_slice(CACHE_FILE_MAGIC); + bytes.extend_from_slice(&2u64.to_le_bytes()); + bytes.extend_from_slice(&[9u8; RECORD_BYTES]); + std::fs::write(&path, &bytes).expect("write truncated"); + let cache = CryptoSigCache::load_or_rebuild(path, true); + assert_eq!(cache.summary().entries_loaded, 0); + } + + #[test] + fn size_cap_triggers_full_rebuild_eviction() { + let dir = tempfile::tempdir().expect("tempdir"); + let cache = CryptoSigCache::load_or_rebuild_with_capacity( + dir.path().join("work-db.crypto-sig-cache"), + true, + 2, + ); + let k1 = compute_cache_key(CryptoSigVerifyPoint::Crl, b"a", b"s", b"k"); + let k2 = compute_cache_key(CryptoSigVerifyPoint::Crl, b"b", b"s", b"k"); + let k3 = compute_cache_key(CryptoSigVerifyPoint::Crl, b"c", b"s", b"k"); + cache.insert_positive(k1); + cache.insert_positive(k2); + assert!(cache.contains(&k1) && cache.contains(&k2)); + // Third insert exceeds the cap -> full rebuild, then insert. + cache.insert_positive(k3); + assert!(!cache.contains(&k1)); + assert!(!cache.contains(&k2)); + assert!(cache.contains(&k3)); + let summary = cache.summary(); + assert_eq!(summary.entries_total, 1); + assert_eq!(summary.evictions_total, 1); + } + + #[test] + fn default_cache_file_path_mirrors_pp_cache_index_placement() { + let db = Path::new("/state/db/work-db"); + assert_eq!( + default_cache_file_path(db), + PathBuf::from("/state/db/work-db.crypto-sig-cache") + ); + } + + #[test] + fn verify_with_cache_without_global_cache_runs_verify_and_collects_nothing() { + let _guard = GLOBAL_TEST_LOCK.lock().expect("test lock"); + clear_global(); + assert!(global_cache().is_none()); + let mut executed = 0u64; + let result: Result = (|| { + verify_with_cache( + CryptoSigVerifyPoint::CmsSignedObject, + b"input", + b"sig", + b"spki", + || -> Result<(), String> { + executed += 1; + Ok(()) + }, + )?; + Ok(42) + })(); + assert_eq!(result, Ok(42)); + assert_eq!(executed, 1); + assert!(global_cache().is_none()); + } + + #[test] + fn verify_with_cache_preserves_result_through_global_install() { + let _guard = GLOBAL_TEST_LOCK.lock().expect("test lock"); + clear_global(); + let dir = tempfile::tempdir().expect("tempdir"); + let cache = Arc::new(CryptoSigCache::load_or_rebuild( + dir.path().join("work-db.crypto-sig-cache"), + true, + )); + install_global(Arc::clone(&cache)); + + let ok: Result<(), String> = verify_with_cache( + CryptoSigVerifyPoint::ChildCaCert, + b"tbs", + b"sig", + b"spki", + || Ok(()), + ); + assert_eq!(ok, Ok(())); + let err: Result<(), String> = verify_with_cache( + CryptoSigVerifyPoint::ChildCaCert, + b"tbs2", + b"sig", + b"spki", + || Err("bad signature".to_string()), + ); + assert_eq!(err, Err("bad signature".to_string())); + let summary = cache.summary(); + let stats = summary + .per_point + .get("child_ca_cert") + .expect("stats present"); + assert_eq!(stats.calls, 2); + assert_eq!(stats.new_keys, 2); + clear_global(); + assert!(global_cache().is_none()); + } +} diff --git a/src/data_model/crl.rs b/src/data_model/crl.rs index 1e45992..4396565 100644 --- a/src/data_model/crl.rs +++ b/src/data_model/crl.rs @@ -285,8 +285,16 @@ impl RpkixCrl { if !rem.is_empty() { return Err(CrlVerifyError::CrlTrailingBytes(rem.len())); } - crl.verify_signature(issuer_spki) - .map_err(|e| CrlVerifyError::InvalidSignature(e.to_string())) + crate::crypto_sig_cache::verify_with_cache( + crate::crypto_sig_cache::CryptoSigVerifyPoint::Crl, + crl.tbs_cert_list.as_ref(), + crl.signature_value.data.as_ref(), + issuer_spki.raw, + || { + crl.verify_signature(issuer_spki) + .map_err(|e| CrlVerifyError::InvalidSignature(e.to_string())) + }, + ) } /// Verify the cryptographic signature on this CRL using a DER-encoded SubjectPublicKeyInfo. diff --git a/src/data_model/signed_object.rs b/src/data_model/signed_object.rs index a0e6437..26efbb2 100644 --- a/src/data_model/signed_object.rs +++ b/src/data_model/signed_object.rs @@ -358,7 +358,19 @@ impl RpkiSignedObject { /// Verify the CMS signature using the embedded EE certificate public key. pub fn verify_signature(&self) -> Result<(), SignedObjectVerifyError> { let ee = &self.signed_data.certificates[0]; - self.verify_signature_with_rsa_components(&ee.rsa_public_modulus, &ee.rsa_public_exponent) + let signer = &self.signed_data.signer_infos[0]; + crate::crypto_sig_cache::verify_with_cache( + crate::crypto_sig_cache::CryptoSigVerifyPoint::CmsSignedObject, + &signer.signed_attrs_der_for_signature, + &signer.signature, + &ee.spki_der, + || { + self.verify_signature_with_rsa_components( + &ee.rsa_public_modulus, + &ee.rsa_public_exponent, + ) + }, + ) } /// Verify the CMS signature using a DER-encoded SubjectPublicKeyInfo. @@ -394,7 +406,14 @@ impl RpkiSignedObject { _ => return Err(SignedObjectVerifyError::UnsupportedEePublicKeyAlgorithm), }; - self.verify_signature_with_rsa_components(n.as_slice(), e.as_slice()) + let signer = &self.signed_data.signer_infos[0]; + crate::crypto_sig_cache::verify_with_cache( + crate::crypto_sig_cache::CryptoSigVerifyPoint::CmsSignedObject, + &signer.signed_attrs_der_for_signature, + &signer.signature, + ee_spki.raw, + || self.verify_signature_with_rsa_components(n.as_slice(), e.as_slice()), + ) } fn verify_signature_with_rsa_components( diff --git a/src/lib.rs b/src/lib.rs index e85aa1c..3aa36d6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod ccr; pub mod cir; +pub mod crypto_sig_cache; pub mod data_model; #[cfg(feature = "full")] diff --git a/src/validation/ca_path.rs b/src/validation/ca_path.rs index 245e334..8440de1 100644 --- a/src/validation/ca_path.rs +++ b/src/validation/ca_path.rs @@ -485,9 +485,17 @@ fn verify_child_signature( child: &X509Certificate<'_>, issuer_spki: &SubjectPublicKeyInfo<'_>, ) -> Result<(), CaPathError> { - child - .verify_signature(Some(issuer_spki)) - .map_err(|e| CaPathError::ChildSignatureInvalid(e.to_string())) + crate::crypto_sig_cache::verify_with_cache( + crate::crypto_sig_cache::CryptoSigVerifyPoint::ChildCaCert, + child.tbs_certificate.as_ref(), + child.signature_value.data.as_ref(), + issuer_spki.raw, + || { + child + .verify_signature(Some(issuer_spki)) + .map_err(|e| CaPathError::ChildSignatureInvalid(e.to_string())) + }, + ) } fn validate_child_aki_matches_issuer_ski( @@ -2118,6 +2126,53 @@ mod tests { ); } + #[test] + fn signature_cache_stores_real_verify_success_and_skips_on_hit() { + let _guard = crate::crypto_sig_cache::GLOBAL_TEST_LOCK.lock().expect("test lock"); + crate::crypto_sig_cache::clear_global(); + let td = tempfile::tempdir().expect("tempdir"); + let (issuer, child, other) = gen_issuer_and_child_der(td.path()); + let issuer_cert = parse_x509_cert(&issuer).expect("x509 parse issuer"); + let child_cert = parse_x509_cert(&child).expect("x509 parse child"); + let other_cert = parse_x509_cert(&other).expect("x509 parse other"); + + let cache_dir = tempfile::tempdir().expect("tempdir"); + let cache = std::sync::Arc::new(crate::crypto_sig_cache::CryptoSigCache::load_or_rebuild( + cache_dir.path().join("work-db.crypto-sig-cache"), + true, + )); + crate::crypto_sig_cache::install_global(std::sync::Arc::clone(&cache)); + + // Miss -> real verification runs and passes -> positive conclusion written. + verify_child_signature(&child_cert, &issuer_cert.tbs_certificate.subject_pki) + .expect("signature ok"); + // Hit -> skipped (a second execution would also pass, so assert via stats). + verify_child_signature(&child_cert, &issuer_cert.tbs_certificate.subject_pki) + .expect("signature ok"); + // Wrong issuer: real verification fails, nothing is written (no negative caching), + // so a retry executes the real verification again instead of inheriting anything. + for _ in 0..2 { + let err = verify_child_signature(&child_cert, &other_cert.tbs_certificate.subject_pki) + .unwrap_err(); + assert!( + matches!(err, CaPathError::ChildSignatureInvalid(_)), + "{err}" + ); + } + + let summary = cache.summary(); + let stats = summary + .per_point + .get("child_ca_cert") + .expect("child_ca_cert stats"); + assert_eq!(stats.calls, 4); + assert_eq!(stats.would_hit, 1); + assert_eq!(stats.new_keys, 3); + assert_eq!(stats.verify_executed, 3); + assert_eq!(stats.verify_skipped, 1); + crate::crypto_sig_cache::clear_global(); + } + #[test] fn issuer_effective_resources_index_and_indexed_resolvers_cover_success_and_failure_paths() { use crate::data_model::rc::{AsIdOrRange, IpPrefix}; diff --git a/src/validation/cert_path.rs b/src/validation/cert_path.rs index 0bfb36b..920054c 100644 --- a/src/validation/cert_path.rs +++ b/src/validation/cert_path.rs @@ -385,8 +385,16 @@ fn verify_ee_cert_signature( ee: &X509Certificate<'_>, issuer_spki: &SubjectPublicKeyInfo<'_>, ) -> Result<(), CertPathError> { - ee.verify_signature(Some(issuer_spki)) - .map_err(|e| CertPathError::EeSignatureInvalid(e.to_string())) + crate::crypto_sig_cache::verify_with_cache( + crate::crypto_sig_cache::CryptoSigVerifyPoint::EeCertX509, + ee.tbs_certificate.as_ref(), + ee.signature_value.data.as_ref(), + issuer_spki.raw, + || { + ee.verify_signature(Some(issuer_spki)) + .map_err(|e| CertPathError::EeSignatureInvalid(e.to_string())) + }, + ) } fn verify_ee_cert_signature_fast( @@ -394,12 +402,20 @@ fn verify_ee_cert_signature_fast( ee_signature_bytes: &[u8], issuer_spki: &SubjectPublicKeyInfo<'_>, ) -> Result<(), CertPathError> { - let key = signature::UnparsedPublicKey::new( - &signature::RSA_PKCS1_2048_8192_SHA256, - &issuer_spki.subject_public_key.data, - ); - key.verify(ee_tbs_der, ee_signature_bytes) - .map_err(|e| CertPathError::EeSignatureInvalid(e.to_string())) + crate::crypto_sig_cache::verify_with_cache( + crate::crypto_sig_cache::CryptoSigVerifyPoint::EeCertFast, + ee_tbs_der, + ee_signature_bytes, + issuer_spki.raw, + || { + let key = signature::UnparsedPublicKey::new( + &signature::RSA_PKCS1_2048_8192_SHA256, + &issuer_spki.subject_public_key.data, + ); + key.verify(ee_tbs_der, ee_signature_bytes) + .map_err(|e| CertPathError::EeSignatureInvalid(e.to_string())) + }, + ) } fn validate_ee_aki_matches_issuer_ski( @@ -720,4 +736,145 @@ mod tests { validate_ee_crldp_contains_issuer_crl_uri(&ee, "rsync://example.test/issuer.crl") .expect("crldp ok"); } + + #[test] + fn signature_cache_hit_does_not_skip_crl_revocation_check() { + use crate::crypto_sig_cache::{ + CryptoSigCache, CryptoSigVerifyPoint, compute_cache_key, + }; + use crate::data_model::common::{Asn1TimeEncoding, Asn1TimeUtc}; + use crate::data_model::crl::CrlExtensions; + + let _guard = crate::crypto_sig_cache::GLOBAL_TEST_LOCK.lock().expect("test lock"); + crate::crypto_sig_cache::clear_global(); + + let now = time::OffsetDateTime::now_utc(); + let mut issuer = dummy_cert( + ResourceCertKind::Ca, + "CN=issuer", + "CN=issuer", + Some(vec![1]), + None, + None, + None, + ); + issuer.tbs.validity_not_before = now - time::Duration::days(1); + issuer.tbs.validity_not_after = now + time::Duration::days(1); + let mut ee_rc = dummy_cert( + ResourceCertKind::Ee, + "CN=ee", + "CN=issuer", + Some(vec![2]), + Some(vec![1]), + Some(vec!["rsync://example.test/issuer.cer"]), + Some(vec!["rsync://example.test/issuer.crl"]), + ); + ee_rc.tbs.validity_not_before = now - time::Duration::days(1); + ee_rc.tbs.validity_not_after = now + time::Duration::days(1); + + // Tampered "object": these bytes could never pass the real RSA verification, so an + // Ok outcome below proves the crypto step was skipped by the cache hit. + let ee = ResourceEeCertificate { + raw_der: Vec::new(), + subject_key_identifier: vec![2], + spki_der: Vec::new(), + rsa_public_modulus: Vec::new(), + rsa_public_exponent: Vec::new(), + tbs_certificate_der: b"tampered-tbs-bytes".to_vec(), + signature_bytes: b"tampered-signature-bytes".to_vec(), + key_usage_summary: EeKeyUsageSummary::DigitalSignatureOnly, + sia_signed_object_uris: Vec::new(), + resource_cert: ee_rc, + }; + + // Any real RSA SPKI works as the verifier identity (the TA fixture is a valid RSA + // certificate); the SPKI DER only feeds the cache key here. + let ta_der = std::fs::read(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/ta/apnic-ta.cer" + )) + .expect("read ta fixture"); + let (_, ta_x509) = X509Certificate::from_der(&ta_der).expect("parse ta fixture"); + let issuer_spki = &ta_x509.tbs_certificate.subject_pki; + + let crl = RpkixCrl { + raw_der: Vec::new(), + version: 2, + issuer_dn: "CN=issuer".to_string(), + signature_algorithm_oid: "1.2.840.113549.1.1.11".to_string(), + this_update: Asn1TimeUtc { + utc: now - time::Duration::hours(1), + encoding: Asn1TimeEncoding::UtcTime, + }, + next_update: Asn1TimeUtc { + utc: now + time::Duration::hours(1), + encoding: Asn1TimeEncoding::UtcTime, + }, + revoked_certs: Vec::new(), + extensions: CrlExtensions { + authority_key_identifier: vec![1], + crl_number: BigUnsigned { bytes_be: vec![1] }, + }, + }; + + let dir = tempfile::tempdir().expect("tempdir"); + let cache = std::sync::Arc::new(CryptoSigCache::load_or_rebuild( + dir.path().join("work-db.crypto-sig-cache"), + true, + )); + cache.insert_positive(compute_cache_key( + CryptoSigVerifyPoint::EeCertFast, + b"tampered-tbs-bytes", + b"tampered-signature-bytes", + issuer_spki.raw, + )); + crate::crypto_sig_cache::install_global(std::sync::Arc::clone(&cache)); + + // EE serial is 1 (dummy_cert); revoke it. The revocation check runs before the + // signature step in this code path, so the result must be EeRevoked — never Ok — + // even though a positive signature conclusion is cached. + let revoked_serials: HashSet> = HashSet::from([vec![1u8]]); + let err = validate_signed_object_ee_cert_path_fast( + &ee, + &issuer, + issuer_spki, + &crl, + &revoked_serials, + Some("rsync://example.test/issuer.cer"), + Some("rsync://example.test/issuer.crl"), + now, + ) + .unwrap_err(); + assert!( + matches!(err, CertPathError::EeRevoked), + "cached signature conclusion must not bypass revocation: {err}" + ); + + // Not revoked: the cached positive conclusion lets validation pass even though the + // tampered bytes could never verify — proving the crypto step was skipped. + let not_revoked: HashSet> = HashSet::new(); + validate_signed_object_ee_cert_path_fast( + &ee, + &issuer, + issuer_spki, + &crl, + ¬_revoked, + Some("rsync://example.test/issuer.cer"), + Some("rsync://example.test/issuer.crl"), + now, + ) + .expect("cache hit skips the impossible verification"); + + let summary = cache.summary(); + let stats = summary + .per_point + .get("ee_cert_fast") + .expect("ee_cert_fast stats"); + assert_eq!(stats.verify_skipped, 1); + assert_eq!( + stats.verify_executed, 0, + "real verification must never run on cache hits" + ); + crate::crypto_sig_cache::clear_global(); + } } diff --git a/src/verification_only.rs b/src/verification_only.rs index 9cc609e..fe4f2b1 100644 --- a/src/verification_only.rs +++ b/src/verification_only.rs @@ -21,11 +21,13 @@ pub struct ValidationCacheContract { pub roa: bool, pub child_certificate: bool, pub transport_prefetch: bool, + #[serde(default)] + pub crypto_signature: bool, } impl ValidationCacheContract { pub fn any_validation_cache_enabled(&self) -> bool { - self.publication_point || self.roa || self.child_certificate + self.publication_point || self.roa || self.child_certificate || self.crypto_signature } } @@ -358,7 +360,7 @@ pub fn prepare_verification( contract.require_current_binary()?; if !contract.cache.any_validation_cache_enabled() { return Err( - "source run did not enable PP, ROA, or child-certificate validation cache".to_string(), + "source run did not enable PP, ROA, child-certificate, or crypto signature validation cache".to_string(), ); } if !artifacts.source_work_db.is_dir() { @@ -645,6 +647,7 @@ mod tests { roa: true, child_certificate: true, transport_prefetch: false, + crypto_signature: false, }, } } @@ -751,6 +754,7 @@ mod tests { roa: false, child_certificate: false, transport_prefetch: true, + crypto_signature: false, }, ) .expect("contract"); @@ -802,6 +806,7 @@ mod tests { roa: false, child_certificate: false, transport_prefetch: true, + crypto_signature: false, }; assert!(!cache.any_validation_cache_enabled()); }