Skip to content

Commit f4b063e

Browse files
committed
fix(sealed-env): preserve current ciphertext and honor image capabilities
Signed-off-by: Kevin Wang <wy721@qq.com>
1 parent a832f63 commit f4b063e

4 files changed

Lines changed: 97 additions & 32 deletions

File tree

‎docs/sealed-env.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ maximum age 300 seconds, future clock tolerance 60 seconds).
6262
## Image compatibility
6363

6464
New image `metadata.json` files declare `"sealed_env": true`; `ListImages` exposes
65-
this flag. Missing metadata means legacy encryption only. The UI uses the target
65+
this flag. Missing metadata means legacy encryption only. The UI and CLI use the target
6666
image for both creation and environment updates, including image changes.
6767
Old or unknown images use legacy encryption even with a capable KMS. For capable
6868
images, legacy fallback is allowed only when the SealedEnv RPC is explicitly absent.
@@ -89,7 +89,10 @@ The copy limits are 384 KiB for sealed JSON and 256 KiB for legacy ciphertext.
8989
At boot, sealed ciphertext explicitly selects its historical root/app epoch pair. The
9090
guest obtains that epoch's app key over the already trusted KMS connection,
9191
decrypts, and seals to the current epoch. Legacy ciphertext uses the legacy
92-
key path at app epoch zero, trying earlier root epochs when necessary, and is converted to SealedEnv.
92+
key path at app epoch zero, trying earlier root epochs when necessary. It is
93+
converted to SealedEnv only when decrypted with a historical epoch. Ciphertext
94+
already decryptable with the current keys is left unchanged, even in the legacy
95+
format; an older KMS need not provide a SealedEnv key in that case.
9396
A future epoch or failed authentication stops environment provisioning.
9497

9598
The guest sends `UpdateSealedEnv` through the host channel before application

‎dstack/dstack-util/src/system_setup.rs‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3364,7 +3364,7 @@ impl Stage1<'_> {
33643364
let ciphertext = &self.shared.encrypted_env;
33653365
let current_key = env_crypt_key(&self.keys.env_crypt_key)?;
33663366
let plaintext = match dh_decrypt(current_key, ciphertext) {
3367-
Ok(plaintext) => plaintext,
3367+
Ok(plaintext) => return Ok((plaintext, Vec::new())),
33683368
Err(error) => {
33693369
let current = self.keys.root_epoch();
33703370
let mut recovered = None;
@@ -4897,7 +4897,7 @@ mod sealed_env_file_tests {
48974897
}
48984898

48994899
#[tokio::test]
4900-
async fn guest_prefers_sealed_and_converts_legacy() {
4900+
async fn guest_preserves_env_at_the_current_epoch() {
49014901
let dir = tempfile::tempdir().unwrap();
49024902
let args = SetupArgs {
49034903
work_dir: dir.path().into(),
@@ -4916,9 +4916,12 @@ mod sealed_env_file_tests {
49164916
let old = stage(&args, None, legacy.clone());
49174917
let (opened, update) = old.decrypt_env().await.unwrap();
49184918
assert_eq!(opened, plaintext);
4919-
assert!(!update.is_empty());
4920-
let migrated = stage(&args, Some(update), vec![]);
4921-
assert_eq!(migrated.decrypt_env().await.unwrap().0, plaintext);
4919+
assert!(update.is_empty());
4920+
let mut without_sealed_key = stage(&args, None, legacy.clone());
4921+
without_sealed_key.keys.sealed_env_key.clear();
4922+
let (opened, update) = without_sealed_key.decrypt_env().await.unwrap();
4923+
assert_eq!(opened, plaintext);
4924+
assert!(update.is_empty());
49224925

49234926
for invalid in [
49244927
Vec::new(),

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

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ def test_sealed_payload_round_trip_and_normalized_pins(self):
130130
self.subTest(pin=pin),
131131
patch.object(VMM_CLI, "load_whitelist", return_value=[pin]),
132132
):
133-
self.decrypt(self.cli.encrypt_env_payload(self.envs, self.app_id, None))
133+
self.decrypt(self.cli.encrypt_env_payload(self.envs, self.app_id, None, True))
134134
self.cli.get_app_env_encrypt_pub_key.assert_not_called()
135135

136136
def test_app_epoch_is_signed_and_used_for_sealing(self):
@@ -142,7 +142,7 @@ def test_app_epoch_is_signed_and_used_for_sealing(self):
142142
+ self.timestamp.to_bytes(8, "big")
143143
)
144144
self.response["signature"] = self.signer.sign_msg_hash(digest).to_bytes().hex()
145-
payload = self.cli.encrypt_env_payload(self.envs, self.app_id, None)
145+
payload = self.cli.encrypt_env_payload(self.envs, self.app_id, None, True)
146146
envelope = json.loads(bytes.fromhex(payload["sealed_env"]))
147147
self.assertEqual(envelope["version"], 1)
148148
self.assertEqual(envelope["app_epoch"], 2)
@@ -154,7 +154,7 @@ def test_app_epoch_is_signed_and_used_for_sealing(self):
154154
self.assertEqual(plaintext, {"env": [{"key": k, "value": v} for k, v in self.envs.items()]})
155155
self.response["app_epoch"] = 1
156156
with self.assertRaises(ValueError):
157-
self.cli.encrypt_env_payload(self.envs, self.app_id, None)
157+
self.cli.encrypt_env_payload(self.envs, self.app_id, None, True)
158158

159159
def test_unpinned_signer_never_becomes_trusted(self):
160160
for pins in [
@@ -167,7 +167,7 @@ def test_unpinned_signer_never_becomes_trusted(self):
167167
patch.object(self.cli, "confirm_untrusted_signer") as confirm,
168168
):
169169
with self.assertRaisesRegex(ValueError, "not pinned"):
170-
self.cli.encrypt_env_payload(self.envs, self.app_id, None)
170+
self.cli.encrypt_env_payload(self.envs, self.app_id, None, True)
171171
confirm.assert_not_called()
172172
self.cli.get_app_env_encrypt_pub_key.assert_not_called()
173173

@@ -193,25 +193,25 @@ def test_signature_binds_every_field_and_app(self):
193193
{**self.response, field: value},
194194
)
195195
with self.assertRaises(Exception):
196-
self.cli.encrypt_env_payload(self.envs, self.app_id, None)
196+
self.cli.encrypt_env_payload(self.envs, self.app_id, None, True)
197197
self.cli.client.request.return_value = (200, self.response)
198198
for app_id in ["55" * 20, "11" * 19]:
199199
with self.assertRaises(ValueError):
200-
self.cli.encrypt_env_payload(self.envs, app_id, None)
200+
self.cli.encrypt_env_payload(self.envs, app_id, None, True)
201201
self.cli.get_app_env_encrypt_pub_key.assert_not_called()
202202

203203
def test_pinned_signed_key_timestamp_window(self):
204204
for age in [-60, 0, 300]:
205205
with self.subTest(age=age), patch.object(
206206
VMM_CLI.time, "time", return_value=self.timestamp + age
207207
):
208-
self.decrypt(self.cli.encrypt_env_payload(self.envs, self.app_id, None))
208+
self.decrypt(self.cli.encrypt_env_payload(self.envs, self.app_id, None, True))
209209
for age, reason in [(-61, "future"), (301, "stale")]:
210210
with self.subTest(age=age), patch.object(
211211
VMM_CLI.time, "time", return_value=self.timestamp + age
212212
):
213213
with self.assertRaisesRegex(ValueError, reason):
214-
self.cli.encrypt_env_payload(self.envs, self.app_id, None)
214+
self.cli.encrypt_env_payload(self.envs, self.app_id, None, True)
215215
self.cli.get_app_env_encrypt_pub_key.assert_not_called()
216216

217217
def test_public_key_request_contains_only_app_id(self):
@@ -228,14 +228,21 @@ def test_only_explicit_unsupported_method_falls_back(self):
228228
status,
229229
{"error": "Service not found: GetSealedEnvPubKey"},
230230
)
231-
payload = self.cli.encrypt_env_payload(self.envs, self.app_id, None)
231+
payload = self.cli.encrypt_env_payload(self.envs, self.app_id, None, True)
232232
self.assertEqual(set(payload), {"encrypted_env"})
233233
self.cli.get_app_env_encrypt_pub_key.assert_called_with(self.app_id, None)
234+
for upstream in ["400 Bad Request", "404 Not Found"]:
235+
self.cli.client.request.return_value = (400, {
236+
"error": f"Request failed with status={upstream}, error="
237+
+ json.dumps({"error": "Service not found: GetSealedEnvPubKey"})
238+
})
239+
payload = self.cli.encrypt_env_payload(self.envs, self.app_id, None, True)
240+
self.assertEqual(set(payload), {"encrypted_env"})
234241
self.cli.get_app_env_encrypt_pub_key.side_effect = ValueError(
235242
"legacy trust rejected"
236243
)
237244
with self.assertRaisesRegex(ValueError, "legacy trust rejected"):
238-
self.cli.encrypt_env_payload(self.envs, self.app_id, None)
245+
self.cli.encrypt_env_payload(self.envs, self.app_id, None, True)
239246

240247
def test_errors_and_bad_responses_do_not_downgrade(self):
241248
for status, body in [
@@ -244,29 +251,53 @@ def test_errors_and_bad_responses_do_not_downgrade(self):
244251
(404, "Not Found"),
245252
(400, {"error": "invalid app"}),
246253
(500, {"error": "unavailable"}),
254+
(400, {"error": 'Request failed with status=500 Internal Server Error, error={"error":"Service not found: GetSealedEnvPubKey"}'}),
255+
(400, {"error": 'Request failed with status=404 Not Found, error={"error":"other service"}'}),
256+
(400, {"error": 'Request failed with status=404 Not Found, error=invalid'}),
247257
(200, {}),
248258
(200, "invalid JSON"),
249259
]:
250260
with self.subTest(status=status, body=body):
251261
self.cli.client.request.return_value = (status, body)
252262
with self.assertRaises(Exception):
253-
self.cli.encrypt_env_payload(self.envs, self.app_id, None)
263+
self.cli.encrypt_env_payload(self.envs, self.app_id, None, True)
254264
self.cli.client.request.side_effect = TimeoutError("test timeout")
255265
with self.assertRaises(TimeoutError):
256-
self.cli.encrypt_env_payload(self.envs, self.app_id, None)
266+
self.cli.encrypt_env_payload(self.envs, self.app_id, None, True)
257267
self.cli.get_app_env_encrypt_pub_key.assert_not_called()
258268

259269
def test_direct_kms_verifies_pin_and_does_not_forward_vmm_credentials(self):
260270
with patch.object(VMM_CLI, "VmmClient") as client:
261271
client.return_value.request.return_value = (200, self.response)
262272
self.decrypt(
263273
self.cli.encrypt_env_payload(
264-
self.envs, self.app_id, "https://kms.example"
274+
self.envs, self.app_id, "https://kms.example", True
265275
)
266276
)
267277
client.assert_called_once_with("https://kms.example", verify_tls=False)
268278
self.cli.get_app_env_encrypt_pub_key.assert_not_called()
269279

280+
def test_image_downgrade_and_env_replacement_share_one_request(self):
281+
def rpc(method, params=None):
282+
if method == "GetInfo":
283+
return {"found": True, "info": {"app_id": self.app_id, "configuration": {
284+
"image": "new", "compose_file": '{"kms_enabled":true}',
285+
}}}
286+
if method == "ListImages":
287+
return {"images": [{"name": "new", "sealed_env": True}, {"name": "old"}]}
288+
return {}
289+
self.cli.rpc_call = Mock(side_effect=rpc)
290+
with patch.object(VMM_CLI, "parse_env_file", return_value=self.envs), contextlib.redirect_stdout(io.StringIO()):
291+
self.cli.update_vm("vm", image="old", env_file="env", kms_urls=["https://kms.example"])
292+
calls = self.cli.rpc_call.call_args_list
293+
self.assertNotIn("ResizeVm", [call.args[0] for call in calls])
294+
method, payload = calls[-1].args
295+
self.assertEqual(method, "UpgradeApp")
296+
self.assertEqual(payload["image"], "old")
297+
self.assertIn("encrypted_env", payload)
298+
self.assertNotIn("sealed_env", payload)
299+
self.cli.client.request.assert_not_called()
300+
270301
def test_create_update_and_update_env_send_only_the_negotiated_field(self):
271302
compose = json.dumps({"kms_enabled": True})
272303
args = argparse.Namespace(
@@ -299,12 +330,14 @@ def test_create_update_and_update_env_send_only_the_negotiated_field(self):
299330
)
300331

301332
def rpc(method, params=None):
333+
if method == "ListImages":
334+
return {"images": [{"name": "image", **capability}]}
302335
if method == "GetInfo":
303336
return {
304337
"found": True,
305338
"info": {
306339
"app_id": self.app_id,
307-
"configuration": {"compose_file": compose},
340+
"configuration": {"compose_file": compose, "image": "image"},
308341
},
309342
}
310343
return {"id": "vm"}
@@ -317,7 +350,7 @@ def rpc(method, params=None):
317350
patch.object(VMM_CLI, "VmmClient") as client,
318351
contextlib.redirect_stdout(io.StringIO()),
319352
):
320-
for supported in [True, False]:
353+
for capability, supported in [({"sealed_env": True}, True), ({"sealed_env": True}, False), ({"sealed_env": False}, True), ({}, True)]:
321354
client.return_value.request.return_value = (
322355
(200, self.response)
323356
if supported
@@ -335,7 +368,7 @@ def rpc(method, params=None):
335368
action()
336369
method, payload = self.cli.rpc_call.call_args.args
337370
self.assertIn(method, ["CreateVm", "UpgradeApp"])
338-
if supported:
371+
if supported and capability.get("sealed_env"):
339372
self.decrypt(payload)
340373
else:
341374
self.assertIn("encrypted_env", payload)

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

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,12 @@ def list_images(self, json_output: bool = False) -> None:
848848
rows = [[img["name"], img.get("version", "-")] for img in images]
849849
print(format_table(rows, headers))
850850

851+
def image_supports_sealed_env(self, image: Optional[str]) -> bool:
852+
if not image:
853+
return False
854+
images = self.rpc_call("ListImages").get("images", [])
855+
return any(item.get("name") == image and item.get("sealed_env") is True for item in images)
856+
851857
def get_sealed_env_pub_key(
852858
self,
853859
app_id: str,
@@ -864,8 +870,20 @@ def get_sealed_env_pub_key(
864870
body={"app_id": app_id},
865871
)
866872
error = response.get("error") if isinstance(response, dict) else response
867-
if status in (400, 404) and error == "Service not found: GetSealedEnvPubKey":
868-
return None
873+
if status in (400, 404):
874+
missing = "Service not found: GetSealedEnvPubKey"
875+
if error == missing:
876+
return None
877+
if isinstance(error, str):
878+
for upstream in ("400 Bad Request", "404 Not Found"):
879+
prefix = f"Request failed with status={upstream}, error="
880+
if error.startswith(prefix):
881+
try:
882+
forwarded = json.loads(error[len(prefix):])
883+
except json.JSONDecodeError:
884+
break
885+
if isinstance(forwarded, dict) and forwarded.get("error") == missing:
886+
return None
869887
if status != 200:
870888
raise RuntimeError(f"GetSealedEnvPubKey failed with HTTP {status}")
871889
if not isinstance(response, dict):
@@ -874,11 +892,12 @@ def get_sealed_env_pub_key(
874892
return response
875893

876894
def encrypt_env_payload(
877-
self, envs: Dict[str, str], app_id: str, kms_url: Optional[str]
895+
self, envs: Dict[str, str], app_id: str, kms_url: Optional[str],
896+
supports_sealed_env: bool,
878897
) -> Dict:
879-
"""Select the wire field by RPC support, never by environment file contents."""
898+
"""Select encryption using the target image capability and KMS RPC support."""
880899
envs_list = [{"key": k, "value": v} for k, v in envs.items()]
881-
key = self.get_sealed_env_pub_key(app_id, kms_url)
900+
key = self.get_sealed_env_pub_key(app_id, kms_url) if supports_sealed_env else None
882901
if key is not None:
883902
return {
884903
"sealed_env": seal_env(envs_list, key["public_key"], key["root_epoch"], key.get("app_epoch", 0))
@@ -1149,7 +1168,7 @@ def create_vm(self, args) -> None:
11491168
encrypt_url = args.kms_encrypt_url or (
11501169
args.kms_url[0] if args.kms_url else None
11511170
)
1152-
params.update(self.encrypt_env_payload(envs, app_id, encrypt_url))
1171+
params.update(self.encrypt_env_payload(envs, app_id, encrypt_url, self.image_supports_sealed_env(args.image)))
11531172
response = self.rpc_call("CreateVm", params)
11541173
print(f"Created VM with ID: {response.get('id')}")
11551174
return response.get("id")
@@ -1175,7 +1194,9 @@ def update_vm_env(
11751194
compose_file = vm_configuration.get("compose_file")
11761195

11771196
payload = {"id": vm_id}
1178-
payload.update(self.encrypt_env_payload(envs, app_id, kms_urls[0]))
1197+
payload.update(self.encrypt_env_payload(
1198+
envs, app_id, kms_urls[0], self.image_supports_sealed_env(vm_configuration.get("image"))
1199+
))
11791200

11801201
if compose_file:
11811202
try:
@@ -1270,12 +1291,14 @@ def update_vm(
12701291
resize_params["image"] = image
12711292
updates.append(f"image: {image}")
12721293

1273-
if resize_params:
1294+
if resize_params and not env_file:
12741295
resize_params["id"] = vm_id
12751296
self.rpc_call("ResizeVm", resize_params)
12761297

12771298
# handle upgrade operations (compose, env, user_config, ports, gpu)
12781299
upgrade_params = {"id": vm_id}
1300+
if env_file:
1301+
upgrade_params.update(resize_params)
12791302

12801303
# handle compose file updates (docker-compose, prelaunch script, swap)
12811304
needs_compose_update = (
@@ -1337,7 +1360,10 @@ def update_vm(
13371360
vm_configuration = vm_info_response["info"].get("configuration") or {}
13381361
compose_file_content = vm_configuration.get("compose_file")
13391362

1340-
upgrade_params.update(self.encrypt_env_payload(envs, app_id, kms_urls[0]))
1363+
upgrade_params.update(self.encrypt_env_payload(
1364+
envs, app_id, kms_urls[0],
1365+
self.image_supports_sealed_env(image if image is not None else vm_configuration.get("image")),
1366+
))
13411367
updates.append("environment variables")
13421368

13431369
# update allowed_envs in compose file if needed

0 commit comments

Comments
 (0)