Skip to content

Commit e5f138c

Browse files
authored
Merge pull request #763 from Dstack-TEE/feat/launch-token-requirement
feat(guest): built-in launch token requirement
2 parents db46132 + e794f2b commit e5f138c

8 files changed

Lines changed: 215 additions & 5 deletions

File tree

docs/security/cvm-boundaries.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ dstack uses encrypted environment variables to allow app developers to securely
126126
This file is not measured to RTMRs. But it is highly recommended to add application-specific integrity checks on encrypted environment variables at the application layer. See [security-best-practices.md](./security-best-practices.md) for more details.
127127

128128
### .user-config
129-
This is an optional application-specific configuration file that applications inside the CVM can access. dstack OS simply stores it at /dstack/.host-shared/.user-config without any measurement or additional processing.
129+
This is an optional application-specific configuration file that applications inside the CVM can access. dstack OS simply stores it at /dstack/.host-shared/.user-config without any measurement or additional processing, unless `requirements.launch_token_hash` is set in app-compose.json — in that case the guest reads the launch token from JSON path `dstack.launch_token` in this file and fails closed at boot, before key provisioning, unless its SHA-256 matches the pinned hash.
130130

131131
Application developers should perform integrity checks on user_config at the application layer if necessary.
132132

docs/security/security-best-practices.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@ If your App is intended for end users who need to verify what code your App is r
4040
4141
dstack provides encrypted environment variable functionality. Although the CVM physical machine controller cannot view encrypted environment variables, they may forge encrypted environment variables because the CVM encryption public key is known to everyone. Therefore, Apps need to perform auth checks on encrypted environment variables at the application layer. LAUNCH_TOKEN pattern is one method to prevent unauthorized envs replacement. For details, refer to the deployment script of [dstack-gateway](https://github.com/Dstack-TEE/dstack/blob/1b8a4516826b02f9d7f747eddac244dcd68fc325/gateway/dstack-app/deploy-to-vmm.sh#L150-L165).
4242
43+
Newer dstack OS images support the LAUNCH_TOKEN pattern natively via `requirements.launch_token_hash` in app-compose.json. When this field is set, the guest reads the launch token from `user_config` at JSON path `dstack.launch_token` and refuses to boot — before any keys are provisioned — unless its digest matches the hash pinned in the (compose-hash-measured) app-compose.json. When the field is absent, `user_config` is not parsed and stays fully application-defined. Set manifest_version to `"3"` (string) when using `requirements` so older guests fail closed instead of silently ignoring it.
44+
45+
The digest is domain-separated so it stays distinct from the legacy plain-`sha256(token)` convention and from generic precomputed tables:
46+
47+
```bash
48+
LAUNCH_TOKEN_HASH=$(printf 'dstack-launch-token/v1:%s' "$TOKEN" | sha256sum | cut -d' ' -f1)
49+
```
50+
51+
Because `launch_token_hash` is public, a guessable token can be recovered offline by brute force. Guests reject tokens shorter than 32 bytes, but length alone does not guarantee entropy — always generate the token randomly, e.g. `tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 32`.
52+
53+
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).
54+
4355
If you use dstack-vmm's built-in UI, the prelaunch script has already been automatically filled in for you:
4456

4557
![Prelaunch Script](../assets/prelaunch-script.png)

dstack-types/src/lib.rs

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,16 +131,46 @@ pub struct Requirements {
131131
/// with ACPI tables measured, while `false` requires lite mode.
132132
#[serde(skip_serializing_if = "Option::is_none")]
133133
pub tdx_measure_acpi_tables: Option<bool>,
134+
/// Hex digest of the launch token carried in `user_config` at JSON path
135+
/// `dstack.launch_token`, computed as
136+
/// `sha256("dstack-launch-token/v1:" || token)` (see
137+
/// [`launch_token_hash`]). When set, guests fail closed before key
138+
/// provisioning unless the token hashes to this value; when absent,
139+
/// `user_config` is not parsed at all.
140+
///
141+
/// This hash is public, so the token must not be guessable: guests reject
142+
/// tokens shorter than 32 bytes, and deployers should use a random token
143+
/// (e.g. 32 random alphanumeric characters).
144+
#[serde(skip_serializing_if = "Option::is_none")]
145+
pub launch_token_hash: Option<String>,
134146
}
135147

136148
impl Requirements {
137149
pub fn is_empty(&self) -> bool {
138150
self.os_version.is_none()
139151
&& self.platforms.is_none()
140152
&& self.tdx_measure_acpi_tables.is_none()
153+
&& self.launch_token_hash.is_none()
141154
}
142155
}
143156

157+
/// Domain-separation prefix for [`launch_token_hash`]. It keeps the digest
158+
/// distinct from a plain `sha256(token)` (as used by the legacy app-layer
159+
/// top-level `launch_token_hash` convention) and from generic precomputed
160+
/// tables.
161+
pub const LAUNCH_TOKEN_HASH_DOMAIN: &str = "dstack-launch-token/v1:";
162+
163+
/// Canonical `requirements.launch_token_hash` digest of a launch token:
164+
/// `sha256("dstack-launch-token/v1:" || token)`.
165+
///
166+
/// Shell equivalent: `printf 'dstack-launch-token/v1:%s' "$TOKEN" | sha256sum`.
167+
pub fn launch_token_hash(token: &str) -> [u8; 32] {
168+
let mut data = Vec::with_capacity(LAUNCH_TOKEN_HASH_DOMAIN.len() + token.len());
169+
data.extend_from_slice(LAUNCH_TOKEN_HASH_DOMAIN.as_bytes());
170+
data.extend_from_slice(token.as_bytes());
171+
sha256(&data)
172+
}
173+
144174
fn deserialize_manifest_version<'de, D>(deserializer: D) -> Result<String, D::Error>
145175
where
146176
D: serde::Deserializer<'de>,
@@ -389,7 +419,8 @@ mod app_compose_tests {
389419
"requirements": {
390420
"os_version": ">=0.6.1",
391421
"platforms": ["dstack-gcp-tdx", "dstack-tdx"],
392-
"tdx_measure_acpi_tables": true
422+
"tdx_measure_acpi_tables": true,
423+
"launch_token_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
393424
}
394425
}))
395426
.unwrap();
@@ -400,6 +431,10 @@ mod app_compose_tests {
400431
Some(vec!["dstack-gcp-tdx".to_string(), "dstack-tdx".to_string()])
401432
);
402433
assert_eq!(requirements.tdx_measure_acpi_tables, Some(true));
434+
assert_eq!(
435+
requirements.launch_token_hash.as_deref(),
436+
Some("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")
437+
);
403438

404439
let err = serde_json::from_value::<AppCompose>(serde_json::json!({
405440
"manifest_version": "3",
@@ -451,6 +486,33 @@ mod app_compose_tests {
451486
let requirements = acpi_tables.requirements.as_ref().unwrap();
452487
assert_eq!(requirements.tdx_measure_acpi_tables, Some(false));
453488
assert!(!requirements.is_empty());
489+
490+
let launch_token: AppCompose = serde_json::from_value(serde_json::json!({
491+
"manifest_version": "3",
492+
"name": "test",
493+
"runner": "docker-compose",
494+
"requirements": {
495+
"launch_token_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
496+
}
497+
}))
498+
.unwrap();
499+
let requirements = launch_token.requirements.as_ref().unwrap();
500+
assert!(requirements.launch_token_hash.is_some());
501+
assert!(!requirements.is_empty());
502+
}
503+
504+
#[test]
505+
fn launch_token_hash_is_domain_separated() {
506+
assert_eq!(
507+
hex::encode(launch_token_hash("unit-test-launch-token-0000000001")),
508+
"28faa1319055d733ad9651f5ab7689c15b04609846bcd27b3c5bc8df6246f5a3"
509+
);
510+
// Not a plain sha256 of the token (the legacy app-layer convention).
511+
use sha2::{Digest, Sha256};
512+
assert_ne!(
513+
launch_token_hash("unit-test-launch-token-0000000001").to_vec(),
514+
Sha256::digest("unit-test-launch-token-0000000001".as_bytes()).to_vec()
515+
);
454516
}
455517
}
456518

dstack-util/src/system_setup.rs

Lines changed: 132 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,10 @@ impl HostShareDir {
229229
fn instance_info_file(&self) -> PathBuf {
230230
self.base_dir.join(INSTANCE_INFO)
231231
}
232+
233+
fn user_config_file(&self) -> PathBuf {
234+
self.base_dir.join(USER_CONFIG)
235+
}
232236
}
233237

234238
struct HostShared {
@@ -774,7 +778,9 @@ fn verify_manifest_version(app_compose: &AppCompose) -> Result<u32> {
774778
Ok(manifest_version)
775779
}
776780

777-
fn verify_app_compose_policy(app_compose: &AppCompose, sys_config: &SysConfig) -> Result<()> {
781+
fn verify_app_compose_policy(shared: &HostShared) -> Result<()> {
782+
let app_compose = &shared.app_compose;
783+
let sys_config = &shared.sys_config;
778784
verify_manifest_feature_requirements(app_compose)?;
779785
let Some(requirements) = app_compose.requirements.as_ref() else {
780786
return Ok(());
@@ -801,6 +807,14 @@ fn verify_app_compose_policy(app_compose: &AppCompose, sys_config: &SysConfig) -
801807
current_platform,
802808
)?;
803809
}
810+
if let Some(launch_token_hash) = requirements.launch_token_hash.as_deref() {
811+
// Only touch user_config when the requirement is present; otherwise it
812+
// is opaque application data and must not be parsed here.
813+
let user_config = fs::read_to_string(shared.dir.user_config_file())
814+
.context("failed to read user_config for requirements.launch_token_hash")?;
815+
let token = launch_token_from_user_config(&user_config)?;
816+
verify_launch_token_requirement(launch_token_hash, &token)?;
817+
}
804818
Ok(())
805819
}
806820

@@ -911,6 +925,55 @@ fn verify_tdx_measure_acpi_tables_requirement(
911925
Ok(())
912926
}
913927

928+
/// Minimum launch token length in bytes. `launch_token_hash` is public (it is
929+
/// part of app-compose.json), so short tokens can be recovered offline via
930+
/// brute force or precomputed tables. Length cannot prove entropy, but it
931+
/// rejects the trivially guessable tokens; deployers should still generate
932+
/// random tokens (e.g. 32 random alphanumeric characters).
933+
const LAUNCH_TOKEN_MIN_LEN: usize = 32;
934+
935+
/// Enforce the launch-token pattern: the compose-hash-measured
936+
/// `requirements.launch_token_hash` must match the domain-separated digest of
937+
/// the launch token (see [`dstack_types::launch_token_hash`]). This binds a
938+
/// deployment to a token known only to the deployer, so a host cannot launch
939+
/// the app with substituted inputs.
940+
fn verify_launch_token_requirement(launch_token_hash: &str, token: &str) -> Result<()> {
941+
let expected = hex::decode(launch_token_hash)
942+
.context("invalid requirements.launch_token_hash: not a hex string")?;
943+
if expected.len() != 32 {
944+
bail!(
945+
"invalid requirements.launch_token_hash: expected 32-byte sha256 hex, got {} bytes",
946+
expected.len()
947+
);
948+
}
949+
if token.len() < LAUNCH_TOKEN_MIN_LEN {
950+
bail!(
951+
"launch token too short: got {} bytes, minimum is {LAUNCH_TOKEN_MIN_LEN}; use a random token since launch_token_hash is public",
952+
token.len()
953+
);
954+
}
955+
if dstack_types::launch_token_hash(token)[..] != expected[..] {
956+
bail!("launch token mismatch: sha256(\"{}\" || launch token) does not match requirements.launch_token_hash", dstack_types::LAUNCH_TOKEN_HASH_DOMAIN);
957+
}
958+
info!("launch token requirement satisfied");
959+
Ok(())
960+
}
961+
962+
/// Extract the launch token from `user_config` at JSON path
963+
/// `dstack.launch_token`. Callers must only invoke this when
964+
/// `requirements.launch_token_hash` is set; otherwise `user_config` is opaque
965+
/// application data and must not be parsed.
966+
fn launch_token_from_user_config(user_config: &str) -> Result<String> {
967+
let user_config: Value = serde_json::from_str(user_config)
968+
.context("failed to parse user_config as JSON for requirements.launch_token_hash")?;
969+
let token = user_config
970+
.pointer("/dstack/launch_token")
971+
.context("user_config is missing dstack.launch_token")?
972+
.as_str()
973+
.context("user_config dstack.launch_token is not a string")?;
974+
Ok(token.to_string())
975+
}
976+
914977
fn read_current_os_version() -> Result<String> {
915978
const OS_RELEASE_PATHS: &[&str] = &["/etc/os-release", "/usr/lib/os-release"];
916979
for path in OS_RELEASE_PATHS {
@@ -974,8 +1037,7 @@ pub async fn cmd_sys_setup(args: SetupArgs) -> Result<()> {
9741037
}
9751038

9761039
async fn do_sys_setup(stage0: Stage0<'_>) -> Result<()> {
977-
verify_app_compose_policy(&stage0.shared.app_compose, &stage0.shared.sys_config)
978-
.context("Failed to verify app-compose policy")?;
1040+
verify_app_compose_policy(&stage0.shared).context("Failed to verify app-compose policy")?;
9791041
if stage0.shared.app_compose.secure_time {
9801042
info!("Waiting for the system time to be synchronized");
9811043
cmd! {
@@ -2306,6 +2368,73 @@ fn test_tdx_measure_acpi_tables_requirement_ignored_on_non_tdx() {
23062368
.unwrap();
23072369
}
23082370

2371+
#[cfg(test)]
2372+
const TEST_LAUNCH_TOKEN: &str = "unit-test-launch-token-0000000001";
2373+
#[cfg(test)]
2374+
// sha256("dstack-launch-token/v1:" || TEST_LAUNCH_TOKEN)
2375+
const TEST_LAUNCH_TOKEN_HASH: &str =
2376+
"28faa1319055d733ad9651f5ab7689c15b04609846bcd27b3c5bc8df6246f5a3";
2377+
2378+
#[test]
2379+
fn test_launch_token_requirement_accepts_matching_token() {
2380+
verify_launch_token_requirement(TEST_LAUNCH_TOKEN_HASH, TEST_LAUNCH_TOKEN).unwrap();
2381+
}
2382+
2383+
#[test]
2384+
fn test_launch_token_requirement_rejects_wrong_token() {
2385+
let err = verify_launch_token_requirement(
2386+
TEST_LAUNCH_TOKEN_HASH,
2387+
"wrong-launch-token-00000000000001",
2388+
)
2389+
.unwrap_err();
2390+
assert!(err.to_string().contains("launch token mismatch"));
2391+
}
2392+
2393+
#[test]
2394+
fn test_launch_token_requirement_rejects_short_token() {
2395+
// sha256("dstack-launch-token/v1:test"): a matching but brute-forceable
2396+
// token must be rejected.
2397+
let err = verify_launch_token_requirement(
2398+
"e128cf5f3c3633d3a1f450d3d4bece260b20f9afb667de4bbff6dd985f1e5d1a",
2399+
"test",
2400+
)
2401+
.unwrap_err();
2402+
assert!(err.to_string().contains("launch token too short"));
2403+
let err = verify_launch_token_requirement(TEST_LAUNCH_TOKEN_HASH, "").unwrap_err();
2404+
assert!(err.to_string().contains("launch token too short"));
2405+
// 31 bytes is one short of the minimum.
2406+
let err = verify_launch_token_requirement(TEST_LAUNCH_TOKEN_HASH, &"a".repeat(31)).unwrap_err();
2407+
assert!(err.to_string().contains("launch token too short"));
2408+
}
2409+
2410+
#[test]
2411+
fn test_launch_token_requirement_rejects_invalid_hash() {
2412+
let err = verify_launch_token_requirement("zz", TEST_LAUNCH_TOKEN).unwrap_err();
2413+
assert!(err.to_string().contains("not a hex string"));
2414+
let err = verify_launch_token_requirement("9f86d0", TEST_LAUNCH_TOKEN).unwrap_err();
2415+
assert!(err.to_string().contains("expected 32-byte sha256 hex"));
2416+
}
2417+
2418+
#[test]
2419+
fn test_launch_token_from_user_config_extracts_token() {
2420+
let user_config = r#"{"dstack":{"launch_token":"test"},"app":{"foo":"bar"}}"#;
2421+
assert_eq!(launch_token_from_user_config(user_config).unwrap(), "test");
2422+
}
2423+
2424+
#[test]
2425+
fn test_launch_token_from_user_config_rejects_missing_or_invalid_token() {
2426+
let err = launch_token_from_user_config(r#"{}"#).unwrap_err();
2427+
assert!(err.to_string().contains("missing dstack.launch_token"));
2428+
let err = launch_token_from_user_config(r#"{"dstack":{}}"#).unwrap_err();
2429+
assert!(err.to_string().contains("missing dstack.launch_token"));
2430+
let err = launch_token_from_user_config(r#"{"dstack":{"launch_token":42}}"#).unwrap_err();
2431+
assert!(err.to_string().contains("not a string"));
2432+
let err = launch_token_from_user_config("not json").unwrap_err();
2433+
assert!(err
2434+
.to_string()
2435+
.contains("failed to parse user_config as JSON"));
2436+
}
2437+
23092438
#[test]
23102439
fn test_os_release_value_parses_quoted_version_id() {
23112440
let content = r#"

sdk/go/dstack/compose_hash.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ type Requirements struct {
4242
OsVersion string `json:"os_version,omitempty"`
4343
Platforms *[]RequirementPlatform `json:"platforms,omitempty"`
4444
TdxMeasureAcpiTables *bool `json:"tdx_measure_acpi_tables,omitempty"`
45+
LaunchTokenHash string `json:"launch_token_hash,omitempty"`
4546
}
4647

4748
// AppCompose represents the application composition structure

sdk/js/src/get-compose-hash.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export interface Requirements extends SortableObject {
5252
os_version?: string;
5353
platforms?: RequirementPlatform[];
5454
tdx_measure_acpi_tables?: boolean;
55+
launch_token_hash?: string;
5556
}
5657

5758
export interface AppCompose extends SortableObject {

sdk/python/src/dstack_sdk/get_compose_hash.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,13 @@ def __init__(
5454
os_version: Optional[str] = None,
5555
platforms: Optional[List[str]] = None,
5656
tdx_measure_acpi_tables: Optional[bool] = None,
57+
launch_token_hash: Optional[str] = None,
5758
) -> None:
5859
"""Initialize a new ``Requirements`` instance."""
5960
self.os_version = os_version
6061
self.platforms = platforms
6162
self.tdx_measure_acpi_tables = tdx_measure_acpi_tables
63+
self.launch_token_hash = launch_token_hash
6264

6365
def to_dict(self) -> Dict[str, Any]:
6466
"""Return a dictionary representation excluding ``None`` fields."""
@@ -69,6 +71,8 @@ def to_dict(self) -> Dict[str, Any]:
6971
result["platforms"] = self.platforms
7072
if self.tdx_measure_acpi_tables is not None:
7173
result["tdx_measure_acpi_tables"] = self.tdx_measure_acpi_tables
74+
if self.launch_token_hash is not None:
75+
result["launch_token_hash"] = self.launch_token_hash
7276
return result
7377

7478

vmm/ui/src/composables/useVmManager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ type Requirements = {
4444
os_version?: string;
4545
platforms?: RequirementPlatform[];
4646
tdx_measure_acpi_tables?: boolean;
47+
launch_token_hash?: string;
4748
};
4849

4950
const x25519 = require('../lib/x25519.js');

0 commit comments

Comments
 (0)