Skip to main content

darkbio_wire/
client.rs

1// wire-rs: encrypted protocol between Ark and host
2// Copyright 2026 Dark Bio AG. All rights reserved.
3
4use crate::framing::Framing;
5use crate::handshake;
6use crate::protocol::{ArkToHost, HostToArk};
7use crate::server::Attestation;
8use crate::session::Session;
9use crate::{
10    CRYPTO_DOMAIN_WIRE, CRYPTO_DOMAIN_WIRE_ARK_TO_HOST, CRYPTO_DOMAIN_WIRE_HOST_TO_ARK, Error,
11};
12use darkbio_crypto::{cbor, cose, xdsa, xhpke};
13use darkbio_trust as trust;
14use std::io::{Read, Write};
15use std::time::{SystemTime, UNIX_EPOCH};
16use tracing::{trace, warn};
17
18/// Maximum number of queued frames skipped while waiting for the ArkHello of a
19/// handshake, before giving up on the server. A well-behaved server only ever leaves
20/// a handful behind, as its writer blocks once the transport buffers fill up.
21pub(crate) const MAX_STALE_FRAMES: usize = 32;
22
23/// Trust policy for the device attestation a server presents in the handshake.
24/// It owns everything the wire deliberately does not (which roots to trust,
25/// self-signing rules, recovery overrides) and decides which Arks a session is
26/// opened with.
27pub trait Verifier {
28    /// Session info extracted from an accepted attestation.
29    type Info;
30
31    /// Verifies the device attestation, returning the server's identity key along
32    /// with any info extracted from the attestation. The handshake signature is
33    /// checked against the returned key, so this decision is what authenticates
34    /// the session. Rejecting the attestation aborts the handshake.
35    fn verify(&self, attestation: &Attestation) -> Result<(xdsa::PublicKey, Self::Info), String>;
36}
37
38/// A pinned identity, accepting any attestation and handing it back as
39/// presented. The handshake is authenticated against the pinned key instead.
40impl Verifier for xdsa::PublicKey {
41    type Info = Attestation;
42
43    fn verify(&self, attestation: &Attestation) -> Result<(xdsa::PublicKey, Self::Info), String> {
44        Ok((self.clone(), attestation.clone()))
45    }
46}
47
48/// Roots of trust, accepting the Arks attested under them. Hardware Arks are
49/// accepted by the hardware roots and emulated Arks by the emulator roots, the
50/// attestation having to be valid at the current time. An Ark that was never
51/// onboarded is rejected, its self-signed attestation being an onboarding
52/// decision rather than one of trust.
53pub struct Roots<'a> {
54    pub hardware: &'a [xdsa::PublicKey], // Roots attesting hardware Arks
55    pub emulator: &'a [xdsa::PublicKey], // Roots attesting emulated Arks
56}
57
58impl Verifier for Roots<'_> {
59    type Info = trust::device::Device;
60
61    fn verify(&self, attestation: &Attestation) -> Result<(xdsa::PublicKey, Self::Info), String> {
62        let now = SystemTime::now()
63            .duration_since(UNIX_EPOCH)
64            .map_err(|err| err.to_string())?
65            .as_secs();
66
67        let device = trust::device::verify(
68            attestation.as_bytes(),
69            self.hardware,
70            self.emulator,
71            Some(now),
72        )
73        .map_err(|err| err.to_string())?;
74        Ok((device.signer.clone(), device))
75    }
76}
77
78/// Client side of the wire, an encrypted transport for issuing protobuf requests
79/// to a connected server. It initiates sessions by signaling a transport reset and
80/// driving the handshake, afterward encrypting outbound and decrypting inbound
81/// messages.
82///
83/// An empty frame from the server means it has no session with the client anymore.
84/// It surfaces as `Error::SessionReset` with the client's session dropped too,
85/// so the caller can handshake again instead of waiting on a dead session.
86///
87/// The device attestation presented in the handshake is not interpreted by the
88/// wire, it is handed to a `Verifier` deciding whether to trust the server.
89pub struct Client<R: Read, W: Write> {
90    framing: Framing<R, W>,   // COBS framed transport for ingress and egress data
91    session: Option<Session>, // Active encrypted session (if handshake completed)
92}
93
94impl<R: Read, W: Write> Client<R, W> {
95    /// Creates a new client side around a low level reader and writer. Reads block
96    /// per the transport's semantics, so a timeout for an unresponsive server must
97    /// be configured on the reader passed in.
98    pub fn new(reader: R, writer: W) -> Self {
99        Self {
100            framing: Framing::new(reader, writer),
101            session: None,
102        }
103    }
104
105    /// Sends a session reset and drives the encrypted handshake with the server:
106    ///
107    ///   1. Client -> Server: HostHello { host_signer, host_crypto }           (plain CBOR)
108    ///   2. Server -> Client: ArkHello  { ark_attest, ark_crypto, a2h_encap }  (cose::seal)
109    ///   3. Client -> Server: HostAck   { h2a_encap }                          (cose::seal)
110    ///
111    /// The verifier receives the raw device attestation from the server's hello and
112    /// its accepted info is returned once the session is established.
113    pub fn handshake<V: Verifier>(&mut self, verifier: &V) -> Result<V::Info, Error> {
114        // Generate ephemeral client keys for this session
115        let host_xdsa_sk = xdsa::SecretKey::generate();
116        let host_xhpke_sk = xhpke::SecretKey::generate();
117        self.handshake_with(verifier, host_xdsa_sk, host_xhpke_sk, None)
118    }
119
120    /// Drives the handshake with the given ephemeral keys, the ack signed at
121    /// the given time instead of now if one is given. This method is internally
122    /// used to generate deterministic test vectors for 3rd party implementations.
123    fn handshake_with<V: Verifier>(
124        &mut self,
125        verifier: &V,
126        host_xdsa_sk: xdsa::SecretKey,
127        host_xhpke_sk: xhpke::SecretKey,
128        timestamp: Option<i64>,
129    ) -> Result<V::Info, Error> {
130        self.session = None;
131
132        // Send two zero bytes: first terminates any interrupted message, second
133        // signals a fresh session.
134        self.framing.send_reset()?;
135
136        let host_xdsa_pk = host_xdsa_sk.public_key();
137        let host_xhpke_pk = host_xhpke_sk.public_key();
138
139        // Message 1: Send HostHello (plain CBOR, COBS-framed)
140        let hello = cbor::encode(&handshake::HostHello {
141            host_signer: host_xdsa_pk.clone(),
142            host_crypto: host_xhpke_pk.clone(),
143        })
144        .map_err(|err| Error::HandshakeFailed(format!("failed to encode client hello: {}", err)))?;
145
146        self.framing.send_packet(&hello)?;
147
148        // Message 2: Read ArkHello (COSE seal'd, COBS-framed). Frames the server
149        // emitted before processing the reset may still be queued, so skip
150        // everything not sealed to the fresh client key.
151        let host_xhpke_fp = host_xhpke_pk.fingerprint();
152
153        let mut stale = 0;
154        let size = loop {
155            // Empty frames are the server signaling an earlier session dropped,
156            // stale junk too by now. So are frames failing to decode, the
157            // leftovers of a transfer that was cut short.
158            let size = match self.framing.next_packet() {
159                Ok(Some(size)) => size,
160                Ok(None) | Err(Error::FrameDecodingFailed(_)) => 0,
161                Err(err) => return Err(err),
162            };
163            let recipient = cose::recipient(&self.framing.decobs_buffer[..size]);
164            if recipient.is_ok_and(|fp| fp == host_xhpke_fp) {
165                break size;
166            }
167            stale += 1;
168            if stale > MAX_STALE_FRAMES {
169                return Err(Error::HandshakeFailed(
170                    "too many stale frames before server hello".into(),
171                ));
172            }
173            warn!("skipping stale frame during handshake");
174        };
175        let auth = handshake::ArkHelloAuth {
176            host_signer: host_xdsa_pk.clone(),
177            host_crypto: host_xhpke_pk.clone(),
178        };
179
180        // Step 2a: Decrypt the outer COSE_Encrypt0 layer
181        let sign1 = cose::decrypt(
182            &self.framing.decobs_buffer[..size],
183            &auth,
184            &host_xhpke_sk,
185            CRYPTO_DOMAIN_WIRE,
186        )
187        .map_err(|err| {
188            Error::HandshakeFailed(format!("failed to decrypt server hello: {}", err))
189        })?;
190
191        // Step 2b: Peek at the unverified payload to discover the server's identity
192        let unverified: handshake::ArkHello = cose::peek(&sign1).map_err(|err| {
193            Error::HandshakeFailed(format!("invalid server hello payload: {}", err))
194        })?;
195
196        // Step 2c: Hand the attestation to the verifier to obtain the server's
197        // identity key and the caller's session info
198        let attestation = Attestation::new(unverified.ark_attest)?;
199        let (ark_identity, info) = verifier
200            .verify(&attestation)
201            .map_err(Error::HandshakeFailed)?;
202
203        // Step 2d: Verify the COSE_Sign1 signature with the discovered identity
204        let ark_hello: handshake::ArkHello =
205            cose::verify(&sign1, &auth, &ark_identity, CRYPTO_DOMAIN_WIRE, None).map_err(
206                |err| Error::HandshakeFailed(format!("server hello signature invalid: {}", err)),
207            )?;
208
209        // Set up the server->Client receiver context
210        let enc_a2h: [u8; xhpke::ENCAP_KEY_SIZE] = ark_hello
211            .a2h_encap
212            .try_into()
213            .map_err(|_| Error::HandshakeFailed("invalid a2h_encap size".into()))?;
214
215        let receiver = host_xhpke_sk
216            .new_receiver(&enc_a2h, CRYPTO_DOMAIN_WIRE_ARK_TO_HOST)
217            .map_err(|err| {
218                Error::HandshakeFailed(format!("client receiver setup failed: {}", err))
219            })?;
220
221        // Set up the Client->server sender context
222        let ark_xhpke_pk = ark_hello.ark_crypto;
223        let (sender, enc_h2a) = ark_xhpke_pk
224            .new_sender(CRYPTO_DOMAIN_WIRE_HOST_TO_ARK)
225            .map_err(|err| {
226                Error::HandshakeFailed(format!("client sender setup failed: {}", err))
227            })?;
228
229        // Message 3: Send HostAck (COSE seal'd, COBS-framed)
230        let ack = handshake::HostAck {
231            h2a_encap: enc_h2a.to_vec(),
232        };
233        let auth = handshake::HostAckAuth {
234            ark_signer: ark_identity,
235            ark_crypto: ark_xhpke_pk.clone(),
236        };
237        let ack = match timestamp {
238            Some(timestamp) => cose::seal_at(
239                &ack,
240                &auth,
241                &host_xdsa_sk,
242                &ark_xhpke_pk,
243                CRYPTO_DOMAIN_WIRE,
244                timestamp,
245            ),
246            None => cose::seal(
247                &ack,
248                &auth,
249                &host_xdsa_sk,
250                &ark_xhpke_pk,
251                CRYPTO_DOMAIN_WIRE,
252            ),
253        }
254        .map_err(|err| Error::HandshakeFailed(format!("failed to seal client ack: {}", err)))?;
255
256        self.framing.send_packet(&ack)?;
257
258        // Session established
259        self.session = Some(Session { sender, receiver });
260        Ok(info)
261    }
262
263    /// Reads the next ark-to-host message, decrypting and protobuf decoding it.
264    /// A frame that cannot be decoded or a packet that cannot be decrypted
265    /// drops the session, as the server's HPKE sequence can no longer be followed.
266    /// So does an empty frame, the server signaling it dropped the session on its
267    /// end. Only a fresh handshake recovers from either.
268    pub fn next_message(&mut self) -> Result<ArkToHost, Error> {
269        // Retrieve the next COBS encoded packet. A skipped frame may have
270        // carried a sealed message, so the session cannot continue past it.
271        // An empty frame is the server telling us it has no session with us.
272        let size = match self.framing.next_packet() {
273            Err(err) => {
274                self.session = None;
275                return Err(err);
276            }
277            Ok(None) => {
278                self.session = None;
279                return Err(Error::SessionReset);
280            }
281            Ok(Some(size)) => size,
282        };
283        // Decrypt the message and parse it with protobuf, dropping the session
284        // if the HPKE sequence cannot be followed anymore
285        let session = self
286            .session
287            .as_mut()
288            .ok_or_else(|| Error::EncryptionFailed("no active session".into()))?;
289
290        let res = match session.open(&self.framing.decobs_buffer[..size]) {
291            Err(err @ Error::EncryptionFailed(_)) => {
292                self.session = None;
293                return Err(err);
294            }
295            Err(err) => return Err(err),
296            Ok(res) => res,
297        };
298        trace!("read ark-to-host message ({} bytes encrypted)", size);
299        Ok(res)
300    }
301
302    /// Protobuf encodes a host-to-ark message, seals it with the session and
303    /// sends it. Fails without an active session, and a failure after sealing
304    /// drops the session, as the server's HPKE sequence can no longer be caught up
305    /// with.
306    pub fn send_message(&mut self, req: HostToArk) -> Result<(), Error> {
307        // Encode and seal the message, oversized messages are rejected before
308        // the HPKE sequence advances, only a failed seal breaks the session
309        let session = self
310            .session
311            .as_mut()
312            .ok_or_else(|| Error::EncryptionFailed("no active session".into()))?;
313
314        let blob = match session.seal(&req, &mut self.framing.encode_buffer) {
315            Err(err @ Error::EncryptionFailed(_)) => {
316                self.session = None;
317                return Err(err);
318            }
319            Err(err) => return Err(err),
320            Ok(blob) => blob,
321        };
322        // Send the sealed message, tearing down the session if the transport
323        // fails to deliver it
324        if let Err(err) = self.framing.send_packet(&blob) {
325            self.session = None;
326            return Err(err);
327        }
328        trace!("sent host-to-ark message ({} bytes)", blob.len());
329        Ok(())
330    }
331
332    /// Test helper running the handshake with the given ephemeral keys instead
333    /// of fresh ones and the ack signed at the given time, so a transcript of
334    /// it can be replayed. Not part of the API.
335    #[doc(hidden)]
336    #[inline]
337    #[cfg(any(test, feature = "bench", feature = "fuzz"))]
338    #[cfg_attr(coverage_nightly, coverage(off))]
339    pub fn handshake_with_keys<V: Verifier>(
340        &mut self,
341        verifier: &V,
342        host_xdsa_sk: xdsa::SecretKey,
343        host_xhpke_sk: xhpke::SecretKey,
344        timestamp: i64,
345    ) -> Result<V::Info, Error> {
346        self.handshake_with(verifier, host_xdsa_sk, host_xhpke_sk, Some(timestamp))
347    }
348
349    /// Test and benchmark helper exposing the framer's `next_packet` with the
350    /// decoded packet as a slice. Not part of the API.
351    #[doc(hidden)]
352    #[inline]
353    #[cfg(any(test, feature = "bench", feature = "fuzz"))]
354    #[cfg_attr(coverage_nightly, coverage(off))]
355    pub fn next_packet_blob(&mut self) -> Result<Option<&[u8]>, Error> {
356        self.framing.next_packet_blob()
357    }
358
359    /// Test and benchmark helper exposing the framer's `send_packet`. Not part
360    /// of the API.
361    #[doc(hidden)]
362    #[inline]
363    #[cfg(any(test, feature = "bench", feature = "fuzz"))]
364    #[cfg_attr(coverage_nightly, coverage(off))]
365    pub fn send_packet_blob(&mut self, packet: &[u8]) -> Result<(), Error> {
366        self.framing.send_packet(packet)
367    }
368
369    /// Test and benchmark helper exposing the framer's `next_frame` with the raw
370    /// frame as a slice. Not part of the API.
371    #[doc(hidden)]
372    #[inline]
373    #[cfg(any(test, feature = "bench", feature = "fuzz"))]
374    #[cfg_attr(coverage_nightly, coverage(off))]
375    pub fn next_frame_blob(&mut self) -> Result<&[u8], Error> {
376        self.framing.next_frame_blob()
377    }
378
379    /// Test and benchmark helper exposing the framer's `send_frame` with the raw
380    /// frame taken from a slice. Not part of the API.
381    #[doc(hidden)]
382    #[inline]
383    #[cfg(any(test, feature = "bench", feature = "fuzz"))]
384    #[cfg_attr(coverage_nightly, coverage(off))]
385    pub fn send_frame_blob(&mut self, frame: &[u8]) -> Result<(), Error> {
386        self.framing.send_frame_blob(frame)
387    }
388}