Skip to content

Commit 401c849

Browse files
committed
feat(gpu-attest-proxy): add caching OCSP/RIM collateral proxy
A PCCS-style cache for NVIDIA GPU attestation collateral, run on the host or at site level: - POST /ocsp relays requests byte-for-byte and caches successful responses keyed by CertID until their nextUpdate, so reboots and fleets stop depending on the responder at every boot. - GET /v1/rim/{id} caches RIM documents with a TTL and serves stale within a bounded window when the upstream is unreachable. - A background refresher renews entries before expiry, letting a warm cache ride through upstream outages with close to a full validity window. - The proxy holds no secrets: guests still verify OCSP signatures, validity windows and RIM signatures themselves. The minimal DER walker extracts CertIDs from requests (cache key) and thisUpdate/nextUpdate from responses (expiry). Includes unit tests and a documented default configuration.
1 parent b73ce76 commit 401c849

9 files changed

Lines changed: 1121 additions & 0 deletions

File tree

dstack/Cargo.lock

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dstack/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ members = [
5959
"dstack-mr",
6060
"dstack-mr/cli",
6161
"verifier",
62+
"gpu-attest-proxy",
6263
"size-parser",
6364
"port-forward",
6465
"crates/dstack-cli-core",

dstack/gpu-attest-proxy/Cargo.toml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# SPDX-FileCopyrightText: © 2026 Phala Network <dstack@phala.network>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
[package]
6+
name = "gpu-attest-proxy"
7+
version.workspace = true
8+
authors.workspace = true
9+
edition.workspace = true
10+
license.workspace = true
11+
12+
[dependencies]
13+
anyhow.workspace = true
14+
base64.workspace = true
15+
clap = { workspace = true, features = ["derive", "string"] }
16+
dashmap.workspace = true
17+
fs-err.workspace = true
18+
git-version.workspace = true
19+
hex = { workspace = true, features = ["alloc"] }
20+
load_config.workspace = true
21+
or-panic.workspace = true
22+
reqwest.workspace = true
23+
rocket = { workspace = true, features = ["json"] }
24+
serde = { workspace = true, features = ["derive"] }
25+
serde-duration.workspace = true
26+
serde_json.workspace = true
27+
sha2.workspace = true
28+
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] }
29+
tracing.workspace = true
30+
tracing-subscriber.workspace = true
31+
32+
[dev-dependencies]
33+
tempfile.workspace = true
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# SPDX-FileCopyrightText: © 2026 Phala Network <dstack@phala.network>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
address = "0.0.0.0"
6+
port = 8090
7+
log_level = "info"
8+
9+
# NVIDIA attestation collateral upstreams.
10+
upstream_ocsp_url = "https://ocsp.ndis.nvidia.com"
11+
upstream_rim_url = "https://rim.attestation.nvidia.com"
12+
13+
# Persistent cache location: one JSON file per entry under ocsp/ and rim/.
14+
cache_dir = "/var/lib/dstack/gpu-attest-proxy"
15+
16+
# RIM documents are re-fetched after rim_ttl; when the upstream is
17+
# unreachable a stale document keeps being served for up to rim_max_stale.
18+
rim_ttl = "24h"
19+
rim_max_stale = "7d"
20+
21+
# OCSP responses are cached until their nextUpdate, bounded by ocsp_max_ttl;
22+
# entries within refresh_margin of expiry are refreshed in the background so
23+
# a warm cache rides through upstream outages.
24+
ocsp_max_ttl = "7d"
25+
refresh_margin = "24h"
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// SPDX-FileCopyrightText: © 2026 Phala Network <dstack@phala.network>
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
//! File-backed collateral cache. Every entry is one JSON file under
6+
//! `<cache_dir>/<kind>/`, so a proxy restart keeps the cache warm and
7+
//! operators can inspect or evict entries with plain file tools.
8+
9+
use std::path::{Path, PathBuf};
10+
use std::time::{SystemTime, UNIX_EPOCH};
11+
12+
use anyhow::{Context, Result};
13+
use dashmap::DashMap;
14+
use serde::{Deserialize, Serialize};
15+
use sha2::{Digest, Sha256};
16+
17+
#[derive(Debug, Clone, Serialize, Deserialize)]
18+
pub struct CacheEntry {
19+
/// "ocsp" or "rim" — also the subdirectory the entry persists under.
20+
pub kind: String,
21+
/// Cache key: hex(CertID DER) for OCSP, the RIM id for RIM.
22+
pub key: String,
23+
pub content_type: String,
24+
#[serde(with = "base64serde")]
25+
pub body: Vec<u8>,
26+
/// Original upstream request body for OCSP entries, letting the
27+
/// background refresher re-run the exact query. RIM refreshes by key.
28+
#[serde(default, with = "base64serde_opt")]
29+
pub refresh_body: Option<Vec<u8>>,
30+
pub fetched_at: i64,
31+
pub expires_at: i64,
32+
}
33+
34+
impl CacheEntry {
35+
pub fn is_fresh(&self, now: i64) -> bool {
36+
now < self.expires_at
37+
}
38+
39+
pub fn is_usable_stale(&self, now: i64, max_stale_secs: u64) -> bool {
40+
now < self.expires_at.saturating_add(max_stale_secs as i64)
41+
}
42+
}
43+
44+
mod base64serde {
45+
use base64::{engine::general_purpose::STANDARD, Engine as _};
46+
use serde::{Deserialize, Deserializer, Serializer};
47+
48+
pub fn serialize<S: Serializer>(bytes: &Vec<u8>, s: S) -> Result<S::Ok, S::Error> {
49+
s.serialize_str(&STANDARD.encode(bytes))
50+
}
51+
52+
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
53+
let text = String::deserialize(d)?;
54+
STANDARD.decode(text).map_err(serde::de::Error::custom)
55+
}
56+
}
57+
58+
mod base64serde_opt {
59+
use base64::{engine::general_purpose::STANDARD, Engine as _};
60+
use serde::{Deserialize, Deserializer, Serializer};
61+
62+
pub fn serialize<S: Serializer>(bytes: &Option<Vec<u8>>, s: S) -> Result<S::Ok, S::Error> {
63+
match bytes {
64+
Some(bytes) => s.serialize_str(&STANDARD.encode(bytes)),
65+
None => s.serialize_none(),
66+
}
67+
}
68+
69+
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<u8>>, D::Error> {
70+
let text = Option::<String>::deserialize(d)?;
71+
text.map(|text| STANDARD.decode(text).map_err(serde::de::Error::custom))
72+
.transpose()
73+
}
74+
}
75+
76+
pub struct Cache {
77+
dir: PathBuf,
78+
entries: DashMap<String, CacheEntry>,
79+
}
80+
81+
fn now() -> i64 {
82+
SystemTime::now()
83+
.duration_since(UNIX_EPOCH)
84+
.map(|d| d.as_secs() as i64)
85+
.unwrap_or(0)
86+
}
87+
88+
impl Cache {
89+
/// Load every well-formed entry from disk; entries past `now +
90+
/// keep_stale_secs` are dropped instead.
91+
pub fn load(dir: impl Into<PathBuf>, keep_stale_secs: u64) -> Result<Self> {
92+
let dir = dir.into();
93+
let cache = Self {
94+
dir,
95+
entries: DashMap::new(),
96+
};
97+
let now = now();
98+
for kind in ["ocsp", "rim"] {
99+
let kind_dir = cache.dir.join(kind);
100+
let Ok(read_dir) = fs_err::read_dir(&kind_dir) else {
101+
continue;
102+
};
103+
for file in read_dir.flatten() {
104+
let path = file.path();
105+
if path.extension().and_then(|e| e.to_str()) != Some("json") {
106+
continue;
107+
}
108+
match Self::read_entry(&path) {
109+
Ok(entry) if entry.is_usable_stale(now, keep_stale_secs) => {
110+
cache.entries.insert(map_key(kind, &entry.key), entry);
111+
}
112+
Ok(_) => {
113+
fs_err::remove_file(&path).ok();
114+
}
115+
Err(err) => {
116+
tracing::warn!(
117+
"dropping unreadable cache entry {}: {err:#}",
118+
path.display()
119+
);
120+
}
121+
}
122+
}
123+
}
124+
Ok(cache)
125+
}
126+
127+
fn read_entry(path: &Path) -> Result<CacheEntry> {
128+
let json = fs_err::read(path)?;
129+
serde_json::from_slice(&json).context("invalid cache entry JSON")
130+
}
131+
132+
fn entry_path(&self, kind: &str, key: &str) -> PathBuf {
133+
let name = hex::encode(Sha256::digest(key.as_bytes()));
134+
self.dir.join(kind).join(format!("{name}.json"))
135+
}
136+
137+
pub fn get(&self, kind: &str, key: &str) -> Option<CacheEntry> {
138+
self.entries.get(&map_key(kind, key)).map(|e| e.clone())
139+
}
140+
141+
/// Insert or replace an entry, persisting it. The entry stays in memory
142+
/// even if the write fails — availability of the proxy matters more than
143+
/// durability of one entry.
144+
pub fn put(&self, entry: CacheEntry) {
145+
let path = self.entry_path(&entry.kind, &entry.key);
146+
if let Some(parent) = path.parent() {
147+
fs_err::create_dir_all(parent).ok();
148+
}
149+
match serde_json::to_vec(&entry) {
150+
Ok(json) => {
151+
if let Err(err) = fs_err::write(&path, json) {
152+
tracing::warn!("failed to persist cache entry {}: {err:#}", path.display());
153+
}
154+
}
155+
Err(err) => tracing::warn!("failed to serialize cache entry: {err:#}"),
156+
}
157+
self.entries.insert(map_key(&entry.kind, &entry.key), entry);
158+
}
159+
160+
/// Entries whose freshness ends within `margin_secs`, for the background
161+
/// refresher.
162+
pub fn expiring_soon(&self, margin_secs: u64) -> Vec<CacheEntry> {
163+
let deadline = now().saturating_add(margin_secs as i64);
164+
self.entries
165+
.iter()
166+
.filter(|entry| entry.expires_at < deadline)
167+
.map(|entry| entry.clone())
168+
.collect()
169+
}
170+
171+
pub fn stats(&self) -> (usize, usize) {
172+
let now = now();
173+
let fresh = self
174+
.entries
175+
.iter()
176+
.filter(|entry| entry.is_fresh(now))
177+
.count();
178+
(fresh, self.entries.len().saturating_sub(fresh))
179+
}
180+
}
181+
182+
fn map_key(kind: &str, key: &str) -> String {
183+
format!("{kind}:{key}")
184+
}
185+
186+
#[cfg(test)]
187+
mod tests {
188+
use super::*;
189+
190+
fn entry(kind: &str, key: &str, expires_at: i64) -> CacheEntry {
191+
CacheEntry {
192+
kind: kind.into(),
193+
key: key.into(),
194+
content_type: "application/ocsp-response".into(),
195+
body: b"body".to_vec(),
196+
refresh_body: Some(b"request".to_vec()),
197+
fetched_at: now(),
198+
expires_at,
199+
}
200+
}
201+
202+
#[test]
203+
fn entries_survive_a_reload() {
204+
let dir = tempfile::tempdir().unwrap();
205+
let cache = Cache::load(dir.path(), 0).unwrap();
206+
cache.put(entry("ocsp", "aabb", now() + 3600));
207+
let reloaded = Cache::load(dir.path(), 0).unwrap();
208+
let entry = reloaded.get("ocsp", "aabb").unwrap();
209+
assert_eq!(entry.body, b"body");
210+
assert_eq!(entry.refresh_body, Some(b"request".to_vec()));
211+
}
212+
213+
#[test]
214+
fn load_drops_entries_past_the_stale_window() {
215+
let dir = tempfile::tempdir().unwrap();
216+
let cache = Cache::load(dir.path(), 0).unwrap();
217+
cache.put(entry("rim", "RIM_OLD", now() - 7200));
218+
let reloaded = Cache::load(dir.path(), 3600).unwrap();
219+
assert!(reloaded.get("rim", "RIM_OLD").is_none());
220+
}
221+
}

0 commit comments

Comments
 (0)