Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
4 changes: 4 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,10 @@ curl http://<new-kms>:9203/finish

> **Admin authentication.** The KMS *admin* RPCs (for example `ClearImageCache`) are served on a dedicated `[core.admin]` listener behind the shared HTTP authenticator, just like the VMM and gateway: set `[core.admin] enabled = true` with an `auth_token` (or the `DSTACK_KMS_ADMIN_TOKEN` / `ADMIN_API_TOKEN` env vars), and clients send `Authorization: Bearer <token>` or `X-Admin-Token`. Enabled with neither `auth_token` nor `htpasswd_file` (and `insecure_no_auth = false`) fails closed — the KMS refuses to start. See [dstack-kms admin authentication](../dstack/kms/README.md#admin-api-authentication).
>
> **Source pinning.** Before sending anything secret, the new KMS requires the source's RPC certificate, at the TLS handshake, to be a `kms:rpc` leaf signed by the current root CA its own auth API reports (`rootCaPubkey` in `GET /`), so `source_url` must be `https://`. A source without the registered roots never sees the launch ticket or `source_token`; onboarding fails if the auth API reports no root CA key. Right after a root epoch is activated, a source still serving the RPC certificate of the previous one fails the handshake until it restarts; onboarding attempts do not trigger that restart, so restart the source. Upgrade the auth backend before the KMS: a custom webhook must report `rootCaPubkey`. A KMS with `auth_api.type = "dev"` registers no roots and pins nothing.
>
> **Launch ticket.** When the KMS contract requires one (`setRequireKmsLaunchTicket`), pass this instance's ticket as `launch_ticket` in the onboarding request; the source checks its hash against `kmsLaunchTickets()` before handing over the keys. Remove the ticket from the contract once the instance has joined. See [launch tickets](../dstack/kms/README.md#launch-tickets).
>
> **Key handover.** It is served as `Admin.GetKmsKey` on the `[core.admin]` listener, which needs `[core.admin.tls]` so it can verify the onboarding KMS's RA-TLS certificate. Pass the source admin URL (`https://`) and its token (`source_token`) in the onboarding request; the token is required unless the source enables the public handover. The legacy public `KMS.GetKmsKey` is off by default; set `core.onboard.public_key_handover = true` on the source only when an onboarding KMS cannot use the admin listener, such as a 0.5.x KMS or an onboarding request without `source_token`.

#### Rotating KMS Root Keys
Expand Down
2 changes: 2 additions & 0 deletions docs/security/security-best-practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ Because `launch_token_hash` is public, a guessable token can be recovered offlin

Also understand the protection boundary of this mechanism: the guest verifies the token before any keys are provisioned, which means the token must reach the guest through `user_config` — a channel the host can read. The requirement therefore stops parties who only know the public app-compose.json from launching the app, but once a host has hosted a deployment it learns the token and can later relaunch instances of that compose with substituted encrypted envs. Mitigations: generate a fresh token per deployment and remove stale compose hashes from the on-chain whitelist; if the token must stay secret from the host, use the app-layer `APP_LAUNCH_TOKEN` encrypted-env pattern above instead (its check necessarily runs after key provisioning).

A KMS-level launch ticket closes that gap for apps governed by `DstackApp`: the contract owner requires a ticket (`setRequireLaunchTicket`) and allows the hashes of the tickets they issue (`addLaunchTicket`), and the KMS refuses keys and certificates to a CVM without an allowed ticket. The check runs in the KMS, so it does not depend on the guest OS, and a ticket is revoked on chain without changing the compose hash. It is orthogonal to `requirements.launch_token_hash`. The host still holds the ticket, so issue one per deployment and remove it when the host stops serving the app. See [launch tickets](../../dstack/kms/README.md#launch-tickets) and [vmm-cli](../vmm-cli-user-guide.md#launch-tickets).

If you use dstack-vmm's built-in UI, the prelaunch script has already been automatically filled in for you:

![Prelaunch Script](../assets/prelaunch-script.png)
Expand Down
26 changes: 26 additions & 0 deletions docs/vmm-cli-user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,32 @@ Deploy your application with the compose file:
- `--kms-url`: KMS server URL
- `--gateway-url`: Gateway server URL
- `--stopped`: Create VM in stopped state (requires dstack-vmm >= 0.5.4)
- `--launch-ticket-file`: File holding the launch ticket the guest presents to the KMS (see below)

#### Launch Tickets

When the app's `DstackApp` contract requires a launch ticket, the KMS releases
keys only to a CVM presenting a ticket whose hash the contract allows. Create a
fresh ticket per deployment, allow its hash on chain, and deploy with it:

```bash
./vmm-cli.py launch-ticket new ./prod.ticket # writes the ticket (mode 0600), prints its hash
# addLaunchTicket(<hash>, "prod") on the DstackApp contract, then:
./vmm-cli.py deploy ... --launch-ticket-file ./prod.ticket
```

`launch-ticket hash FILE` prints the hash of an existing ticket, for
`addLaunchTicket` or, for a KMS onboarding ticket, `addKmsLaunchTicket`. A KMS
takes its ticket in the `Onboard` request rather than from
`--launch-ticket-file`, so deploy a KMS CVM without it. `update
--launch-ticket-file FILE` replaces the ticket of a VM and `update
--no-launch-ticket` removes it. The VMM keeps the ticket and writes it to the
guest's sys-config at every boot; it returns only its hash, which `info` shows.
The host holds the ticket, so remove that hash from the contract once the host
stops serving the app. Tickets need dstack-vmm, the KMS and the auth backend at
0.6.4 or later, and a guest OS at 0.6.4 or later or a 0.5.x release with the
launch ticket backport. See
[launch tickets](../dstack/kms/README.md#launch-tickets).

#### Port Mapping

Expand Down
34 changes: 24 additions & 10 deletions dstack/cert-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex};

use anyhow::{bail, Context, Result};
use dstack_kms_rpc::{kms_client::KmsClient, AppKeyResponse, GetAppKeyRequest, SignCertRequest};
use dstack_types::{AppKeys, KeyProvider};
use dstack_types::{AppKeys, KeyProvider, SysConfig};
use ra_rpc::client::{CertInfo, RaClient, RaClientConfig};
use ra_tls::{
attestation::{AppInfo, AttestationVerifier, VersionedAttestation},
Expand All @@ -20,6 +20,7 @@ pub enum CertRequestClient {
Kms {
client: KmsClient<RaClient>,
vm_config: String,
launch_ticket: String,
},
}

Expand Down Expand Up @@ -158,13 +159,18 @@ impl CertRequestClient {
.context("Failed to sign certificate")?;
Ok(vec![cert.pem(), ca.pem_cert.clone()])
}
CertRequestClient::Kms { client, vm_config } => {
CertRequestClient::Kms {
client,
vm_config,
launch_ticket,
} => {
let response = client
.sign_cert(SignCertRequest {
api_version: 2,
csr: csr.to_vec(),
signature: signature.to_vec(),
vm_config: vm_config.clone(),
launch_ticket: launch_ticket.clone(),
})
.await?;
Ok(response.certificate_chain)
Expand All @@ -178,7 +184,12 @@ impl CertRequestClient {
root_epoch: Option<u32>,
app_epoch: Option<u32>,
) -> Result<AppKeyResponse> {
let CertRequestClient::Kms { client, vm_config } = self else {
let CertRequestClient::Kms {
client,
vm_config,
launch_ticket,
} = self
else {
bail!("only KMS-provided keys have older root epochs");
};
let response = client
Expand All @@ -187,6 +198,7 @@ impl CertRequestClient {
vm_config: vm_config.clone(),
root_epoch,
app_epoch,
launch_ticket: launch_ticket.clone(),
})
.await?;
if root_epoch.is_some_and(|epoch| epoch != response.root_epoch) {
Expand All @@ -208,7 +220,7 @@ impl CertRequestClient {
pub async fn create(
keys: &AppKeys,
attestation_verifier: Arc<AttestationVerifier>,
vm_config: String,
sys_config: &SysConfig,
) -> Result<CertRequestClient> {
match &keys.key_provider {
KeyProvider::None { key }
Expand All @@ -231,7 +243,11 @@ impl CertRequestClient {
client_cert.key_pem,
attestation_verifier,
)?;
Ok(CertRequestClient::Kms { client, vm_config })
Ok(CertRequestClient::Kms {
client,
vm_config: sys_config.vm_config.clone(),
launch_ticket: sys_config.launch_ticket.clone().unwrap_or_default(),
})
}
}
}
Expand Down Expand Up @@ -643,9 +659,7 @@ mod tests {
};
let request = || GetAppKeyRequest {
api_version: 1,
vm_config: String::new(),
root_epoch: None,
app_epoch: None,
..Default::default()
};
let result = request_app_keys(
url.clone(),
Expand All @@ -665,9 +679,8 @@ mod tests {
for requested_root_epoch in [None, Some(0), Some(1)] {
let request = || GetAppKeyRequest {
api_version: 1,
vm_config: String::new(),
root_epoch: requested_root_epoch,
app_epoch: None,
..Default::default()
};
let selected = AppKeyResponse {
root_epoch: requested_root_epoch.unwrap_or(1),
Expand Down Expand Up @@ -735,6 +748,7 @@ mod tests {
)
.unwrap(),
vm_config: "selector-test".into(),
launch_ticket: String::new(),
};
for root_epoch in [None, Some(0), Some(1)] {
for app_epoch in [None, Some(0), Some(2)] {
Expand Down
106 changes: 106 additions & 0 deletions dstack/crates/dstack-auth/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ struct BootInfo {
/// KMS that predates root epochs, and from KMS checks made while onboarding.
root_epoch: Option<usize>,
root_epoch_hash: Option<String>,
/// hash of the launch ticket the attested subject presented; empty when it
/// presented none, and from the legacy routes.
launch_ticket_hash: String,
}

#[derive(Serialize)]
Expand Down Expand Up @@ -203,6 +206,10 @@ struct KmsRules {
mr_aggregated: Vec<String>,
devices: Vec<String>,
allow_any_device: bool,
/// when set, a KMS importing the root keys must present one of these
/// launch ticket hashes; an empty list denies every import. Absent: the
/// import needs no ticket.
launch_tickets: Option<Vec<String>>,
}

#[derive(Deserialize, Default)]
Expand All @@ -212,6 +219,9 @@ struct AppRules {
compose_hashes: Vec<String>,
devices: Vec<String>,
allow_any_device: bool,
/// when set, the app must present one of these launch ticket hashes; an
/// empty list denies everything. Absent: no ticket is needed.
launch_tickets: Option<Vec<String>>,
}

/// normalize a hex string for comparison: trim, drop a `0x`/`0X` prefix,
Expand All @@ -232,6 +242,13 @@ fn contains(list: &[String], value: &str) -> bool {
list.iter().any(|x| norm(x) == v)
}

/// No allowlist admits any ticket; an empty one admits none.
fn launch_ticket_ok(tickets: &Option<Vec<String>>, ticket_hash: &str) -> bool {
tickets
.as_ref()
.is_none_or(|tickets| !ticket_hash.is_empty() && contains(tickets, ticket_hash))
}

/// An empty `devices` list allows no device; any device needs `allowAnyDevice`.
fn device_ok(allow_any: bool, devices: &[String], device_id: &str) -> bool {
allow_any || contains(devices, device_id)
Expand Down Expand Up @@ -297,6 +314,12 @@ fn tcb_denial(info: &BootInfo, al: &Allowlist) -> Option<&'static str> {
}

fn app_policy(info: &BootInfo, al: &Allowlist) -> BootResponse {
if al
.app(&info.app_id)
.is_some_and(|app| !launch_ticket_ok(&app.launch_tickets, &info.launch_ticket_hash))
{
return deny(al, "invalid launch ticket");
}
if let Some(reason) = tcb_denial(info, al) {
return deny(al, reason);
}
Expand Down Expand Up @@ -355,6 +378,10 @@ fn check_app(info: &BootInfo, al: &Allowlist) -> BootResponse {
}

fn check_kms(info: &BootInfo, al: &Allowlist) -> BootResponse {
// legacy callers present no ticket and cannot say which operation they authorize
if al.kms.launch_tickets.is_some() {
return deny(al, "invalid launch ticket");
}
let result = kms_policy(info, al);
if result.is_allowed {
allow(info, al)
Expand Down Expand Up @@ -397,6 +424,11 @@ fn authorize(
);
result.rejection_reason = if request.operation.is_kms() != is_kms {
"invalid authorization operation".into()
} else if request.operation == Operation::KmsImportKeys
&& !launch_ticket_ok(&al.kms.launch_tickets, &info.launch_ticket_hash)
{
// only the import hands a joining KMS the root keys
"invalid launch ticket".into()
} else if current.is_none() && request.operation != Operation::KmsBootstrap {
"root epoch not registered".into()
} else if request
Expand Down Expand Up @@ -508,6 +540,8 @@ fn load(path: &PathBuf) -> Allowlist {

#[post("/bootAuth/app", data = "<info>")]
fn boot_app(info: Json<BootInfo>, cli: &State<Cli>) -> Json<BootResponse> {
let mut info = info.into_inner();
info.launch_ticket_hash.clear();
let r = check_app(&info, &load(&cli.config));
rocket::info!(
"bootAuth/app app={} compose={} os={} device={} tcb={} -> allowed={} ({})",
Expand Down Expand Up @@ -557,6 +591,10 @@ fn info_json(al: &Allowlist) -> Result<serde_json::Value> {
"appImplementation": al.app_implementation,
});
if let Some(epoch) = al.current_root_epoch()? {
// the root CA key of the current epoch signs every serving KMS's RPC
// certificate
info["rootCaPubkey"] =
format!("0x{}", norm(&al.root_epochs[epoch.number].ca_pubkey)).into();
info["rootEpoch"] = epoch.number.into();
info["rootEpochHash"] = epoch.hash.into();
}
Expand Down Expand Up @@ -1187,6 +1225,74 @@ mod tests {
assert_eq!(serve.rejection_reason, "requested root epoch is not active");
}

#[test]
fn launch_tickets_are_required_only_when_configured() {
let mut al: Allowlist = serde_json::from_value(json!({
"kmsContractAddr": "0x1111111111111111111111111111111111111111",
"rootEpochs": [{"caPubkey": "aa", "k256Pubkey": "bb"}, {"caPubkey": "cc", "k256Pubkey": "dd"}],
"osImages": ["aa"],
"kms": {"mrAggregated": ["bb"], "allowAnyDevice": true, "launchTickets": ["0xEE"]},
"apps": {"0x2222222222222222222222222222222222222222": {
"composeHashes": ["cc"], "allowAnyDevice": true, "launchTickets": ["0xFF"]
}}
}))
.unwrap();
let ask = |al: &Allowlist, is_kms: bool, operation: &str, ticket: Option<&str>| {
let mut request = if is_kms {
json!({"operation": operation, "appId": "11".repeat(20), "mrAggregated": "bb"})
} else {
json!({"operation": operation, "appId": "22".repeat(20), "composeHash": "cc", "osImageHash": "aa"})
};
if let Some(ticket) = ticket {
request["launchTicketHash"] = ticket.into();
}
let request: AuthorizationRequest = serde_json::from_value(request).unwrap();
authorize(&request, is_kms, al).unwrap()
};
for operation in ["app_get_key", "app_get_cert"] {
assert!(ask(&al, false, operation, Some("ff")).authorized);
assert_eq!(
ask(&al, false, operation, Some("ee")).rejection_reason,
"invalid launch ticket"
);
assert!(!ask(&al, false, operation, None).authorized);
}
assert!(ask(&al, true, "kms_import_keys", Some("0xee")).authorized);
assert_eq!(
ask(&al, true, "kms_import_keys", None).rejection_reason,
"invalid launch ticket"
);
// Only the import hands out the root keys.
assert!(ask(&al, true, "kms_serve", None).authorized);
// Legacy callers cannot present a ticket.
let legacy = boot("0x2222222222222222222222222222222222222222", "cc", "aa");
assert!(!check_app(&legacy, &al).is_allowed);
assert!(
!check_kms(
&BootInfo {
mr_aggregated: "bb".into(),
..Default::default()
},
&al
)
.is_allowed
);

// An empty allowlist admits no ticket; none configured admits any.
al.kms.launch_tickets = Some(vec![]);
assert!(!ask(&al, true, "kms_import_keys", Some("ee")).authorized);
al.kms.launch_tickets = None;
assert!(ask(&al, true, "kms_import_keys", None).authorized);

assert_eq!(info_json(&al).unwrap()["rootCaPubkey"], "0xaa");
al.current_root_epoch = 1;
assert_eq!(info_json(&al).unwrap()["rootCaPubkey"], "0xcc");
assert!(info_json(&Allowlist::default())
.unwrap()
.get("rootCaPubkey")
.is_none());
}

#[test]
fn allows_only_a_kms_serving_the_current_root_epoch() {
// Shared with the DstackKms contract tests.
Expand Down
44 changes: 44 additions & 0 deletions dstack/dstack-types/src/authorization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,47 @@ impl AuthorizationOperation {
)
}
}

/// The `launchTicketHash` of `ticket`: `sha256("dstack-launch-ticket/v1:" || ticket)`.
///
/// Shell equivalent: `printf 'dstack-launch-ticket/v1:%s' "$TICKET" | sha256sum`.
pub fn launch_ticket_hash(ticket: &str) -> [u8; 32] {
crate::sha256(format!("dstack-launch-ticket/v1:{ticket}").as_bytes())
}

/// Shortest launch ticket a KMS accepts. Its hash is public, so a short ticket
/// can be recovered offline; length alone cannot prove entropy, so generate
/// tickets randomly.
pub const LAUNCH_TICKET_MIN_LEN: usize = 32;
/// Longest launch ticket a KMS accepts; sys-config must stay small.
pub const LAUNCH_TICKET_MAX_LEN: usize = 1024;

/// Refuse a ticket the KMS would refuse. Surrounding whitespace is refused
/// rather than trimmed: it changes the hash, so a pasted newline would
/// otherwise fail as an unknown ticket.
pub fn check_launch_ticket(ticket: &str) -> Result<(), String> {
if !(LAUNCH_TICKET_MIN_LEN..=LAUNCH_TICKET_MAX_LEN).contains(&ticket.len()) {
return Err(format!(
"launch ticket must be {LAUNCH_TICKET_MIN_LEN} to {LAUNCH_TICKET_MAX_LEN} bytes, got {}",
ticket.len()
));
}
if ticket.trim() != ticket {
return Err("launch ticket has surrounding whitespace".into());
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

/// Shared with vmm-cli's tests, which compute the hash the contracts allow.
#[test]
fn launch_ticket_hash_is_domain_separated() {
assert_eq!(
hex::encode(launch_ticket_hash(&"t".repeat(32))),
"8185a84114cfd5c0fb70e2233dee133cfa6467b58537ece8b27137828baabecc"
);
}
}
Loading
Loading