enclavia_protocol/lib.rs
1pub mod attestation;
2pub mod chain;
3pub mod custody;
4#[cfg(feature = "async-transport")]
5pub mod egress;
6#[cfg(feature = "egress-config")]
7pub mod egress_config;
8pub mod kms_policy;
9pub mod kms_recipient;
10#[cfg(feature = "async-transport")]
11pub mod mesh;
12mod noise;
13pub mod staging;
14
15pub use noise::*;
16
17use serde::{Deserialize, Serialize};
18
19/// Messages sent from the client to the enclave server.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(tag = "type")]
22pub enum ClientMessage {
23 /// Request an attestation document. The server includes the handshake hash
24 /// as the attestation nonce and the current control nonce as user_data.
25 RequestAttestation,
26
27 /// Raw bytes to forward to the inner container (typically an HTTP request).
28 /// The `id` is echoed back in the response so the client can match them.
29 /// One-shot: the server writes the payload, drains the response to EOF,
30 /// and replies with exactly one [`ServerMessage::Data`].
31 Data { id: u64, payload: Vec<u8> },
32
33 /// Authenticated management command. `payload` is a CBOR-encoded
34 /// `ControlCommand`; `signature` is a P-256 ECDSA raw r||s 64-byte
35 /// signature over `payload` produced with the enclave's control private
36 /// key. The server verifies the signature against the control public key
37 /// baked into the EIF and the embedded nonce against its current
38 /// single-use nonce.
39 Control {
40 payload: Vec<u8>,
41 signature: Vec<u8>,
42 },
43
44 /// Fetch the current single-use control nonce without consuming it.
45 /// Answered by [`ServerMessage::ControlNonce`]. The nonce is only
46 /// consumed when a full `Control` message is processed (success OR
47 /// failure). Use this to learn the nonce before constructing a signed
48 /// `ControlCommand` to embed in it.
49 GetControlNonce,
50
51 /// Open a bidirectional byte stream to the inner container. The server
52 /// writes `payload` (typically an HTTP/1.1 upgrade request) to the
53 /// container's TCP socket, then pumps bytes both ways until either side
54 /// closes: container reads come back as [`ServerMessage::StreamData`],
55 /// client follow-ups arrive as [`ClientMessage::StreamData`]. The server
56 /// does NOT inspect the payload — it is the caller's job (e.g. the SDK)
57 /// to recognize `101 Switching Protocols` (or any other response shape)
58 /// in the returned bytes. This keeps the in-enclave protocol small enough
59 /// that a non-Rust frontend (a future nginx C module, a WASM SDK) can
60 /// implement it without an HTTP parser.
61 OpenStream { id: u64, payload: Vec<u8> },
62
63 /// Additional bytes sent into an open stream (e.g. WebSocket payload
64 /// frames). The `id` matches the original `OpenStream` request.
65 StreamData { id: u64, payload: Vec<u8> },
66
67 /// Close one or both halves of an open stream. `half = Write` signals
68 /// that the client is done sending (the server should `shutdown(WRITE)` on
69 /// the inner TCP), `half = Both` tears the stream down.
70 StreamClose { id: u64, half: StreamHalf },
71}
72
73/// Which halves of an upgraded stream a [`ClientMessage::StreamClose`] tears
74/// down.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76pub enum StreamHalf {
77 /// Half-close: the client is done writing, but still expects to read.
78 Write,
79 /// Full close: both directions torn down.
80 Both,
81}
82
83/// Messages sent from the enclave server to the client.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85#[serde(tag = "type")]
86pub enum ServerMessage {
87 /// Attestation document (COSE_Sign1 in enclave mode, raw nonce in debug
88 /// mode). `control_nonce` is the current per-boot single-use nonce that
89 /// must be embedded in the next signed `ControlCommand`.
90 Attestation {
91 data: Vec<u8>,
92 control_nonce: [u8; 32],
93 },
94
95 /// Response to [`ClientMessage::GetControlNonce`]. Returns the current
96 /// single-use nonce. Unauthenticated: the nonce is not secret, only
97 /// anti-replay. The nonce is NOT consumed by this fetch; it is only
98 /// consumed when the server processes a full `Control` message (success
99 /// or failure). The backend must fetch the nonce and then immediately
100 /// send its signed `Control` without any intervening messages from
101 /// another client that might rotate the nonce.
102 ControlNonce { nonce: [u8; 32] },
103
104 /// Raw bytes received from the inner container (typically an HTTP response).
105 /// The `id` matches the corresponding `ClientMessage::Data` request.
106 Data { id: u64, payload: Vec<u8> },
107
108 /// Error forwarding to the inner container.
109 Error { id: u64, message: String },
110
111 /// Result of a `Control` command. The control nonce was rotated whether
112 /// or not the command succeeded — the next signed command must use the
113 /// new nonce, fetched via a fresh `GetControlNonce` or
114 /// `RequestAttestation`.
115 ControlResult { success: bool, message: String },
116
117 /// Bytes read out of an open inner-container connection. For an
118 /// [`ClientMessage::OpenStream`] this carries everything the workload
119 /// writes back, including the initial HTTP response head: the client is
120 /// responsible for any parsing.
121 StreamData { id: u64, payload: Vec<u8> },
122
123 /// The open stream has been closed by the server side (workload EOF or
124 /// error). After this, no further `StreamData` for this `id` will arrive.
125 StreamClose { id: u64 },
126}
127
128/// Storage re-key parameters for a `PrepareUpgrade` control command. Only
129/// provided for enclaves that have a persistent LUKS-backed storage volume;
130/// `None` for stateless enclaves.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct RekeyParams {
133 /// DER-encoded SubjectPublicKeyInfo of the new RSA-OAEP KMS key.
134 /// The enclave wraps a freshly-generated passphrase under this key and
135 /// stores the ciphertext in the key blob alongside `new_key_id`.
136 pub new_public_key: Vec<u8>,
137 /// Identifier of the new KMS key (ARN, mock-kms id, etc.). Recorded in
138 /// the key blob so the post-upgrade enclave decrypts via the right key.
139 pub new_key_id: String,
140}
141
142/// Inner payload of a signed control command. Serialized as CBOR before
143/// signing — the wire-level signature covers the exact bytes the verifier
144/// then deserializes, so re-encoding skew can't break verification.
145///
146/// # Wire-stability note
147///
148/// `PrepareUpgrade` was redesigned as part of the public upgrade-chain
149/// work. There were no live senders
150/// of the previous shape (`new_public_key / new_key_id / nonce`), so the
151/// wire change is safe. The new shape carries the chain artifact inline so
152/// the enclave can emit the chain link as part of the same atomic operation
153/// as the storage re-key, before replying to the backend.
154#[derive(Debug, Clone, Serialize, Deserialize)]
155#[serde(tag = "command")]
156pub enum ControlCommand {
157 /// Staged-upgrade confirmation. The enclave verifies the envelope
158 /// signature, optionally re-keys storage, emits a chain `Upgrade` link to
159 /// `chain-host`, and replies success.
160 PrepareUpgrade {
161 /// CBOR-encoded [`chain::UpgradePayload`]. Becomes the `payload` field
162 /// of the chain link verbatim; the enclave must not re-encode it.
163 payload: Vec<u8>,
164 /// 64-byte raw r||s ECDSA P-256 signature over `payload` under the
165 /// enclave's control private key. Becomes the `signature` field of
166 /// the chain link. The enclave MAY also verify this against its own
167 /// control public key as defence-in-depth (same key signs both the
168 /// envelope and the chain payload).
169 payload_signature: Vec<u8>,
170 /// Storage re-key parameters. `None` for stateless enclaves.
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 rekey: Option<RekeyParams>,
173 /// Single-use per-boot nonce, must equal the server's current nonce.
174 /// Prevents replay across boots without relying on clocks.
175 nonce: [u8; 32],
176 },
177
178 /// Pre-activation revocation. The enclave verifies the envelope
179 /// signature, optionally rolls back the LUKS keyslot added at
180 /// `PrepareUpgrade` time, emits a chain `Revocation` link to
181 /// `chain-host`, and replies success.
182 RevokeUpgrade {
183 /// CBOR-encoded [`chain::RevocationPayload`]. Becomes the `payload`
184 /// field of the chain link verbatim.
185 payload: Vec<u8>,
186 /// 64-byte raw r||s ECDSA P-256 signature over `payload`. Becomes the
187 /// chain link signature.
188 payload_signature: Vec<u8>,
189 /// When `true`, the enclave runs `enclavia-crypto revoke-upgrade` to
190 /// kill the LUKS keyslot added at prepare time and restore the key
191 /// blob to its pre-prepare state. `false` for stateless enclaves.
192 rollback: bool,
193 /// Single-use per-boot nonce.
194 nonce: [u8; 32],
195 },
196}
197
198/// Shared helper: write a length-prefixed CBOR `ChainLink` to a generic
199/// async stream and wait for the one-byte `0x06` ACK from `chain-host`.
200///
201/// Wire format (matches `chain-host/src/main.rs` and `enclavia-chain-init`):
202/// ```text
203/// [u32 BE length] [CBOR-encoded ChainLink bytes]
204/// ```
205/// After writing, the helper calls `shutdown(WRITE)` then reads exactly one
206/// byte. The byte is expected to be `ACK_BYTE` (`0x06`); any other value is
207/// logged (as a warning by the caller) but not treated as an error since the
208/// link bytes already landed in chain-host's buffer.
209///
210/// Large links (rare; chain attestations are ~5 KiB) are safe because the
211/// write is split into the 4-byte length header and then the body; both are
212/// well under the ~32 KiB per-write vsock limit documented in CLAUDE.md.
213///
214/// Returns `Ok(ack_byte)` on success, `Err` on I/O failure or ACK timeout.
215#[cfg(feature = "async-transport")]
216pub async fn submit_chain_link<S>(
217 stream: &mut S,
218 link: &chain::ChainLink,
219 ack_timeout: std::time::Duration,
220) -> Result<u8, Box<dyn std::error::Error + Send + Sync>>
221where
222 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
223{
224 use tokio::io::{AsyncReadExt, AsyncWriteExt};
225
226 let mut link_bytes = Vec::with_capacity(1024);
227 ciborium::ser::into_writer(link, &mut link_bytes)?;
228
229 let len: u32 = link_bytes
230 .len()
231 .try_into()
232 .map_err(|_| "chain link too large to encode as u32-prefixed frame")?;
233
234 // Write the 4-byte length header then the body in two separate calls.
235 // Each call is well under the ~32 KiB vsock single-write limit.
236 stream.write_all(&len.to_be_bytes()).await?;
237
238 // Chunk body writes at 32 KiB to stay within the vsock per-write limit.
239 const VSOCK_CHUNK: usize = 32 * 1024;
240 for chunk in link_bytes.chunks(VSOCK_CHUNK) {
241 stream.write_all(chunk).await?;
242 }
243
244 stream.shutdown().await?;
245
246 // Wait for the explicit ACK byte from chain-host.
247 let mut ack = [0u8; 1];
248 tokio::time::timeout(ack_timeout, stream.read_exact(&mut ack)).await??;
249 Ok(ack[0])
250}
251
252/// The ACK byte `chain-host` sends after accepting a chain link.
253/// Value `0x06` (ASCII ACK). Must match `chain-host/src/main.rs` constant.
254pub const CHAIN_LINK_ACK: u8 = 0x06;
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 fn cbor_round_trip<T>(value: &T) -> T
261 where
262 T: serde::Serialize + serde::de::DeserializeOwned,
263 {
264 let mut buf = Vec::new();
265 ciborium::into_writer(value, &mut buf).expect("serialize");
266 ciborium::from_reader(buf.as_slice()).expect("deserialize")
267 }
268
269 #[test]
270 fn client_open_stream_round_trip() {
271 let msg = ClientMessage::OpenStream {
272 id: 11,
273 payload: b"GET /ws HTTP/1.1\r\n\r\n".to_vec(),
274 };
275 let back = cbor_round_trip(&msg);
276 match back {
277 ClientMessage::OpenStream { id, payload } => {
278 assert_eq!(id, 11);
279 assert_eq!(payload, b"GET /ws HTTP/1.1\r\n\r\n".to_vec());
280 }
281 _ => panic!("wrong variant"),
282 }
283 }
284
285 #[test]
286 fn client_stream_data_round_trip() {
287 let msg = ClientMessage::StreamData {
288 id: 42,
289 payload: vec![1, 2, 3, 4, 5],
290 };
291 let back = cbor_round_trip(&msg);
292 match back {
293 ClientMessage::StreamData { id, payload } => {
294 assert_eq!(id, 42);
295 assert_eq!(payload, vec![1, 2, 3, 4, 5]);
296 }
297 _ => panic!("wrong variant"),
298 }
299 }
300
301 #[test]
302 fn client_stream_close_round_trip() {
303 for half in [StreamHalf::Write, StreamHalf::Both] {
304 let msg = ClientMessage::StreamClose { id: 7, half };
305 let back = cbor_round_trip(&msg);
306 match back {
307 ClientMessage::StreamClose { id, half: got } => {
308 assert_eq!(id, 7);
309 assert_eq!(got, half);
310 }
311 _ => panic!("wrong variant"),
312 }
313 }
314 }
315
316 #[test]
317 fn server_stream_data_round_trip() {
318 let msg = ServerMessage::StreamData {
319 id: 99,
320 payload: vec![0xde, 0xad, 0xbe, 0xef],
321 };
322 let back = cbor_round_trip(&msg);
323 match back {
324 ServerMessage::StreamData { id, payload } => {
325 assert_eq!(id, 99);
326 assert_eq!(payload, vec![0xde, 0xad, 0xbe, 0xef]);
327 }
328 _ => panic!("wrong variant"),
329 }
330 }
331
332 #[test]
333 fn server_stream_close_round_trip() {
334 let msg = ServerMessage::StreamClose { id: 13 };
335 let back = cbor_round_trip(&msg);
336 match back {
337 ServerMessage::StreamClose { id } => assert_eq!(id, 13),
338 _ => panic!("wrong variant"),
339 }
340 }
341
342 #[test]
343 fn existing_data_variants_still_round_trip() {
344 // Guard against accidental tag-shape changes affecting on-wire compat.
345 let req = ClientMessage::Data {
346 id: 1,
347 payload: b"hello".to_vec(),
348 };
349 let back = cbor_round_trip(&req);
350 match back {
351 ClientMessage::Data { id, payload } => {
352 assert_eq!(id, 1);
353 assert_eq!(payload, b"hello".to_vec());
354 }
355 _ => panic!("wrong variant"),
356 }
357
358 let resp = ServerMessage::Data {
359 id: 1,
360 payload: b"world".to_vec(),
361 };
362 let back = cbor_round_trip(&resp);
363 match back {
364 ServerMessage::Data { id, payload } => {
365 assert_eq!(id, 1);
366 assert_eq!(payload, b"world".to_vec());
367 }
368 _ => panic!("wrong variant"),
369 }
370 }
371
372 #[test]
373 fn prepare_upgrade_round_trip() {
374 let cmd = ControlCommand::PrepareUpgrade {
375 payload: vec![1, 2, 3],
376 payload_signature: vec![0xde; 64],
377 rekey: Some(RekeyParams {
378 new_public_key: vec![0xAB; 32],
379 new_key_id: "arn:aws:kms:us-east-1:123:key/abc".into(),
380 }),
381 nonce: [0x42u8; 32],
382 };
383 let back: ControlCommand = cbor_round_trip(&cmd);
384 match back {
385 ControlCommand::PrepareUpgrade {
386 payload,
387 payload_signature,
388 rekey,
389 nonce,
390 } => {
391 assert_eq!(payload, vec![1, 2, 3]);
392 assert_eq!(payload_signature, vec![0xde; 64]);
393 let rk = rekey.expect("rekey should be Some");
394 assert_eq!(rk.new_public_key, vec![0xAB; 32]);
395 assert_eq!(rk.new_key_id, "arn:aws:kms:us-east-1:123:key/abc");
396 assert_eq!(nonce, [0x42u8; 32]);
397 }
398 _ => panic!("wrong variant"),
399 }
400 }
401
402 #[test]
403 fn prepare_upgrade_stateless_round_trip() {
404 let cmd = ControlCommand::PrepareUpgrade {
405 payload: vec![0xAA],
406 payload_signature: vec![0xBB; 64],
407 rekey: None,
408 nonce: [0x01u8; 32],
409 };
410 let back: ControlCommand = cbor_round_trip(&cmd);
411 match back {
412 ControlCommand::PrepareUpgrade { rekey, .. } => {
413 assert!(rekey.is_none(), "stateless: rekey should be None");
414 }
415 _ => panic!("wrong variant"),
416 }
417 }
418
419 #[test]
420 fn revoke_upgrade_round_trip() {
421 let cmd = ControlCommand::RevokeUpgrade {
422 payload: vec![0xCC; 8],
423 payload_signature: vec![0xDD; 64],
424 rollback: true,
425 nonce: [0x99u8; 32],
426 };
427 let back: ControlCommand = cbor_round_trip(&cmd);
428 match back {
429 ControlCommand::RevokeUpgrade {
430 payload,
431 payload_signature,
432 rollback,
433 nonce,
434 } => {
435 assert_eq!(payload, vec![0xCC; 8]);
436 assert_eq!(payload_signature, vec![0xDD; 64]);
437 assert!(rollback);
438 assert_eq!(nonce, [0x99u8; 32]);
439 }
440 _ => panic!("wrong variant"),
441 }
442 }
443
444 #[test]
445 fn get_control_nonce_round_trip() {
446 let msg = ClientMessage::GetControlNonce;
447 let back = cbor_round_trip(&msg);
448 assert!(matches!(back, ClientMessage::GetControlNonce));
449 }
450
451 #[test]
452 fn server_control_nonce_round_trip() {
453 let nonce = [0x77u8; 32];
454 let msg = ServerMessage::ControlNonce { nonce };
455 let back = cbor_round_trip(&msg);
456 match back {
457 ServerMessage::ControlNonce { nonce: got } => assert_eq!(got, nonce),
458 _ => panic!("wrong variant"),
459 }
460 }
461
462 /// Exact JSON field-name shape lock for `ControlCommand::PrepareUpgrade`.
463 /// Changing any of these names is a wire break; update the test AND add
464 /// a migration note.
465 #[test]
466 fn prepare_upgrade_json_field_names() {
467 let cmd = ControlCommand::PrepareUpgrade {
468 payload: vec![1],
469 payload_signature: vec![2; 64],
470 rekey: Some(RekeyParams {
471 new_public_key: vec![3],
472 new_key_id: "k1".into(),
473 }),
474 nonce: [0u8; 32],
475 };
476 // Use JSON (not CBOR) for readable field-name assertions.
477 let v = serde_json::to_value(&cmd).unwrap();
478 assert_eq!(v["command"], "PrepareUpgrade");
479 assert!(v.get("payload").is_some());
480 assert!(v.get("payload_signature").is_some());
481 assert!(v.get("rekey").is_some());
482 assert_eq!(v["rekey"]["new_key_id"], "k1");
483 assert!(v.get("nonce").is_some());
484 }
485
486 #[test]
487 fn revoke_upgrade_json_field_names() {
488 let cmd = ControlCommand::RevokeUpgrade {
489 payload: vec![1],
490 payload_signature: vec![2; 64],
491 rollback: false,
492 nonce: [0u8; 32],
493 };
494 let v = serde_json::to_value(&cmd).unwrap();
495 assert_eq!(v["command"], "RevokeUpgrade");
496 assert!(v.get("payload").is_some());
497 assert!(v.get("payload_signature").is_some());
498 assert_eq!(v["rollback"], false);
499 assert!(v.get("nonce").is_some());
500 }
501}