Skip to content

Commit 64ee590

Browse files
committed
feat(vmm-cli): create launch tickets and deploy with them
Signed-off-by: Kevin Wang <wy721@qq.com>
1 parent 39bf843 commit 64ee590

5 files changed

Lines changed: 142 additions & 0 deletions

File tree

‎docs/security/security-best-practices.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ Because `launch_token_hash` is public, a guessable token can be recovered offlin
5252

5353
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).
5454

55+
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).
56+
5557
If you use dstack-vmm's built-in UI, the prelaunch script has already been automatically filled in for you:
5658

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

‎docs/vmm-cli-user-guide.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,27 @@ Deploy your application with the compose file:
293293
- `--kms-url`: KMS server URL
294294
- `--gateway-url`: Gateway server URL
295295
- `--stopped`: Create VM in stopped state (requires dstack-vmm >= 0.5.4)
296+
- `--launch-ticket-file`: File holding the launch ticket the guest presents to the KMS (see below)
297+
298+
#### Launch Tickets
299+
300+
When the app's `DstackApp` contract requires a launch ticket, the KMS releases
301+
keys only to a CVM presenting a ticket whose hash the contract allows. Create a
302+
fresh ticket per deployment, allow its hash on chain, and deploy with it:
303+
304+
```bash
305+
./vmm-cli.py launch-ticket new ./prod.ticket # writes the ticket (mode 0600), prints its hash
306+
# addLaunchTicket(<hash>, "prod") on the DstackApp contract, then:
307+
./vmm-cli.py deploy ... --launch-ticket-file ./prod.ticket
308+
```
309+
310+
`launch-ticket hash FILE` prints the hash of an existing ticket; `--kms` gives the
311+
hash for a KMS onboarding ticket (`addKmsLaunchTicket`) instead. `update
312+
--launch-ticket-file FILE` replaces the ticket of a VM and `update
313+
--no-launch-ticket` removes it. The VMM keeps the ticket and writes it to the
314+
guest's sys-config at every boot, but never returns it. The host holds the
315+
ticket, so remove its hash from the contract once that host stops serving the
316+
app. See [launch tickets](../dstack/kms/README.md#launch-tickets).
296317

297318
#### Port Mapping
298319

‎dstack/dstack-types/src/authorization.rs‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,21 @@ pub fn check_launch_ticket(ticket: &str) -> Result<(), String> {
6363
Ok(())
6464
}
6565

66+
#[cfg(test)]
67+
mod tests {
68+
use super::*;
69+
70+
/// Shared with vmm-cli's tests, which compute the hash the contracts allow.
71+
#[test]
72+
fn launch_ticket_hashes_are_domain_separated() {
73+
let ticket = "t".repeat(32);
74+
assert_eq!(
75+
hex::encode(AuthorizationOperation::AppGetCert.launch_ticket_hash(&ticket)),
76+
"8185a84114cfd5c0fb70e2233dee133cfa6467b58537ece8b27137828baabecc"
77+
);
78+
assert_eq!(
79+
hex::encode(AuthorizationOperation::KmsImportKeys.launch_ticket_hash(&ticket)),
80+
"a78c8aebf7e3ba7ead22d3e9e20827a53c89af8e61115a0e8c333b9e27f632ad"
81+
);
82+
}
83+
}

‎dstack/vmm/src/tests/test_vmm_cli.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,35 @@ def test_kms_urls_are_sent_to_upgrade_app(self) -> None:
6262
],
6363
)
6464

65+
def test_launch_ticket_is_sent_and_removed(self) -> None:
66+
"""Send a replacement ticket, and an empty one to remove it."""
67+
cli = VMM_CLI.VmmCLI("http://127.0.0.1:8080")
68+
calls: list[tuple[str, dict]] = []
69+
cli.rpc_call = lambda method, params=None: calls.append((method, params)) or {}
70+
71+
with contextlib.redirect_stdout(io.StringIO()):
72+
cli.update_vm("vm-id", launch_ticket="t" * 32)
73+
cli.update_vm("vm-id", launch_ticket="")
74+
75+
self.assertEqual(
76+
[params["launch_ticket"] for _, params in calls], ["t" * 32, ""]
77+
)
78+
79+
80+
class LaunchTicketTests(unittest.TestCase):
81+
"""Pin the hash the contracts allow; the KMS computes the same."""
82+
83+
def test_hash_is_domain_separated(self) -> None:
84+
ticket = "t" * 32
85+
self.assertEqual(
86+
VMM_CLI.launch_ticket_hash(ticket, kms=False),
87+
"0x8185a84114cfd5c0fb70e2233dee133cfa6467b58537ece8b27137828baabecc",
88+
)
89+
self.assertEqual(
90+
VMM_CLI.launch_ticket_hash(ticket, kms=True),
91+
"0xa78c8aebf7e3ba7ead22d3e9e20827a53c89af8e61115a0e8c333b9e27f632ad",
92+
)
93+
6594

6695
class SealedEnvTests(unittest.TestCase):
6796
"""Exercise signed recipient selection and SealedEnv compatibility."""
@@ -382,6 +411,7 @@ def test_create_update_and_update_env_send_only_the_negotiated_field(self):
382411
stopped=False,
383412
no_tee=False,
384413
annotation=None,
414+
launch_ticket_file=None,
385415
disk_prealloc=None,
386416
simulated_tee=None,
387417
swap=None,

‎dstack/vmm/src/vmm-cli.py‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import json
1313
import os
1414
import re
15+
import secrets
1516
import socket
1617
import ssl
1718
import sys
@@ -501,6 +502,32 @@ def read_utf8(filepath: str) -> str:
501502
return f.read().decode("utf-8")
502503

503504

505+
def read_launch_ticket(filepath: str) -> str:
506+
"""Read a launch ticket file, ignoring surrounding whitespace."""
507+
ticket = read_utf8(filepath).strip()
508+
if not 32 <= len(ticket.encode()) <= 1024:
509+
raise Exception("launch ticket must be 32 to 1024 bytes")
510+
return ticket
511+
512+
513+
def launch_ticket_hash(ticket: str, kms: bool) -> str:
514+
"""The hash to allow on chain: sha256(domain || ticket)."""
515+
domain = "dstack-kms-launch-ticket/v1:" if kms else "dstack-launch-ticket/v1:"
516+
return "0x" + hashlib.sha256((domain + ticket).encode()).hexdigest()
517+
518+
519+
def cmd_launch_ticket(args) -> None:
520+
"""Create a launch ticket file, or print the hash of one."""
521+
if args.launch_ticket_command == "new":
522+
ticket = secrets.token_urlsafe(32)
523+
fd = os.open(args.file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
524+
with os.fdopen(fd, "w") as f:
525+
f.write(ticket + "\n")
526+
else:
527+
ticket = read_launch_ticket(args.file)
528+
print(launch_ticket_hash(ticket, args.kms))
529+
530+
504531
class UnixSocketHTTPConnection(http.client.HTTPConnection):
505532
"""HTTPConnection that connects to a Unix domain socket."""
506533

@@ -1128,6 +1155,8 @@ def create_vm(self, args) -> None:
11281155
"no_tee": args.no_tee,
11291156
"annotations": args.annotation or {},
11301157
}
1158+
if args.launch_ticket_file:
1159+
params["launch_ticket"] = read_launch_ticket(args.launch_ticket_file)
11311160
if args.disk_prealloc:
11321161
params["disk_prealloc"] = args.disk_prealloc
11331162
if args.simulated_tee:
@@ -1277,6 +1306,7 @@ def update_vm(
12771306
net_vhost_inherit: bool = False,
12781307
net_queues: Optional[Union[int, str]] = None,
12791308
annotations: Optional[Dict[str, str]] = None,
1309+
launch_ticket: Optional[str] = None,
12801310
) -> None:
12811311
"""Update multiple aspects of a VM in one command."""
12821312
# Validate: --env-file requires --kms-url
@@ -1529,6 +1559,10 @@ def update_vm(
15291559
upgrade_params["annotations"] = annotations
15301560
updates.append(f"annotations ({len(annotations)})")
15311561

1562+
if launch_ticket is not None:
1563+
upgrade_params["launch_ticket"] = launch_ticket
1564+
updates.append("launch ticket" if launch_ticket else "launch ticket removed")
1565+
15321566
if no_tee is not None:
15331567
upgrade_params["no_tee"] = no_tee
15341568
updates.append("TEE disabled" if no_tee else "TEE enabled")
@@ -2196,6 +2230,10 @@ def _patched_format_help():
21962230
metavar="KEY=VALUE",
21972231
help="Annotation for host-side tooling (can be repeated)",
21982232
)
2233+
deploy_parser.add_argument(
2234+
"--launch-ticket-file",
2235+
help="File holding the launch ticket the guest presents to the KMS",
2236+
)
21992237
deploy_parser.add_argument(
22002238
"--disk-prealloc",
22012239
choices=["off", "metadata", "falloc", "full"],
@@ -2476,6 +2514,31 @@ def _patched_format_help():
24762514
annotations_group.add_argument(
24772515
"--no-annotations", action="store_true", help="Remove all annotations"
24782516
)
2517+
launch_ticket_group = update_parser.add_mutually_exclusive_group()
2518+
launch_ticket_group.add_argument(
2519+
"--launch-ticket-file", help="Replace the launch ticket with this file's"
2520+
)
2521+
launch_ticket_group.add_argument(
2522+
"--no-launch-ticket", action="store_true", help="Remove the launch ticket"
2523+
)
2524+
2525+
launch_ticket_parser = subparsers.add_parser(
2526+
"launch-ticket", help="Create launch tickets and print their on-chain hash"
2527+
)
2528+
launch_ticket_subparsers = launch_ticket_parser.add_subparsers(
2529+
dest="launch_ticket_command", required=True
2530+
)
2531+
for name, help_text in [
2532+
("new", "Write a new random ticket to FILE"),
2533+
("hash", "Print the hash of the ticket in FILE"),
2534+
]:
2535+
sub = launch_ticket_subparsers.add_parser(name, help=help_text)
2536+
sub.add_argument("file", metavar="FILE")
2537+
sub.add_argument(
2538+
"--kms",
2539+
action="store_true",
2540+
help="A KMS onboarding ticket (addKmsLaunchTicket) rather than an app one",
2541+
)
24792542

24802543
args = parser.parse_args()
24812544

@@ -2488,6 +2551,9 @@ def _patched_format_help():
24882551
else:
24892552
vmm_parser.print_help()
24902553
return
2554+
if args.command == "launch-ticket":
2555+
cmd_launch_ticket(args)
2556+
return
24912557

24922558
# Resolve the URL with auto-discovery
24932559
url = resolve_vmm_url(instances, config, args.url)
@@ -2574,6 +2640,11 @@ def _patched_format_help():
25742640
net_vhost_inherit=getattr(args, "net_vhost_inherit", False),
25752641
net_queues=args.net_queues,
25762642
annotations={} if args.no_annotations else args.annotation,
2643+
launch_ticket=(
2644+
""
2645+
if args.no_launch_ticket
2646+
else args.launch_ticket_file and read_launch_ticket(args.launch_ticket_file)
2647+
),
25772648
)
25782649
elif args.command == "kms":
25792650
if not args.kms_action:

0 commit comments

Comments
 (0)