use std::time::Duration;
use tokio::io::{AsyncRead, AsyncWrite};
use crate::codec::{self, CodecError};
use crate::wire::Frame;
pub const DEFAULT_PRE_AUTH_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_AUTH_FRAME_BYTES: u32 = 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthPolicy {
Open,
Closed,
InviteToken,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthRole {
Dialer,
Acceptor,
}
#[derive(Debug, Clone, Copy)]
pub struct AuthConfig {
pub role: AuthRole,
pub account_id: [u8; 32],
pub local_node: [u8; 32],
pub remote_node: [u8; 32],
pub policy: AuthPolicy,
pub now_ms: i64,
pub pre_auth_timeout: Duration,
}
pub trait NodeAuth {
fn local_binding(&self, local_node: &[u8; 32], now_ms: i64) -> anyhow::Result<Vec<u8>>;
fn authorize(
&self,
binding: &[u8],
remote_node: &[u8; 32],
now_ms: i64,
) -> anyhow::Result<bool>;
}
#[derive(Debug)]
pub enum AuthError {
Codec(CodecError),
Unauthorized,
Timeout,
Protocol(String),
}
impl std::fmt::Display for AuthError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AuthError::Codec(e) => write!(f, "sync auth transport: {e}"),
AuthError::Unauthorized => write!(f, "peer is not authorized for this account"),
AuthError::Timeout => write!(f, "peer sent no auth frame before the deadline"),
AuthError::Protocol(m) => write!(f, "sync auth protocol violation: {m}"),
}
}
}
impl std::error::Error for AuthError {}
pub async fn run_auth_phase<W, R, A>(
send: &mut W,
recv: &mut R,
auth: &A,
cfg: AuthConfig,
) -> Result<(), AuthError>
where
W: AsyncWrite + Unpin,
R: AsyncRead + Unpin,
A: NodeAuth,
{
match cfg.role {
AuthRole::Acceptor => {
verify_peer(recv, auth, &cfg).await?;
send_ours(send, auth, &cfg).await?;
},
AuthRole::Dialer => {
send_ours(send, auth, &cfg).await?;
verify_peer(recv, auth, &cfg).await?;
},
}
Ok(())
}
async fn send_ours<W: AsyncWrite + Unpin>(
send: &mut W,
auth: &dyn NodeAuth,
cfg: &AuthConfig,
) -> Result<(), AuthError> {
let binding =
auth.local_binding(&cfg.local_node, cfg.now_ms).map_err(|_| AuthError::Unauthorized)?;
let frame = Frame::Auth { account_id: cfg.account_id, binding };
match tokio::time::timeout(cfg.pre_auth_timeout, codec::write_frame(send, &frame)).await {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(AuthError::Codec(e)),
Err(_elapsed) => Err(AuthError::Timeout),
}
}
async fn verify_peer<R: AsyncRead + Unpin>(
recv: &mut R,
auth: &dyn NodeAuth,
cfg: &AuthConfig,
) -> Result<(), AuthError> {
let read = codec::read_frame_within(recv, MAX_AUTH_FRAME_BYTES);
let frame = match tokio::time::timeout(cfg.pre_auth_timeout, read).await {
Ok(Ok(frame)) => frame,
Ok(Err(e)) => return Err(AuthError::Codec(e)),
Err(_elapsed) => return Err(AuthError::Timeout),
};
let Frame::Auth { account_id: peer_account, binding } = frame else {
return Err(AuthError::Protocol("peer did not open with an auth frame".into()));
};
if peer_account != cfg.account_id {
return Err(AuthError::Unauthorized);
}
match cfg.policy {
AuthPolicy::Open => Ok(()),
AuthPolicy::Closed => {
match auth.authorize(&binding, &cfg.remote_node, cfg.now_ms) {
Ok(true) => Ok(()),
Ok(false) => Err(AuthError::Unauthorized),
Err(_) => Err(AuthError::Unauthorized),
}
},
AuthPolicy::InviteToken =>
Err(AuthError::Protocol("invite-token admission is not supported yet".into())),
}
}
#[cfg(test)]
mod tests {
use super::*;
const ACCT: [u8; 32] = [1u8; 32];
const D_NODE: [u8; 32] = [2u8; 32];
const A_NODE: [u8; 32] = [3u8; 32];
struct FakeAuth {
binding: Vec<u8>,
authorize_ok: bool,
}
impl NodeAuth for FakeAuth {
fn local_binding(&self, _local_node: &[u8; 32], _now_ms: i64) -> anyhow::Result<Vec<u8>> {
Ok(self.binding.clone())
}
fn authorize(
&self,
_binding: &[u8],
_remote_node: &[u8; 32],
_now_ms: i64,
) -> anyhow::Result<bool> {
Ok(self.authorize_ok)
}
}
async fn run_pair(
dialer: FakeAuth,
dialer_account: [u8; 32],
dialer_policy: AuthPolicy,
acceptor: FakeAuth,
acceptor_policy: AuthPolicy,
) -> (Result<(), AuthError>, Result<(), AuthError>) {
let (mut d_send, mut a_recv) = tokio::io::duplex(1 << 16);
let (mut a_send, mut d_recv) = tokio::io::duplex(1 << 16);
let timeout = Duration::from_millis(300);
let dialer_side = run_auth_phase(&mut d_send, &mut d_recv, &dialer, AuthConfig {
role: AuthRole::Dialer,
account_id: dialer_account,
local_node: D_NODE,
remote_node: A_NODE,
policy: dialer_policy,
now_ms: 1,
pre_auth_timeout: timeout,
});
let acceptor_side = run_auth_phase(&mut a_send, &mut a_recv, &acceptor, AuthConfig {
role: AuthRole::Acceptor,
account_id: ACCT,
local_node: A_NODE,
remote_node: D_NODE,
policy: acceptor_policy,
now_ms: 1,
pre_auth_timeout: timeout,
});
tokio::join!(dialer_side, acceptor_side)
}
fn ok_auth() -> FakeAuth {
FakeAuth { binding: vec![1, 2, 3], authorize_ok: true }
}
#[tokio::test]
async fn mutual_closed_authorization_admits_both() {
let (d, a) =
run_pair(ok_auth(), ACCT, AuthPolicy::Closed, ok_auth(), AuthPolicy::Closed).await;
assert!(d.is_ok() && a.is_ok(), "both sides authorized each other: {d:?} {a:?}");
}
#[tokio::test]
async fn an_unauthorized_dialer_is_refused_before_the_acceptor_reveals_its_binding() {
let acceptor = FakeAuth { binding: vec![9, 9, 9], authorize_ok: false };
let (dialer, accept) =
run_pair(ok_auth(), ACCT, AuthPolicy::Closed, acceptor, AuthPolicy::Closed).await;
assert!(matches!(accept, Err(AuthError::Unauthorized)), "acceptor refused: {accept:?}");
assert!(dialer.is_err(), "dialer got no acceptor binding — nothing leaked: {dialer:?}");
}
#[tokio::test]
async fn open_policy_admits_a_peer_that_would_fail_closed() {
let anon_dialer = FakeAuth { binding: vec![], authorize_ok: false };
let (d, a) =
run_pair(anon_dialer, ACCT, AuthPolicy::Open, ok_auth(), AuthPolicy::Open).await;
assert!(d.is_ok() && a.is_ok(), "open admits anyone: {d:?} {a:?}");
}
#[tokio::test]
async fn a_dialer_naming_a_different_account_is_refused_under_closed() {
let (_dialer, accept) = run_pair(
ok_auth(),
[0xee; 32], AuthPolicy::Closed,
ok_auth(),
AuthPolicy::Closed,
)
.await;
assert!(
matches!(accept, Err(AuthError::Unauthorized)),
"the acceptor refuses a cross-account dialer: {accept:?}",
);
}
#[tokio::test]
async fn even_an_open_endpoint_refuses_a_cross_account_dialer() {
let (_dialer, accept) = run_pair(
ok_auth(),
[0xee; 32], AuthPolicy::Open,
ok_auth(),
AuthPolicy::Open,
)
.await;
assert!(
matches!(accept, Err(AuthError::Unauthorized)),
"an open endpoint still enforces the account scope: {accept:?}",
);
}
#[tokio::test]
async fn invite_token_policy_fails_closed_until_implemented() {
let (_d, a) =
run_pair(ok_auth(), ACCT, AuthPolicy::Closed, ok_auth(), AuthPolicy::InviteToken).await;
assert!(
matches!(a, Err(AuthError::Protocol(_))),
"invite-token is not admitted yet: {a:?}"
);
}
#[tokio::test]
async fn a_peer_that_sends_nothing_times_out() {
let (_silent_writer, mut recv) = tokio::io::duplex(1 << 10);
let (mut send, _sink) = tokio::io::duplex(1 << 10);
let r = run_auth_phase(&mut send, &mut recv, &ok_auth(), AuthConfig {
role: AuthRole::Acceptor,
account_id: ACCT,
local_node: A_NODE,
remote_node: D_NODE,
policy: AuthPolicy::Closed,
now_ms: 1,
pre_auth_timeout: Duration::from_millis(100),
})
.await;
assert!(matches!(r, Err(AuthError::Timeout)), "a silent peer times out: {r:?}");
}
#[tokio::test]
async fn a_non_auth_first_frame_is_a_protocol_violation() {
let (mut peer_send, mut recv) = tokio::io::duplex(1 << 12);
let (mut send, _sink) = tokio::io::duplex(1 << 12);
codec::write_frame(&mut peer_send, &Frame::Done).await.unwrap();
let r = run_auth_phase(&mut send, &mut recv, &ok_auth(), AuthConfig {
role: AuthRole::Acceptor,
account_id: ACCT,
local_node: A_NODE,
remote_node: D_NODE,
policy: AuthPolicy::Open,
now_ms: 1,
pre_auth_timeout: Duration::from_millis(200),
})
.await;
assert!(matches!(r, Err(AuthError::Protocol(_))), "a non-auth opener is refused: {r:?}");
}
#[test]
fn auth_errors_render() {
for e in [
AuthError::Unauthorized,
AuthError::Timeout,
AuthError::Protocol("x".into()),
AuthError::Codec(CodecError::Eof),
] {
assert!(!format!("{e}").is_empty());
}
}
}