Skip to main content

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}