Skip to main content

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}