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}