rag_rat_sync/auth.rs
1//! The node-authorization handshake that gates a session (phase D, #881).
2//!
3//! Runs BEFORE [`crate::session::run_session`], over the same bidirectional stream, and reveals
4//! nothing about the account (not even confirmation that this peer hosts it) until the remote is
5//! authorized. Each side presents a signed transport-node ↔ account-device binding and verifies the
6//! other's under its OWN admission policy — authorization is **mutual**, so a peer handed a
7//! poisoned address never streams its inventory to an impostor.
8//!
9//! Ordering is asymmetric to close the metadata gap without deadlocking (the transfer phase that
10//! follows stays concurrent):
11//! - the **acceptor** reads the dialer's binding and verifies it BEFORE sending its own — an
12//! unauthorized dialer learns nothing, not even the acceptor's binding;
13//! - the **dialer** sends its binding (to the node iroh already authenticated it dialed) then
14//! verifies the acceptor's before proceeding to the data phase.
15//!
16//! A failure closes with one UNIFORM error regardless of cause (wrong account / not on roster / bad
17//! signature / stale) so a peer cannot probe "does this server host account A / know device D".
18
19use std::time::Duration;
20
21use tokio::io::{AsyncRead, AsyncWrite};
22
23use crate::codec::{self, CodecError};
24use crate::wire::Frame;
25
26/// How long a peer may take to send its auth frame before the handshake aborts. Deliberately much
27/// shorter than the data-phase idle timeout: an unauthorized peer that connects and stalls must not
28/// occupy the single-session accept slot for long.
29pub const DEFAULT_PRE_AUTH_TIMEOUT: Duration = Duration::from_secs(10);
30
31/// The largest frame accepted during the auth phase, checked against the length prefix BEFORE any
32/// allocation. A valid auth frame is a ~600-byte cap (domain + tag + account id + a
33/// [`crate::wire::MAX_AUTH_BINDING_BYTES`] binding); 1 KiB leaves headroom while keeping the
34/// pre-auth allocation an unauthenticated peer can force to ~1 KiB, not the data-phase
35/// [`crate::codec::MAX_FRAME_BYTES`].
36const MAX_AUTH_FRAME_BYTES: u32 = 1024;
37
38/// How a peer decides whether to admit a connection. Local policy, never negotiated on the wire —
39/// an impostor cannot assert `Open` to exempt itself from the other side's `Closed`. Per-ACCOUNT,
40/// not per-endpoint: one transport node may legitimately serve several accounts, so a public
41/// account being `Open` must not open a private one on the same endpoint.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum AuthPolicy {
44 /// Admit any peer. For a public, read-only knowledge base (a later slice) or a trusted LAN.
45 Open,
46 /// Admit only a peer whose binding verifies against this account's roster and the connection's
47 /// authenticated remote node id. The default for a private account.
48 Closed,
49 /// Admit a not-yet-roster peer via a one-time invite token — the onboarding/pairing flow. Not
50 /// implemented in this slice (it needs the token issue/redeem exchange); a session configured
51 /// with it fails closed rather than silently admitting.
52 InviteToken,
53}
54
55/// Which end of the connection this peer is — determines the send/verify order above.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum AuthRole {
58 Dialer,
59 Acceptor,
60}
61
62/// The non-stream inputs to one auth handshake, bundled so [`run_auth_phase`] takes the stream
63/// pair, the authorizer, and this — rather than a long argument train.
64#[derive(Debug, Clone, Copy)]
65pub struct AuthConfig {
66 /// Which end this peer is (sets the send/verify order).
67 pub role: AuthRole,
68 /// The account this session is scoped to (named in our own auth frame).
69 pub account_id: [u8; 32],
70 /// Our own transport node id (bound into the binding we present).
71 pub local_node: [u8; 32],
72 /// The peer's iroh-authenticated transport node id (checked against the binding it presents).
73 pub remote_node: [u8; 32],
74 /// How we admit the peer.
75 pub policy: AuthPolicy,
76 /// The CURRENT wall-clock (ms) for this handshake — used to stamp the binding we mint and to
77 /// check the peer's binding freshness. Per-handshake, NOT a store-construction timestamp: a
78 /// reused store would otherwise mint stale bindings and never advance the replay window.
79 pub now_ms: i64,
80 /// How long we wait for the peer's auth frame before aborting.
81 pub pre_auth_timeout: Duration,
82}
83
84/// The account-authorization capability the transport needs from the store: mint our own binding,
85/// and verify a peer's. Both resolve against the store's account + live fold; kept separate from
86/// [`crate::session::SyncStore`] so the auth phase is testable without the data-phase machinery.
87pub trait NodeAuth {
88 /// Our signed binding vouching that `local_node` is this account's local device, stamped
89 /// `now_ms`. `Err` if this store cannot authorize (e.g. no local account/device yet — the
90 /// onboarding case).
91 fn local_binding(&self, local_node: &[u8; 32], now_ms: i64) -> anyhow::Result<Vec<u8>>;
92
93 /// Whether `binding` authorizes a peer whose iroh-authenticated node key is `remote_node`,
94 /// judged fresh against `now_ms`. Returns a plain bool — the store collapses the internal
95 /// failure taxonomy so the wire stays uniform. `Err` is reserved for a real fault (e.g. the
96 /// DB read failed), not a rejected peer.
97 fn authorize(
98 &self,
99 binding: &[u8],
100 remote_node: &[u8; 32],
101 now_ms: i64,
102 ) -> anyhow::Result<bool>;
103}
104
105/// A node-authorization handshake that did not admit the connection.
106#[derive(Debug)]
107pub enum AuthError {
108 /// The transport failed or the peer sent an unreadable frame.
109 Codec(CodecError),
110 /// The peer's binding did not satisfy our admission policy — the UNIFORM refusal (cause
111 /// hidden).
112 Unauthorized,
113 /// The peer sent no auth frame within the pre-auth deadline.
114 Timeout,
115 /// The peer sent something other than an auth frame to open, or a policy we cannot serve.
116 Protocol(String),
117}
118
119impl std::fmt::Display for AuthError {
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 match self {
122 AuthError::Codec(e) => write!(f, "sync auth transport: {e}"),
123 AuthError::Unauthorized => write!(f, "peer is not authorized for this account"),
124 AuthError::Timeout => write!(f, "peer sent no auth frame before the deadline"),
125 AuthError::Protocol(m) => write!(f, "sync auth protocol violation: {m}"),
126 }
127 }
128}
129
130impl std::error::Error for AuthError {}
131
132/// Run the mutual auth handshake. On `Ok(())` both sides have verified each other under their own
133/// policies and the caller may proceed to [`crate::session::run_session`] over the same stream; on
134/// `Err` the caller drops the connection without revealing any inventory.
135pub async fn run_auth_phase<W, R, A>(
136 send: &mut W,
137 recv: &mut R,
138 auth: &A,
139 cfg: AuthConfig,
140) -> Result<(), AuthError>
141where
142 W: AsyncWrite + Unpin,
143 R: AsyncRead + Unpin,
144 A: NodeAuth,
145{
146 match cfg.role {
147 // Acceptor verifies the dialer BEFORE revealing its own binding.
148 AuthRole::Acceptor => {
149 verify_peer(recv, auth, &cfg).await?;
150 send_ours(send, auth, &cfg).await?;
151 },
152 // Dialer presents first (to the node it already authenticated), then verifies the acceptor
153 // before proceeding to the data phase.
154 AuthRole::Dialer => {
155 send_ours(send, auth, &cfg).await?;
156 verify_peer(recv, auth, &cfg).await?;
157 },
158 }
159 Ok(())
160}
161
162async fn send_ours<W: AsyncWrite + Unpin>(
163 send: &mut W,
164 auth: &dyn NodeAuth,
165 cfg: &AuthConfig,
166) -> Result<(), AuthError> {
167 // A store that cannot mint a binding (no local account/device) cannot authorize — fail closed.
168 let binding =
169 auth.local_binding(&cfg.local_node, cfg.now_ms).map_err(|_| AuthError::Unauthorized)?;
170 // Bound the WRITE by the same pre-auth deadline as the read: a peer that opens the stream but
171 // never grants receive credit would otherwise hang this write forever, blocking the acceptor's
172 // single-session accept slot — a pre-auth DoS.
173 let frame = Frame::Auth { account_id: cfg.account_id, binding };
174 match tokio::time::timeout(cfg.pre_auth_timeout, codec::write_frame(send, &frame)).await {
175 Ok(Ok(())) => Ok(()),
176 Ok(Err(e)) => Err(AuthError::Codec(e)),
177 Err(_elapsed) => Err(AuthError::Timeout),
178 }
179}
180
181async fn verify_peer<R: AsyncRead + Unpin>(
182 recv: &mut R,
183 auth: &dyn NodeAuth,
184 cfg: &AuthConfig,
185) -> Result<(), AuthError> {
186 let read = codec::read_frame_within(recv, MAX_AUTH_FRAME_BYTES);
187 let frame = match tokio::time::timeout(cfg.pre_auth_timeout, read).await {
188 Ok(Ok(frame)) => frame,
189 Ok(Err(e)) => return Err(AuthError::Codec(e)),
190 Err(_elapsed) => return Err(AuthError::Timeout),
191 };
192 let Frame::Auth { account_id: peer_account, binding } = frame else {
193 return Err(AuthError::Protocol("peer did not open with an auth frame".into()));
194 };
195 // Account scope is enforced regardless of policy — even an Open endpoint must not proceed to
196 // the data phase (whose Hello reveals the hosted account id + inventory) for a peer that
197 // named a DIFFERENT account. Open relaxes the BINDING check, never the scope. Uniform error
198 // either way.
199 if peer_account != cfg.account_id {
200 return Err(AuthError::Unauthorized);
201 }
202 // Every rejection below is the SAME uniform error — no not-on-roster / bad-sig distinction on
203 // the wire.
204 match cfg.policy {
205 AuthPolicy::Open => Ok(()),
206 AuthPolicy::Closed => {
207 match auth.authorize(&binding, &cfg.remote_node, cfg.now_ms) {
208 Ok(true) => Ok(()),
209 Ok(false) => Err(AuthError::Unauthorized),
210 // A real fault (DB read failed) is not the same as a rejected peer, but it still
211 // means we cannot admit — surface it uniformly rather than admitting on error.
212 Err(_) => Err(AuthError::Unauthorized),
213 }
214 },
215 AuthPolicy::InviteToken =>
216 Err(AuthError::Protocol("invite-token admission is not supported yet".into())),
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 const ACCT: [u8; 32] = [1u8; 32];
225 const D_NODE: [u8; 32] = [2u8; 32];
226 const A_NODE: [u8; 32] = [3u8; 32];
227
228 /// A fake authorizer that tests the PROTOCOL (ordering, mutual verification, uniform refusal)
229 /// independently of the binding crypto (covered by the oplog `node_binding` tests).
230 struct FakeAuth {
231 binding: Vec<u8>,
232 authorize_ok: bool,
233 }
234
235 impl NodeAuth for FakeAuth {
236 fn local_binding(&self, _local_node: &[u8; 32], _now_ms: i64) -> anyhow::Result<Vec<u8>> {
237 Ok(self.binding.clone())
238 }
239
240 fn authorize(
241 &self,
242 _binding: &[u8],
243 _remote_node: &[u8; 32],
244 _now_ms: i64,
245 ) -> anyhow::Result<bool> {
246 Ok(self.authorize_ok)
247 }
248 }
249
250 async fn run_pair(
251 dialer: FakeAuth,
252 dialer_account: [u8; 32],
253 dialer_policy: AuthPolicy,
254 acceptor: FakeAuth,
255 acceptor_policy: AuthPolicy,
256 ) -> (Result<(), AuthError>, Result<(), AuthError>) {
257 let (mut d_send, mut a_recv) = tokio::io::duplex(1 << 16);
258 let (mut a_send, mut d_recv) = tokio::io::duplex(1 << 16);
259 // Short: the happy path never waits, and a refusal leaves the peer's read pending (the
260 // duplex send half is not dropped until this fn returns, unlike a real connection
261 // that closes on abort), so a tight timeout keeps the failure-path tests fast.
262 let timeout = Duration::from_millis(300);
263 let dialer_side = run_auth_phase(&mut d_send, &mut d_recv, &dialer, AuthConfig {
264 role: AuthRole::Dialer,
265 account_id: dialer_account,
266 local_node: D_NODE,
267 remote_node: A_NODE,
268 policy: dialer_policy,
269 now_ms: 1,
270 pre_auth_timeout: timeout,
271 });
272 let acceptor_side = run_auth_phase(&mut a_send, &mut a_recv, &acceptor, AuthConfig {
273 role: AuthRole::Acceptor,
274 account_id: ACCT,
275 local_node: A_NODE,
276 remote_node: D_NODE,
277 policy: acceptor_policy,
278 now_ms: 1,
279 pre_auth_timeout: timeout,
280 });
281 tokio::join!(dialer_side, acceptor_side)
282 }
283
284 fn ok_auth() -> FakeAuth {
285 FakeAuth { binding: vec![1, 2, 3], authorize_ok: true }
286 }
287
288 #[tokio::test]
289 async fn mutual_closed_authorization_admits_both() {
290 let (d, a) =
291 run_pair(ok_auth(), ACCT, AuthPolicy::Closed, ok_auth(), AuthPolicy::Closed).await;
292 assert!(d.is_ok() && a.is_ok(), "both sides authorized each other: {d:?} {a:?}");
293 }
294
295 #[tokio::test]
296 async fn an_unauthorized_dialer_is_refused_before_the_acceptor_reveals_its_binding() {
297 // The acceptor rejects the dialer. Because the acceptor verifies BEFORE sending its own
298 // binding, it aborts without revealing anything — the dialer's read then fails (no Auth
299 // frame arrives). This is the mutual-auth ordering that stops inventory (and here even the
300 // acceptor's binding) leaking to an unauthorized peer.
301 let acceptor = FakeAuth { binding: vec![9, 9, 9], authorize_ok: false };
302 let (dialer, accept) =
303 run_pair(ok_auth(), ACCT, AuthPolicy::Closed, acceptor, AuthPolicy::Closed).await;
304 assert!(matches!(accept, Err(AuthError::Unauthorized)), "acceptor refused: {accept:?}");
305 assert!(dialer.is_err(), "dialer got no acceptor binding — nothing leaked: {dialer:?}");
306 }
307
308 #[tokio::test]
309 async fn open_policy_admits_a_peer_that_would_fail_closed() {
310 // A peer whose binding would never verify is admitted under Open (no verification), so both
311 // sides complete.
312 let anon_dialer = FakeAuth { binding: vec![], authorize_ok: false };
313 let (d, a) =
314 run_pair(anon_dialer, ACCT, AuthPolicy::Open, ok_auth(), AuthPolicy::Open).await;
315 assert!(d.is_ok() && a.is_ok(), "open admits anyone: {d:?} {a:?}");
316 }
317
318 #[tokio::test]
319 async fn a_dialer_naming_a_different_account_is_refused_under_closed() {
320 // The dialer presents a binding scoped to a different account than the acceptor serves.
321 let (_dialer, accept) = run_pair(
322 ok_auth(),
323 [0xee; 32], // dialer names a different account
324 AuthPolicy::Closed,
325 ok_auth(),
326 AuthPolicy::Closed,
327 )
328 .await;
329 assert!(
330 matches!(accept, Err(AuthError::Unauthorized)),
331 "the acceptor refuses a cross-account dialer: {accept:?}",
332 );
333 }
334
335 #[tokio::test]
336 async fn even_an_open_endpoint_refuses_a_cross_account_dialer() {
337 // Open relaxes the binding check, NOT the account scope: a peer naming a different account
338 // than the endpoint serves must be refused before run_session could leak the hosted
339 // account's Hello + inventory.
340 let (_dialer, accept) = run_pair(
341 ok_auth(),
342 [0xee; 32], // dialer names a different account
343 AuthPolicy::Open,
344 ok_auth(),
345 AuthPolicy::Open,
346 )
347 .await;
348 assert!(
349 matches!(accept, Err(AuthError::Unauthorized)),
350 "an open endpoint still enforces the account scope: {accept:?}",
351 );
352 }
353
354 #[tokio::test]
355 async fn invite_token_policy_fails_closed_until_implemented() {
356 let (_d, a) =
357 run_pair(ok_auth(), ACCT, AuthPolicy::Closed, ok_auth(), AuthPolicy::InviteToken).await;
358 assert!(
359 matches!(a, Err(AuthError::Protocol(_))),
360 "invite-token is not admitted yet: {a:?}"
361 );
362 }
363
364 #[tokio::test]
365 async fn a_peer_that_sends_nothing_times_out() {
366 // The write end stays alive but silent, so the acceptor's read pends and hits the deadline
367 // (rather than seeing EOF).
368 let (_silent_writer, mut recv) = tokio::io::duplex(1 << 10);
369 let (mut send, _sink) = tokio::io::duplex(1 << 10);
370 let r = run_auth_phase(&mut send, &mut recv, &ok_auth(), AuthConfig {
371 role: AuthRole::Acceptor,
372 account_id: ACCT,
373 local_node: A_NODE,
374 remote_node: D_NODE,
375 policy: AuthPolicy::Closed,
376 now_ms: 1,
377 pre_auth_timeout: Duration::from_millis(100),
378 })
379 .await;
380 assert!(matches!(r, Err(AuthError::Timeout)), "a silent peer times out: {r:?}");
381 }
382
383 #[tokio::test]
384 async fn a_non_auth_first_frame_is_a_protocol_violation() {
385 // The peer opens with a data-phase frame instead of an Auth frame.
386 let (mut peer_send, mut recv) = tokio::io::duplex(1 << 12);
387 let (mut send, _sink) = tokio::io::duplex(1 << 12);
388 codec::write_frame(&mut peer_send, &Frame::Done).await.unwrap();
389 let r = run_auth_phase(&mut send, &mut recv, &ok_auth(), AuthConfig {
390 role: AuthRole::Acceptor,
391 account_id: ACCT,
392 local_node: A_NODE,
393 remote_node: D_NODE,
394 policy: AuthPolicy::Open,
395 now_ms: 1,
396 pre_auth_timeout: Duration::from_millis(200),
397 })
398 .await;
399 assert!(matches!(r, Err(AuthError::Protocol(_))), "a non-auth opener is refused: {r:?}");
400 }
401
402 #[test]
403 fn auth_errors_render() {
404 // Every Display arm is reachable and non-empty.
405 for e in [
406 AuthError::Unauthorized,
407 AuthError::Timeout,
408 AuthError::Protocol("x".into()),
409 AuthError::Codec(CodecError::Eof),
410 ] {
411 assert!(!format!("{e}").is_empty());
412 }
413 }
414}