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