Skip to content

Commit e2aa333

Browse files
committed
feat(gpu): refresh OCSP cache before expiry
1 parent b147e09 commit e2aa333

5 files changed

Lines changed: 96 additions & 19 deletions

File tree

dstack/gpu-attest-proxy/README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,16 @@ response is cached no later than its signed `nextUpdate` and no longer than
4747
hour from `thisUpdate`, matching the pinned NVIDIA SDK. RIM documents default
4848
to a 30-day TTL.
4949

50+
When an OCSP response has less than `--ocsp-refresh-before` validity remaining
51+
(five minutes by default), the next request refreshes it synchronously from
52+
NVIDIA. Concurrent refreshes are coalesced. If the refresh fails, the proxy
53+
continues serving the old response only until its existing signed expiry; it
54+
never extends or serves an expired response.
55+
5056
Expired entries are never served. Therefore a warm cache removes the NVIDIA
5157
service from the boot path only for the signed validity period; it does not
5258
turn revocation checking into an indefinite fail-open. Response headers expose
53-
`X-Dstack-Cache: HIT|MISS`, `Age`, and `X-Dstack-Cache-Expires` for operations.
59+
`X-Dstack-Cache: HIT|MISS|REFRESH`, `Age`, and `X-Dstack-Cache-Expires` for operations.
5460
Each cache kind is capped at 10,000 entries by default; the oldest entry is
5561
evicted when the limit is reached. Use `--max-cache-entries-per-kind` to tune
5662
the bound for a deployment.

dstack/gpu-attest-proxy/src/lib.rs

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ pub struct ProxyConfig {
4747
pub connect_timeout: Duration,
4848
pub ocsp_max_ttl: Duration,
4949
pub ocsp_default_ttl: Duration,
50+
pub ocsp_refresh_before: Duration,
5051
pub rim_ttl: Duration,
5152
pub max_cache_entries_per_kind: usize,
5253
}
@@ -60,6 +61,7 @@ impl ProxyConfig {
6061
("connect timeout", self.connect_timeout),
6162
("OCSP maximum TTL", self.ocsp_max_ttl),
6263
("OCSP default TTL", self.ocsp_default_ttl),
64+
("OCSP refresh window", self.ocsp_refresh_before),
6365
("RIM TTL", self.rim_ttl),
6466
] {
6567
if value.is_zero() {
@@ -205,22 +207,37 @@ impl Proxy {
205207
}
206208
};
207209
if let Some(entry) = self.cache_get("ocsp", &key).await {
208-
return cached_response(entry, "HIT");
210+
if !self.ocsp_needs_refresh(&entry) {
211+
return cached_response(entry, "HIT");
212+
}
209213
}
210214

211215
let fill = self.fill_lock("ocsp", &key);
212216
let _fill = fill.lock().await;
213-
if let Some(entry) = self.cache_get("ocsp", &key).await {
214-
return cached_response(entry, "HIT");
217+
let cached = self.cache_get("ocsp", &key).await;
218+
if let Some(entry) = &cached {
219+
if !self.ocsp_needs_refresh(entry) {
220+
return cached_response(entry.clone(), "HIT");
221+
}
215222
}
216223

217224
let upstream = match self.fetch_ocsp(body).await {
218225
Ok(response) => response,
219226
Err(error) => {
220227
warn!(%error, "OCSP upstream request failed");
228+
if let Some(entry) = cached {
229+
return cached_response(entry, "HIT");
230+
}
221231
return text_response(StatusCode::BAD_GATEWAY, "OCSP upstream unavailable\n");
222232
}
223233
};
234+
if upstream.status != StatusCode::OK {
235+
if let Some(entry) = &cached {
236+
warn!(status = %upstream.status, "OCSP refresh returned an error; using valid cached response");
237+
return cached_response(entry.clone(), "HIT");
238+
}
239+
}
240+
let cache_state = if cached.is_some() { "REFRESH" } else { "MISS" };
224241
if upstream.status == StatusCode::OK {
225242
let now = now_epoch();
226243
match ocsp::response_cache_expiry(
@@ -250,7 +267,7 @@ impl Proxy {
250267
}
251268
}
252269
}
253-
upstream_response(upstream, "MISS")
270+
upstream_response(upstream, cache_state)
254271
}
255272

256273
async fn handle_rim(&self, rim_id: &str) -> Response<Full<Bytes>> {
@@ -345,6 +362,11 @@ impl Proxy {
345362
}
346363
}
347364

365+
fn ocsp_needs_refresh(&self, entry: &CacheEntry) -> bool {
366+
entry.expires_at.saturating_sub(now_epoch())
367+
<= self.config.ocsp_refresh_before.as_secs() as i64
368+
}
369+
348370
fn fill_lock(&self, namespace: &str, key: &str) -> Arc<Mutex<()>> {
349371
self.fills
350372
.entry(format!("{namespace}:{key}"))
@@ -498,6 +520,10 @@ mod tests {
498520
}
499521

500522
fn ocsp_response(now: i64) -> Bytes {
523+
ocsp_response_with_ttl(now, 3600)
524+
}
525+
526+
fn ocsp_response_with_ttl(now: i64, ttl: i64) -> Bytes {
501527
let format = |timestamp| {
502528
Utc.timestamp_opt(timestamp, 0)
503529
.unwrap()
@@ -509,7 +535,7 @@ mod tests {
509535
cert_id,
510536
der(0x80, &[]),
511537
der(0x18, format(now - 60).as_bytes()),
512-
der(0xa0, &der(0x18, format(now + 3600).as_bytes())),
538+
der(0xa0, &der(0x18, format(now + ttl).as_bytes())),
513539
]);
514540
let response_data = sequence(&[
515541
der(0x82, &[7; 20]),
@@ -590,6 +616,7 @@ mod tests {
590616
connect_timeout: Duration::from_secs(1),
591617
ocsp_max_ttl: Duration::from_secs(86_400),
592618
ocsp_default_ttl: Duration::from_secs(3600),
619+
ocsp_refresh_before: Duration::from_secs(300),
593620
rim_ttl: Duration::from_secs(86_400),
594621
max_cache_entries_per_kind: 10,
595622
};
@@ -616,6 +643,45 @@ mod tests {
616643
upstream.abort();
617644
}
618645

646+
#[tokio::test]
647+
async fn ocsp_cache_refreshes_before_expiry() {
648+
let now = now_epoch();
649+
let response = ocsp_response_with_ttl(now, 60);
650+
let (upstream_url, requests, upstream) = mock_upstream(response, OCSP_CONTENT_TYPE).await;
651+
let cache_dir = tempfile::tempdir().unwrap();
652+
let proxy = Proxy::new(ProxyConfig {
653+
listen_addr: "127.0.0.1:0".parse().unwrap(),
654+
cache_dir: cache_dir.path().to_path_buf(),
655+
ocsp_url: upstream_url,
656+
rim_url: Url::parse("https://rim.attestation.nvidia.com").unwrap(),
657+
service_key: None,
658+
request_timeout: Duration::from_secs(5),
659+
connect_timeout: Duration::from_secs(1),
660+
ocsp_max_ttl: Duration::from_secs(86_400),
661+
ocsp_default_ttl: Duration::from_secs(3600),
662+
ocsp_refresh_before: Duration::from_secs(300),
663+
rim_ttl: Duration::from_secs(86_400),
664+
max_cache_entries_per_kind: 10,
665+
})
666+
.await
667+
.unwrap();
668+
669+
let nonce = std::process::id() as u8;
670+
assert_eq!(
671+
proxy.handle_ocsp(ocsp_request(nonce)).await.headers()["x-dstack-cache"],
672+
"MISS"
673+
);
674+
assert_eq!(
675+
proxy
676+
.handle_ocsp(ocsp_request(nonce.wrapping_add(1)))
677+
.await
678+
.headers()["x-dstack-cache"],
679+
"REFRESH"
680+
);
681+
assert_eq!(requests.load(Ordering::SeqCst), 2);
682+
upstream.abort();
683+
}
684+
619685
#[tokio::test]
620686
async fn rim_cache_uses_versioned_id() {
621687
let response = Bytes::from_static(br#"{"id":"NV_GPU_DRIVER_GH100_580.1","rim":"test"}"#);
@@ -632,6 +698,7 @@ mod tests {
632698
connect_timeout: Duration::from_secs(1),
633699
ocsp_max_ttl: Duration::from_secs(86_400),
634700
ocsp_default_ttl: Duration::from_secs(3600),
701+
ocsp_refresh_before: Duration::from_secs(300),
635702
rim_ttl: Duration::from_secs(86_400),
636703
max_cache_entries_per_kind: 10,
637704
})

dstack/gpu-attest-proxy/src/main.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ struct Args {
5959
#[arg(long, env = "GPU_ATTEST_PROXY_OCSP_DEFAULT_TTL", default_value = "1h", value_parser = parse_duration)]
6060
ocsp_default_ttl: Duration,
6161

62+
/// Refresh an OCSP response when less than this much validity remains.
63+
#[arg(long, env = "GPU_ATTEST_PROXY_OCSP_REFRESH_BEFORE", default_value = "5m", value_parser = parse_duration)]
64+
ocsp_refresh_before: Duration,
65+
6266
/// Cache lifetime for version-addressed RIM documents.
6367
#[arg(long, env = "GPU_ATTEST_PROXY_RIM_TTL", default_value = "30d", value_parser = parse_duration)]
6468
rim_ttl: Duration,
@@ -94,6 +98,7 @@ async fn main() -> Result<()> {
9498
connect_timeout: args.connect_timeout,
9599
ocsp_max_ttl: args.ocsp_max_ttl,
96100
ocsp_default_ttl: args.ocsp_default_ttl,
101+
ocsp_refresh_before: args.ocsp_refresh_before,
97102
rim_ttl: args.rim_ttl,
98103
max_cache_entries_per_kind: args.max_cache_entries_per_kind,
99104
})

dstack/gpu-attest-proxy/src/ocsp.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@ pub(crate) fn response_cache_expiry(
5959
let max_ttl = i64::try_from(max_ttl_seconds).context("maximum OCSP TTL is too large")?;
6060
let mut expires_at = now.checked_add(max_ttl).context("OCSP TTL overflow")?;
6161
for item in validity {
62-
// Do not cache a response which claims to have been produced far in
63-
// the future. The guest performs the authoritative freshness check.
64-
if item.this_update > now.saturating_add(300) {
62+
// Do not cache a response which claims to have been produced in the
63+
// future. The guest performs the authoritative freshness check.
64+
if item.this_update > now {
6565
bail!("OCSP response thisUpdate is in the future");
6666
}
6767
let signed_expiry = item

os/yocto/layers/meta-nvidia/recipes-graphics/nvattest/files/0001-validate-ocsp-response-freshness.patch

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,44 +9,43 @@ nonce prevents replay. Trust Outpost-compatible caches deliberately return a
99
response with an older nonce, so the verifier must independently reject stale
1010
signed responses.
1111

12-
Use OpenSSL's OCSP_check_validity with five minutes of clock skew. Preserve the
13-
SDK's existing one-hour lifetime for responses without nextUpdate and enforce
14-
that synthesized deadline as well.
12+
Use OpenSSL's OCSP_check_validity without accepting expired responses through
13+
a clock-skew grace period. Preserve the SDK's existing one-hour lifetime for
14+
responses without nextUpdate and enforce that synthesized deadline as well.
1515

1616
Upstream-Status: Pending
1717

1818
---
19-
nv-attestation-sdk-cpp/src/nv_ocsp.cpp | 18 ++++++++++++++++++
20-
1 file changed, 18 insertions(+)
19+
nv-attestation-sdk-cpp/src/nv_ocsp.cpp | 17 +++++++++++++++++
20+
1 file changed, 17 insertions(+)
2121
diff --git a/nv-attestation-sdk-cpp/src/nv_ocsp.cpp b/nv-attestation-sdk-cpp/src/nv_ocsp.cpp
2222
index 8054971..a4a29c7 100644
2323
--- a/nv-attestation-sdk-cpp/src/nv_ocsp.cpp
2424
+++ b/nv-attestation-sdk-cpp/src/nv_ocsp.cpp
25-
@@ -291,6 +291,15 @@ Error NvHttpOcspClient::get_ocsp_status(
25+
@@ -291,6 +291,14 @@ Error NvHttpOcspClient::get_ocsp_status(
2626
return Error::OcspInvalidResponse;
2727
}
2828

2929
+ // OCSP_basic_verify checks signature and trust, but not response time.
3030
+ // Cached responses intentionally cannot satisfy the request nonce, so
3131
+ // the signed validity window is the remaining replay boundary.
32-
+ constexpr long OCSP_VALIDITY_SKEW_SECONDS = 300;
33-
+ if (OCSP_check_validity(thisupd, nextupd, OCSP_VALIDITY_SKEW_SECONDS, -1) != 1) {
32+
+ if (OCSP_check_validity(thisupd, nextupd, 0, -1) != 1) {
3433
+ LOG_ERROR("OCSP response is outside its validity window: " << get_openssl_error());
3534
+ return Error::OcspInvalidResponse;
3635
+ }
3736
+
3837
// note: timegm only works on linux.
3938
struct tm this_update_tm{};
4039
if (ASN1_TIME_to_tm((const ASN1_TIME *)thisupd, &this_update_tm) != 1) {
41-
@@ -318,6 +327,15 @@ Error NvHttpOcspClient::get_ocsp_status(
40+
@@ -318,6 +326,15 @@ Error NvHttpOcspClient::get_ocsp_status(
4241
LOG_DEBUG("ASN1_TIME_to_tm successful for next update time: " << next_update_time);
4342
out_ocsp_response.nextupd = next_update_time;
4443
}
4544
+ // When nextUpdate is absent OCSP_check_validity has no upper bound to
4645
+ // evaluate. The SDK already synthesizes one hour from thisUpdate;
4746
+ // enforce it before exposing a successful claim.
4847
+ time_t now = time(nullptr);
49-
+ if (out_ocsp_response.nextupd < now - OCSP_VALIDITY_SKEW_SECONDS) {
48+
+ if (out_ocsp_response.nextupd < now) {
5049
+ LOG_ERROR("OCSP response has expired (nextUpdate: "
5150
+ << out_ocsp_response.nextupd << ", now: " << now << ")");
5251
+ return Error::OcspInvalidResponse;

0 commit comments

Comments
 (0)