enclavia_protocol/mesh.rs
1//! Wire format for the synchronizer mesh relay.
2//!
3//! The synchronizer cluster is a set of in-enclave nodes that need to
4//! talk to one another. An in-enclave node cannot dial another enclave
5//! directly: it can only reach its own host over vsock. The future
6//! `mesh-host` daemon (part of the host-side tooling, following the
7//! `chain-host` conventions: ACK framing, 32 KiB vsock write chunking)
8//! is the host-side relay that bridges these vsock connections into
9//! plain-TCP inter-host links between the parent EC2 instances. The
10//! end-to-end Noise channel between the two enclaves is load-bearing for
11//! confidentiality, so the relay never sees plaintext; in production
12//! `mesh-host`'s inter-host TCP is itself wrapped in WireGuard.
13//!
14//! On every new outbound mesh connection the in-enclave node writes one
15//! length-prefixed CBOR [`Open`] frame naming the peer it wants to reach,
16//! then reads exactly one ack byte, then the bidirectional byte stream
17//! begins. `mesh-host` reads the frame, resolves `target_peer` to that
18//! peer's host endpoint, dials it, and splices. This mirrors
19//! `enclavia-protocol::egress`'s `Open` frame style exactly (4-byte
20//! big-endian length prefix, then CBOR), so the two relays share one
21//! framing idiom.
22//!
23//! ## The single-byte open ack (end-to-end, transits the relays)
24//!
25//! Right after the dialer writes its [`Open`] frame it reads exactly one
26//! ack byte off the same stream:
27//!
28//! * [`OPEN_ACK_OK`] (`0x00`) means the end-to-end guest-to-guest path is
29//! established: the FAR side's relay successfully dialed its local
30//! guest's bootstrap port and the byte after it is the first byte of the
31//! remote enclave's Noise handshake.
32//! * [`OPEN_ACK_FAILED`] (`0x01`), any other byte, or EOF means the path
33//! could not be set up (the target peer is down, the far relay could not
34//! reach its guest, etc). The dialer closes and retries with backoff.
35//!
36//! The ack **originates from the remote relay** (the one nearest the
37//! target enclave) once it has a live connection to that enclave's
38//! bootstrap listener, and it **transits the splice** back to the dialer.
39//! The relays never inspect any byte after the [`Open`] frame: the ack is
40//! the first byte the far relay forwards from its guest-side leg, so it is
41//! the relay's only structured signal that the far leg came up before the
42//! enclave-to-enclave Noise handshake takes over. (`mesh-host`
43//! mirrors this contract: dial the named peer's bootstrap port, and on
44//! success write [`OPEN_ACK_OK`] toward the originating side before
45//! splicing; on failure write [`OPEN_ACK_FAILED`] and close.)
46//!
47//! Use [`read_open_ack`] on the dialer side and [`write_open_ack`] on the
48//! relay side. The inbound (accepting) enclave never sees the ack byte: it
49//! is consumed entirely between the two relays' splice and the dialer.
50//!
51//! Transport (vsock from inside the enclave, AF_VSOCK or
52//! `vhost-device-vsock` UDS on the host) is external to this module:
53//! callers hand in any `AsyncRead + AsyncWrite` and the helpers read or
54//! write the opener frame.
55
56use std::io;
57
58use serde::{Deserialize, Serialize};
59use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
60
61use crate::attestation::CONTROL_PUBKEY_LEN;
62
63/// vsock port the in-enclave node dials to reach `mesh-host`, the
64/// host-side relay that bridges the mesh into inter-host TCP.
65/// Assignment settled in the synchronizer mesh design
66/// pass (5004/5007 from the earlier draft collided with `secrets-host`
67/// and the control channel).
68pub const MESH_VSOCK_PORT: u32 = 5009;
69
70/// vsock port a synchronizer node listens on for cluster bootstrap /
71/// peer-join traffic. Assignment settled in the synchronizer mesh
72/// design pass.
73pub const SYNCHRONIZER_BOOTSTRAP_PORT: u32 = 5008;
74
75/// vsock port a synchronizer node listens on for customer-enclave RPC
76/// (`Pin` / `Get` / `Transition`). Assignment settled in the
77/// synchronizer mesh design pass; supersedes the single-node binary's interim default of 5004.
78pub const SYNCHRONIZER_CLIENT_PORT: u32 = 5010;
79
80/// vsock port a CUSTOMER enclave's `nbd-client` dials on its OWN host
81/// (CID 2) to reach the synchronizer cluster's customer RPC surface.
82///
83/// A customer enclave cannot dial the synchronizer enclaves directly (an
84/// in-enclave binary only reaches its own parent over vsock), so a
85/// host-side relay (same conventions as `egress-host` / `mesh-host`)
86/// listens here and splices the
87/// byte stream to a cluster node's [`SYNCHRONIZER_CLIENT_PORT`] (5010).
88/// The relay never sees plaintext: the end-to-end Noise channel between
89/// the customer enclave and the synchronizer node is load-bearing.
90///
91/// 5010 is the cluster's OWN customer listener (guest-side, inside the
92/// synchronizer enclaves) and 5011 is the names side-channel
93/// (`synchronizer-names-init`), so this had to be a fresh assignment.
94pub const SYNCHRONIZER_CUSTOMER_RELAY_PORT: u32 = 5012;
95
96/// Maximum size (in bytes) of the opener CBOR frame. Plenty of room for
97/// the small `Open` struct we serialize today (a peer name), but tight
98/// enough to reject obvious junk before allocating. Mirrors
99/// `egress::MAX_OPEN_FRAME_SIZE`.
100pub const MAX_OPEN_FRAME_SIZE: u32 = 4096;
101
102/// Open ack byte the far relay writes back toward the dialer once it has a
103/// live connection to the target peer's bootstrap listener. After this
104/// byte the stream is end-to-end guest-to-guest and the Noise handshake
105/// begins. See the module docs for the full contract.
106pub const OPEN_ACK_OK: u8 = 0x00;
107
108/// Open ack byte signalling the far relay could not establish the path to
109/// the target peer (peer down, far relay could not reach its guest, etc).
110/// Any byte that is not [`OPEN_ACK_OK`], plus EOF, is treated the same way
111/// by [`read_open_ack`]: the dialer closes and retries with backoff.
112pub const OPEN_ACK_FAILED: u8 = 0x01;
113
114/// Outcome of reading the single open ack byte on the dialer side.
115///
116/// Returned by [`read_open_ack`]. Only [`OpenAck::Ok`] means the
117/// end-to-end path is up; everything else is a failure the dialer must
118/// retry.
119#[derive(Clone, Copy, Debug, PartialEq, Eq)]
120pub enum OpenAck {
121 /// The far relay reported the end-to-end path is established
122 /// ([`OPEN_ACK_OK`]). The next byte read belongs to the remote
123 /// enclave's Noise handshake.
124 Ok,
125 /// The far relay reported failure: either [`OPEN_ACK_FAILED`] or some
126 /// other unexpected byte. The carried byte is surfaced for logging.
127 Failed(u8),
128 /// The stream hit EOF before any ack byte arrived (the relay closed
129 /// the connection). Treated as a failure.
130 Eof,
131}
132
133/// Read exactly one open ack byte on the dialer side, after writing the
134/// [`Open`] frame.
135///
136/// Maps [`OPEN_ACK_OK`] to [`OpenAck::Ok`], a clean EOF to [`OpenAck::Eof`],
137/// and any other single byte (including [`OPEN_ACK_FAILED`]) to
138/// [`OpenAck::Failed`]. Genuine transport read errors other than EOF
139/// surface as `Err`.
140pub async fn read_open_ack<S>(stream: &mut S) -> io::Result<OpenAck>
141where
142 S: AsyncRead + Unpin,
143{
144 let mut byte = [0u8; 1];
145 match stream.read_exact(&mut byte).await {
146 Ok(_) => {
147 if byte[0] == OPEN_ACK_OK {
148 Ok(OpenAck::Ok)
149 } else {
150 Ok(OpenAck::Failed(byte[0]))
151 }
152 }
153 Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => Ok(OpenAck::Eof),
154 Err(e) => Err(e),
155 }
156}
157
158/// Write the single open ack byte. Used by the relay nearest the target
159/// peer once it has dialed (or failed to dial) that peer's bootstrap
160/// listener: pass `true` once the far leg is up, `false` on failure.
161///
162/// The relays never write any other byte before splicing, so this ack is
163/// the first byte the far relay forwards from its guest-side leg.
164pub async fn write_open_ack<S>(stream: &mut S, ok: bool) -> io::Result<()>
165where
166 S: AsyncWrite + Unpin,
167{
168 let byte = if ok { OPEN_ACK_OK } else { OPEN_ACK_FAILED };
169 stream.write_all(&[byte]).await?;
170 stream.flush().await?;
171 Ok(())
172}
173
174/// Opener frame the in-enclave node writes on every new mesh
175/// connection to `mesh-host`.
176///
177/// Wire format: 4-byte big-endian length prefix, then CBOR-encoded
178/// `Open`, then the bidirectional byte stream begins.
179///
180/// `target_peer` is an opaque, deployment-defined peer identifier (the
181/// logical name of one of the other synchronizer nodes). `mesh-host`
182/// owns the `target_peer -> host endpoint` mapping; this module deals
183/// only in the name.
184#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
185pub struct Open {
186 /// Logical name of the synchronizer peer the node wants to reach.
187 /// Resolved to a concrete host endpoint by `mesh-host`.
188 pub target_peer: String,
189}
190
191/// Errors surfaced while reading the opener frame from a relay stream.
192#[derive(Debug, thiserror::Error)]
193pub enum ReadOpenError {
194 /// Underlying transport I/O failure.
195 #[error("io error: {0}")]
196 Io(#[from] io::Error),
197 /// The claimed frame length exceeds [`MAX_OPEN_FRAME_SIZE`].
198 #[error("opener frame too large: {0} > {MAX_OPEN_FRAME_SIZE}")]
199 FrameTooLarge(u32),
200 /// CBOR decode of the opener frame failed.
201 #[error("failed to decode opener frame: {0}")]
202 Decode(#[from] ciborium::de::Error<io::Error>),
203}
204
205/// Read the length-prefixed CBOR [`Open`] frame from `stream`.
206pub async fn read_open_frame<S>(stream: &mut S) -> Result<Open, ReadOpenError>
207where
208 S: AsyncRead + Unpin,
209{
210 let len = stream.read_u32().await?;
211 if len > MAX_OPEN_FRAME_SIZE {
212 return Err(ReadOpenError::FrameTooLarge(len));
213 }
214 let mut buf = vec![0u8; len as usize];
215 stream.read_exact(&mut buf).await?;
216 let open: Open = ciborium::from_reader(&buf[..])?;
217 Ok(open)
218}
219
220/// Write a length-prefixed CBOR [`Open`] frame to `stream`.
221pub async fn write_open_frame<S>(stream: &mut S, open: &Open) -> io::Result<()>
222where
223 S: AsyncWrite + Unpin,
224{
225 let mut buf = Vec::new();
226 ciborium::into_writer(open, &mut buf).expect("ciborium encode Open frame");
227 let len: u32 = buf
228 .len()
229 .try_into()
230 .expect("Open frame fits in u32 (CBOR encoding is small)");
231 stream.write_all(&len.to_be_bytes()).await?;
232 stream.write_all(&buf).await?;
233 stream.flush().await?;
234 Ok(())
235}
236
237/// Why a mesh peer's identity-key signature over the Noise handshake hash
238/// failed [`verify_mesh_identity`].
239///
240/// The mesh-identity signature is the channel-binding step the mutually
241/// attested mesh layers on top of the attestation document: it proves the
242/// enclave that produced the attestation (and announced
243/// [`crate::attestation::AttestedIdentity::control_pubkey`] in the doc's
244/// `user_data`) is the same party terminating *this* Noise channel, by
245/// signing the channel's handshake hash with the matching private key.
246#[derive(Debug, thiserror::Error)]
247pub enum MeshIdentityError {
248 /// The announced 65-byte SEC1 pubkey did not decode as a P-256 point.
249 #[error("mesh identity pubkey does not decode as SEC1 P-256")]
250 BadPubkey,
251 /// The signature was not a 64-byte raw r||s ECDSA P-256 signature.
252 #[error("mesh identity signature is not 64 bytes raw r||s P-256")]
253 SignatureShape,
254 /// The signature did not verify over the handshake hash under the
255 /// announced pubkey.
256 #[error("mesh identity signature does not verify over the handshake hash")]
257 SignatureInvalid,
258}
259
260/// Sign `handshake_hash` with a per-boot P-256 mesh identity key, returning
261/// the 64-byte raw r||s ECDSA signature.
262///
263/// This is the local half of the mesh channel binding: a node signs the
264/// live Noise handshake hash with the same per-boot identity key whose
265/// public half it stamped into its attestation document's `user_data`. The
266/// peer verifies the result with [`verify_mesh_identity`] after extracting
267/// that pubkey via [`crate::attestation::verify_and_extract`].
268pub fn sign_mesh_identity(signing_key: &p256::ecdsa::SigningKey, handshake_hash: &[u8]) -> Vec<u8> {
269 use p256::ecdsa::{Signature, signature::Signer};
270 let sig: Signature = signing_key.sign(handshake_hash);
271 sig.to_bytes().to_vec()
272}
273
274/// Verify a mesh peer's identity-key signature over the Noise handshake
275/// hash.
276///
277/// `mesh_pubkey` is the 65-byte uncompressed SEC1 P-256 key extracted from
278/// the peer's attestation document (`AttestedIdentity::control_pubkey`);
279/// `signature` is the 64-byte raw r||s ECDSA signature the peer sent over
280/// the channel; `handshake_hash` is this Noise session's hash (identical on
281/// both ends). On success the caller knows the attested enclave is the same
282/// party that terminates this channel, defeating a relay that splices a
283/// captured attestation onto a channel it controls.
284pub fn verify_mesh_identity(
285 mesh_pubkey: &[u8; CONTROL_PUBKEY_LEN],
286 signature: &[u8],
287 handshake_hash: &[u8],
288) -> Result<(), MeshIdentityError> {
289 use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
290 let verifying =
291 VerifyingKey::from_sec1_bytes(mesh_pubkey).map_err(|_| MeshIdentityError::BadPubkey)?;
292 let sig = Signature::from_slice(signature).map_err(|_| MeshIdentityError::SignatureShape)?;
293 verifying
294 .verify(handshake_hash, &sig)
295 .map_err(|_| MeshIdentityError::SignatureInvalid)
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn open_roundtrip_cbor() {
304 let open = Open {
305 target_peer: "synchronizer-az-b".to_string(),
306 };
307 let mut buf = Vec::new();
308 ciborium::into_writer(&open, &mut buf).unwrap();
309 let decoded: Open = ciborium::from_reader(&buf[..]).unwrap();
310 assert_eq!(open, decoded);
311 }
312
313 #[tokio::test]
314 async fn open_frame_roundtrip_over_stream() {
315 let open = Open {
316 target_peer: "synchronizer-az-c".to_string(),
317 };
318 let (mut a, mut b) = tokio::io::duplex(1024);
319 write_open_frame(&mut a, &open).await.unwrap();
320 let decoded = read_open_frame(&mut b).await.unwrap();
321 assert_eq!(open, decoded);
322 }
323
324 #[tokio::test]
325 async fn oversized_open_frame_is_rejected() {
326 let (mut a, mut b) = tokio::io::duplex(1024);
327 // Claim a length past the cap; the reader must bail before
328 // allocating or reading a body.
329 a.write_u32(MAX_OPEN_FRAME_SIZE + 1).await.unwrap();
330 a.flush().await.unwrap();
331 let err = read_open_frame(&mut b).await.unwrap_err();
332 assert!(matches!(err, ReadOpenError::FrameTooLarge(_)), "{err:?}");
333 }
334
335 #[tokio::test]
336 async fn open_ack_ok_roundtrips() {
337 let (mut a, mut b) = tokio::io::duplex(64);
338 write_open_ack(&mut a, true).await.unwrap();
339 assert_eq!(read_open_ack(&mut b).await.unwrap(), OpenAck::Ok);
340 }
341
342 #[tokio::test]
343 async fn open_ack_failed_roundtrips() {
344 let (mut a, mut b) = tokio::io::duplex(64);
345 write_open_ack(&mut a, false).await.unwrap();
346 assert_eq!(
347 read_open_ack(&mut b).await.unwrap(),
348 OpenAck::Failed(OPEN_ACK_FAILED)
349 );
350 }
351
352 #[tokio::test]
353 async fn open_ack_unexpected_byte_is_failure() {
354 let (mut a, mut b) = tokio::io::duplex(64);
355 a.write_all(&[0x7f]).await.unwrap();
356 a.flush().await.unwrap();
357 assert_eq!(read_open_ack(&mut b).await.unwrap(), OpenAck::Failed(0x7f));
358 }
359
360 #[tokio::test]
361 async fn open_ack_eof_is_failure() {
362 let (a, mut b) = tokio::io::duplex(64);
363 // Drop the writer without sending anything: the reader sees EOF.
364 drop(a);
365 assert_eq!(read_open_ack(&mut b).await.unwrap(), OpenAck::Eof);
366 }
367
368 #[test]
369 fn ack_constants_are_distinct() {
370 assert_eq!(OPEN_ACK_OK, 0x00);
371 assert_eq!(OPEN_ACK_FAILED, 0x01);
372 assert_ne!(OPEN_ACK_OK, OPEN_ACK_FAILED);
373 }
374
375 fn mesh_keypair() -> (p256::ecdsa::SigningKey, [u8; CONTROL_PUBKEY_LEN]) {
376 use p256::ecdsa::SigningKey;
377 let mut scalar = [0u8; 32];
378 scalar[0] = 0x01;
379 scalar[1] = 0x42;
380 let sk = SigningKey::from_slice(&scalar).unwrap();
381 let mut pk = [0u8; CONTROL_PUBKEY_LEN];
382 pk.copy_from_slice(sk.verifying_key().to_encoded_point(false).as_bytes());
383 (sk, pk)
384 }
385
386 #[test]
387 fn mesh_identity_signature_roundtrips() {
388 let (sk, pk) = mesh_keypair();
389 let hh = [0xab; 32];
390 let sig = sign_mesh_identity(&sk, &hh);
391 verify_mesh_identity(&pk, &sig, &hh).expect("verify");
392 }
393
394 #[test]
395 fn mesh_identity_rejects_wrong_handshake_hash() {
396 let (sk, pk) = mesh_keypair();
397 let sig = sign_mesh_identity(&sk, &[0x01; 32]);
398 let err = verify_mesh_identity(&pk, &sig, &[0x02; 32]).unwrap_err();
399 assert!(
400 matches!(err, MeshIdentityError::SignatureInvalid),
401 "{err:?}"
402 );
403 }
404
405 #[test]
406 fn mesh_identity_rejects_wrong_signer() {
407 let (sk, _pk) = mesh_keypair();
408 let hh = [0xcd; 32];
409 let sig = sign_mesh_identity(&sk, &hh);
410 // A different, structurally-valid SEC1 pubkey: the signature must
411 // not verify under it.
412 let mut other = [0u8; 32];
413 other[0] = 0x01;
414 other[1] = 0x99;
415 let other_sk = p256::ecdsa::SigningKey::from_slice(&other).unwrap();
416 let mut other_pk = [0u8; CONTROL_PUBKEY_LEN];
417 other_pk.copy_from_slice(other_sk.verifying_key().to_encoded_point(false).as_bytes());
418 let err = verify_mesh_identity(&other_pk, &sig, &hh).unwrap_err();
419 assert!(
420 matches!(err, MeshIdentityError::SignatureInvalid),
421 "{err:?}"
422 );
423 }
424
425 #[test]
426 fn mesh_identity_rejects_malformed_signature() {
427 let (_sk, pk) = mesh_keypair();
428 let err = verify_mesh_identity(&pk, &[0xde, 0xad], &[0x00; 32]).unwrap_err();
429 assert!(matches!(err, MeshIdentityError::SignatureShape), "{err:?}");
430 }
431
432 #[test]
433 fn mesh_identity_rejects_bad_pubkey() {
434 let (sk, _pk) = mesh_keypair();
435 let hh = [0x11; 32];
436 let sig = sign_mesh_identity(&sk, &hh);
437 let bogus = [0u8; CONTROL_PUBKEY_LEN];
438 let err = verify_mesh_identity(&bogus, &sig, &hh).unwrap_err();
439 assert!(matches!(err, MeshIdentityError::BadPubkey), "{err:?}");
440 }
441
442 #[test]
443 fn ports_are_distinct_and_match_design() {
444 assert_eq!(SYNCHRONIZER_BOOTSTRAP_PORT, 5008);
445 assert_eq!(MESH_VSOCK_PORT, 5009);
446 assert_eq!(SYNCHRONIZER_CLIENT_PORT, 5010);
447 // 5011 is the names side-channel (synchronizer-names-init); the
448 // customer relay had to skip it.
449 assert_eq!(SYNCHRONIZER_CUSTOMER_RELAY_PORT, 5012);
450 // Sanity: no accidental collisions among the assignments.
451 let ports = [
452 SYNCHRONIZER_BOOTSTRAP_PORT,
453 MESH_VSOCK_PORT,
454 SYNCHRONIZER_CLIENT_PORT,
455 SYNCHRONIZER_CUSTOMER_RELAY_PORT,
456 ];
457 for (i, p) in ports.iter().enumerate() {
458 for q in &ports[i + 1..] {
459 assert_ne!(p, q, "mesh ports must be distinct");
460 }
461 }
462 }
463}