Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- http-client: a caller can bound the response body (`http_request_bounded`, `PrpcClient::with_max_response_bytes`). Nothing is bounded by default — `dstack vmm logs --lines 100000` is a legitimate multi-megabyte fetch — but every client that talks to a guest agent opts in, in the gateway and in the VMM, because a CVM is untrusted and one of them polls on a timer against the whole fleet

### Fixed
- verifier/kms/gateway: issuer certificates and CRLs named by a GCP TPM AK certificate are fetched only from an allowlist of hosts, including across redirects. Those URLs come from a certificate checked only after the fetch, so an unauthenticated `/verify` caller could make the verifier issue requests to any host, including internal ones. The default allows `privateca-content-*.storage.googleapis.com`, where Google's Private CA publishes them; `attestation.allowed_collateral_hosts` replaces it, and `*` matches within one DNS label
- sdk: Python and JavaScript blockchain adapters now reject TLS-key responses. The deprecated conversion path read fixed PKCS#8 framing as private-key bytes, causing different TLS keys to derive the same Ethereum and Solana wallets. Use `get_key()` / `getKey()` for wallet keys.
- data disks: discard now propagates through ZFS or ext4, dm-crypt, virtio-blk, and QEMU so encrypted qcow2 images release deleted blocks instead of growing with lifetime writes. Discard defaults on and can be disabled with `storage_discard: false` when allocation-pattern leakage is unacceptable; upgrading an existing ZFS pool also starts a one-time trim for historical free space
- certbot: a certificate covering both a name and its wildcard (`example.com` and `*.example.com`) could never be issued over dns-01. The two authorizations are answered under one `_acme-challenge.example.com`, each with its own TXT value, and the publish step cleared every TXT record at that name before writing its own -- so the second authorization deleted the record answering the first, and the order failed with `Correct value not found for DNS challenge`. Clearing leftovers from an aborted run is now done once per challenge name per issuance, and the records for one name accumulate instead of replacing each other; cleanup afterwards is unchanged, deleting each record this run created by id
Expand Down
1 change: 1 addition & 0 deletions dstack/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion dstack/crates/mock-attestation/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,10 @@ mod tests {
let task = tokio::spawn(serve_listener(listener, state.clone()));
let quote = state.tpm.attest(&[0x42; 32]).unwrap();
let root = state.tpm.root_ca_pem();
let collateral = tpm_qvl::get_collateral(&quote, &root).await.unwrap();
let hosts = tpm_qvl::AllowedHosts::new([addr.ip().to_string()]);
let collateral = tpm_qvl::get_collateral(&quote, &root, &hosts)
.await
.unwrap();
tpm_qvl::QuoteVerifier::new(state.tpm.root_ca_pem())
.verify(&quote, &collateral)
.unwrap();
Expand Down
144 changes: 138 additions & 6 deletions dstack/crates/pki-fetch/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@
//! whoever supplies the certificate chooses them. The endpoints are untrusted
//! transport: everything they serve still has to chain to a pinned root CA.
//! What they must not decide is how much work one verification does, so a
//! [`Fetcher`] spends a fixed budget of time, requests and bytes.
//! [`Fetcher`] spends a fixed budget of time, requests and bytes, nor where the
//! verifier connects to, so it only contacts [`AllowedHosts`].

use std::time::{Duration, Instant};

use anyhow::{bail, ensure, Context, Result};
use reqwest::{redirect, Url};
use tracing::{debug, warn};
use x509_parser::{
extensions::{DistributionPointName, GeneralName, ParsedExtension},
Expand All @@ -24,21 +26,92 @@ const TOTAL_TIMEOUT: Duration = Duration::from_secs(60);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_REQUESTS: usize = 16;
const MAX_BYTES: usize = 4 * 1024 * 1024;
const MAX_REDIRECTS: usize = 5;

/// Where the vendors publish the collateral their certificates point to.
pub const DEFAULT_ALLOWED_HOSTS: &[&str] = &[
// GCP vTPM: Google Private CA serves the AK chain and its CRLs.
"privateca-content-*.storage.googleapis.com",
// AWS Nitro Enclaves and NitroTPM CRLs.
"aws-nitro-enclaves-crl.s3.amazonaws.com",
"crl-*-aws-nitro-enclaves.s3.*.amazonaws.com",
];

/// Host patterns collateral may be fetched from. A `*` matches one or more
/// characters within a single DNS label.
#[derive(Debug, Clone)]
pub struct AllowedHosts(Vec<String>);

impl AllowedHosts {
pub fn new(patterns: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
Self(
patterns
.into_iter()
.map(|p| p.as_ref().to_ascii_lowercase())
.collect(),
)
}

fn check(&self, url: &Url) -> Result<()> {
ensure!(
matches!(url.scheme(), "http" | "https"),
"unsupported URL scheme in {url}"
);
let host = url.host_str().context("URL has no host")?;
ensure!(
self.0.iter().any(|pattern| host_matches(pattern, host)),
"collateral host {host} is not allowed"
);
Ok(())
}
}

impl Default for AllowedHosts {
fn default() -> Self {
Self::new(DEFAULT_ALLOWED_HOSTS)
}
}

fn host_matches(pattern: &str, host: &str) -> bool {
match pattern.split_once('*') {
None => pattern == host,
Some((prefix, rest)) => {
let Some(host) = host.strip_prefix(prefix) else {
return false;
};
let label_len = host.find('.').unwrap_or(host.len());
(1..=label_len).any(|n| host_matches(rest, &host[n..]))
}
}
}

/// Downloads collateral for one verification within a fixed budget.
pub struct Fetcher {
client: reqwest::Client,
allowed_hosts: AllowedHosts,
deadline: Instant,
requests_left: usize,
bytes_left: usize,
}

impl Fetcher {
pub fn new() -> Result<Self> {
pub fn new(allowed_hosts: &AllowedHosts) -> Result<Self> {
let redirect_hosts = allowed_hosts.clone();
let redirects = redirect::Policy::custom(move |attempt| {
if attempt.previous().len() > MAX_REDIRECTS {
attempt.error("too many redirects")
} else if let Err(e) = redirect_hosts.check(attempt.url()) {
attempt.error(e)
} else {
attempt.follow()
}
});
Ok(Self {
client: reqwest::Client::builder()
.redirect(redirects)
.build()
.context("failed to build HTTP client")?,
allowed_hosts: allowed_hosts.clone(),
deadline: Instant::now() + TOTAL_TIMEOUT,
requests_left: MAX_REQUESTS,
bytes_left: MAX_BYTES,
Expand All @@ -47,6 +120,8 @@ impl Fetcher {

/// GET `url`, charging the request, its time and its body to the budget.
pub async fn download(&mut self, url: &str) -> Result<Vec<u8>> {
let url = Url::parse(url).with_context(|| format!("invalid URL {url}"))?;
self.allowed_hosts.check(&url)?;
ensure!(
self.requests_left > 0,
"collateral fetch exceeded {MAX_REQUESTS} requests"
Expand All @@ -60,7 +135,7 @@ impl Fetcher {
debug!("downloading {url}");
let mut response = self
.client
.get(url)
.get(url.clone())
.timeout(remaining.min(REQUEST_TIMEOUT))
.send()
.await
Expand Down Expand Up @@ -145,6 +220,10 @@ mod tests {
net::TcpListener,
};

fn local() -> AllowedHosts {
AllowedHosts::new(["127.0.0.1"])
}

#[derive(Clone, Copy)]
enum Reply {
NotFound,
Expand Down Expand Up @@ -189,7 +268,7 @@ mod tests {
#[tokio::test]
async fn request_count_is_bounded() {
let url = serve(Reply::NotFound).await;
let mut fetcher = Fetcher::new().unwrap();
let mut fetcher = Fetcher::new(&local()).unwrap();
for _ in 0..MAX_REQUESTS {
fetcher.download(&url).await.unwrap_err();
}
Expand All @@ -200,7 +279,11 @@ mod tests {
#[tokio::test]
async fn downloaded_bytes_are_bounded() {
let url = serve(Reply::Endless).await;
let err = Fetcher::new().unwrap().download(&url).await.unwrap_err();
let err = Fetcher::new(&local())
.unwrap()
.download(&url)
.await
.unwrap_err();
assert!(format!("{err:#}").contains("bytes"), "{err:#}");
}

Expand All @@ -209,10 +292,59 @@ mod tests {
let url = serve(Reply::Drip).await;
let mut fetcher = Fetcher {
deadline: Instant::now() + Duration::from_secs(1),
..Fetcher::new().unwrap()
..Fetcher::new(&local()).unwrap()
};
let started = Instant::now();
fetcher.download(&url).await.unwrap_err();
assert!(started.elapsed() < Duration::from_secs(2));
}

#[tokio::test]
async fn only_allowed_hosts_are_contacted() {
let url = serve(Reply::NotFound).await;
let mut fetcher = Fetcher::new(&AllowedHosts::default()).unwrap();
let err = fetcher.download(&url).await.unwrap_err();
assert!(format!("{err:#}").contains("not allowed"), "{err:#}");
assert_eq!(fetcher.requests_left, MAX_REQUESTS);
}

#[test]
fn host_patterns_match_within_one_label() {
let hosts = AllowedHosts::default();
for (url, allowed) in [
(
"http://privateca-content-62d7.storage.googleapis.com/a/ca.crt",
true,
),
(
"http://crl-us-east-1-aws-nitro-enclaves.s3.us-east-1.amazonaws.com/c",
true,
),
(
"http://aws-nitro-enclaves-crl.s3.amazonaws.com/crl/x.crl",
true,
),
("http://privateca-content-.storage.googleapis.com/", false),
(
"http://privateca-content-x.evil.com.storage.googleapis.com/",
false,
),
(
"http://privateca-content-x.storage.googleapis.com.evil.com/",
false,
),
(
"http://evil.com/privateca-content-x.storage.googleapis.com",
false,
),
("http://169.254.169.254/latest/meta-data/", false),
("file:///etc/passwd", false),
] {
assert_eq!(
hosts.check(&Url::parse(url).unwrap()).is_ok(),
allowed,
"{url}"
);
}
}
}
1 change: 1 addition & 0 deletions dstack/dstack-attest/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ nsm-qvl.workspace = true
tpm-qvl.workspace = true
tpm-types.workspace = true
tracing.workspace = true
url.workspace = true
x509-parser.workspace = true
insta.workspace = true
errify.workspace = true
Expand Down
19 changes: 17 additions & 2 deletions dstack/dstack-attest/src/attestation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ pub struct AttestationVerifierConfig {
pub urls: CollateralUrls,
#[serde(default)]
pub root_ca: RootCaPaths,
/// Hosts the GCP TPM AK issuer certificates and CRLs may be fetched from;
/// `*` matches within one DNS label. Unset keeps
/// the vendor hosts in `pki_fetch::DEFAULT_ALLOWED_HOSTS`.
#[serde(default)]
pub allowed_collateral_hosts: Option<Vec<String>>,
}

pub struct AttestationVerifier {
Expand All @@ -63,6 +68,7 @@ pub struct AttestationVerifier {
aws_nitro_tpm: nsm_qvl::QuoteVerifier,
sev_snp: sev_snp_qvl::QuoteVerifier,
amd_kds: AmdKdsClient,
allowed_collateral_hosts: tpm_qvl::AllowedHosts,
}

impl AttestationVerifier {
Expand Down Expand Up @@ -150,6 +156,11 @@ impl AttestationVerifier {
aws_nitro_tpm: nsm(aws_nitro_tpm.as_deref(), "AWS NitroTPM")?,
sev_snp,
amd_kds: AmdKdsClient::with_base_url(amd_kds)?,
allowed_collateral_hosts: config
.allowed_collateral_hosts
.as_ref()
.map(tpm_qvl::AllowedHosts::new)
.unwrap_or_default(),
})
}

Expand All @@ -175,6 +186,7 @@ impl AttestationVerifier {
.filter(|url| !url.trim().is_empty())
.unwrap_or(sev_snp_qvl::AMD_KDS_DEFAULT_BASE_URL),
)?,
allowed_collateral_hosts: Default::default(),
})
}

Expand Down Expand Up @@ -1087,7 +1099,7 @@ impl AttestationV1 {
.await?;
let tpm_report = verifier
.gcp_tpm
.fetch_and_verify(tpm_quote)
.fetch_and_verify(tpm_quote, &verifier.allowed_collateral_hosts)
.await
.context("failed to verify TPM quote")?;
let qualifying_data = sha256(quote);
Expand Down Expand Up @@ -2504,7 +2516,10 @@ impl Attestation {
quote: &TpmQuote,
qualifying_data: &[u8],
) -> Result<TpmVerifiedReport> {
let report = verifier.gcp_tpm.fetch_and_verify(quote).await?;
let report = verifier
.gcp_tpm
.fetch_and_verify(quote, &verifier.allowed_collateral_hosts)
.await?;
let pcr_ind = self
.quote
.variant()
Expand Down
9 changes: 9 additions & 0 deletions dstack/dstack-attest/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ pub fn default_verifier(
insecure_allow_external_trust_anchors: true,
urls: collateral_urls.clone(),
root_ca,
// The simulator's collateral service also serves the certificates and
// CRLs its development roots name.
allowed_collateral_hosts: Some(
[&collateral_urls.pccs, &collateral_urls.amd_kds]
.into_iter()
.flatten()
.filter_map(|url| Some(url::Url::parse(url).ok()?.host_str()?.to_owned()))
.collect(),
),
})
}

Expand Down
1 change: 1 addition & 0 deletions dstack/dstack-attest/src/trust_anchors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ mod tests {
insecure_allow_external_trust_anchors: true,
urls: Default::default(),
root_ca,
allowed_collateral_hosts: None,
})
}

Expand Down
11 changes: 10 additions & 1 deletion dstack/dstack-util/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,11 @@ struct TpmVerifyArgs {
/// path to TPM quote JSON file
#[arg(short, long)]
quote: PathBuf,

/// host the AK issuer certificates and CRLs may be fetched from (`*`
/// matches within one DNS label); repeatable, replaces the default list
#[arg(long = "allowed-collateral-host")]
allowed_collateral_hosts: Vec<String>,
}

#[derive(Parser)]
Expand Down Expand Up @@ -1524,7 +1529,11 @@ async fn cmd_tpm_verify(args: TpmVerifyArgs) -> Result<()> {

// Step 1: Get collateral (certificates + CRLs)
println!("[Step 1] Fetching quote collateral (certificates + CRLs)...");
let collateral = tpm_qvl::get_collateral(&tpm_quote, &root_ca_pem)
let allowed_hosts = match args.allowed_collateral_hosts.as_slice() {
[] => Default::default(),
hosts => tpm_qvl::AllowedHosts::new(hosts),
};
let collateral = tpm_qvl::get_collateral(&tpm_quote, &root_ca_pem, &allowed_hosts)
.await
.context("failed to get TPM collateral")?;
let crl_count = collateral.crls.len()
Expand Down
3 changes: 3 additions & 0 deletions dstack/gateway/gateway.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ rpc_domain = ""
# production should retain vendor roots.
[core.attestation]
insecure_allow_external_trust_anchors = false
# Hosts the GCP TPM AK issuer certificates and CRLs may be fetched from; `*`
# matches within one DNS label. Unset keeps Google's Private CA hosts.
# allowed_collateral_hosts = ["privateca-content-*.storage.googleapis.com"]

[core.attestation.urls]
# pccs = "https://pccs.phala.network"
Expand Down
3 changes: 3 additions & 0 deletions dstack/kms/kms.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ nitro_enclave_key_release = false
# Optional attestation trust-anchor files. Omitted entries use vendor roots.
[core.attestation]
insecure_allow_external_trust_anchors = false
# Hosts the GCP TPM AK issuer certificates and CRLs may be fetched from; `*`
# matches within one DNS label. Unset keeps Google's Private CA hosts.
# allowed_collateral_hosts = ["privateca-content-*.storage.googleapis.com"]

[core.attestation.urls]
# pccs = "https://pccs.phala.network"
Expand Down
Loading
Loading