20260722 新增密码学签名验证缓存(#126):5个验签收口点接入独立缓存,observe-only/enable双开关默认关闭,消融脚本新增crypto-sig等case set,verification contract补crypto_signature字段

This commit is contained in:
yuyr 2026-07-22 12:14:13 +08:00
parent f86fbd4939
commit 3d772f748c
11 changed files with 1313 additions and 22 deletions

View File

@ -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")

View File

@ -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` 不参与比较。

View File

@ -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<crate::crypto_sig_cache::CryptoSigCacheSummary>,
enable_transport_request_prefetch: bool,
report_build_ms: u64,
report_write_ms: Option<u64>,
@ -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<PathBuf>,
pub vrps_csv_out_path: Option<PathBuf>,
@ -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 <path> Write CCR DER ContentInfo to this path (optional)
@ -315,6 +324,8 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
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<PathBuf> = None;
@ -579,6 +590,12 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
"--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<CliArgs, String> {
|| 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<CliArgs, String> {
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,

View File

@ -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,

876
src/crypto_sig_cache.rs Normal file
View File

@ -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 (`<work-db-name>.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<String, CryptoSigVerifyPointStats>,
}
#[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<Mutex<HashMap<[u8; 32], CacheValue>>>,
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<E>(
&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` (`<work-db-name>.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<HashMap<[u8; 32], CacheValue>> {
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<HashMap<[u8; 32], CacheValue>>],
) -> 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<Option<Arc<CryptoSigCache>>> = 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<CryptoSigCache>) {
*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<Arc<CryptoSigCache>> {
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<E>(
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<u64, String> = (|| {
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());
}
}

View File

@ -285,8 +285,16 @@ impl RpkixCrl {
if !rem.is_empty() {
return Err(CrlVerifyError::CrlTrailingBytes(rem.len()));
}
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.

View File

@ -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(

View File

@ -1,5 +1,6 @@
pub mod ccr;
pub mod cir;
pub mod crypto_sig_cache;
pub mod data_model;
#[cfg(feature = "full")]

View File

@ -485,9 +485,17 @@ fn verify_child_signature(
child: &X509Certificate<'_>,
issuer_spki: &SubjectPublicKeyInfo<'_>,
) -> Result<(), CaPathError> {
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};

View File

@ -385,8 +385,16 @@ fn verify_ee_cert_signature(
ee: &X509Certificate<'_>,
issuer_spki: &SubjectPublicKeyInfo<'_>,
) -> Result<(), CertPathError> {
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> {
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<Vec<u8>> = 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<Vec<u8>> = HashSet::new();
validate_signed_object_ee_cert_path_fast(
&ee,
&issuer,
issuer_spki,
&crl,
&not_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();
}
}

View File

@ -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());
}