WIP refactor

This commit is contained in:
Quentin 2024-03-08 08:17:03 +01:00
parent bb9cb386b6
commit 1a43ce5ac7
Signed by: quentin
GPG key ID: E9602264D639FF68
80 changed files with 1668 additions and 2649 deletions

1761
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,16 +1,28 @@
[package] [workspace]
name = "aerogramme" resolver = "2"
version = "0.3.0" members = [
authors = ["Alex Auvolat <alex@adnab.me>", "Quentin Dufour <quentin@dufour.io>"] "aero-user",
edition = "2021" "aero-bayou",
license = "EUPL-1.2" "aero-sasl",
description = "A robust email server" "aero-dav",
"aero-dav/fuzz",
# "aero-collections",
# "aero-proto",
# "aerogramme",
]
[lib] default-members = ["aerogramme"]
name = "aerogramme"
path = "src/lib.rs" [workspace.dependencies]
# internal crates
aero-user = { version = "0.3.0", path = "aero-user" }
aero-bayou = { version = "0.3.0", path = "aero-bayou" }
aero-sasl = { version = "0.3.0", path = "aero-sasl" }
aero-dav = { version = "0.3.0", path = "aero-dav" }
#aero-collections = { version = "0.3.0", path = "aero-collections" }
#aero-proto = { version = "0.3.0", path = "aero-proto" }
#aerogramme = { version = "0.3.0", path = "aerogramme" }
[dependencies]
# async runtime # async runtime
tokio = { version = "1.18", default-features = false, features = ["rt", "rt-multi-thread", "io-util", "net", "time", "macros", "sync", "signal", "fs"] } tokio = { version = "1.18", default-features = false, features = ["rt", "rt-multi-thread", "io-util", "net", "time", "macros", "sync", "signal", "fs"] }
tokio-util = { version = "0.7", features = [ "compat" ] } tokio-util = { version = "0.7", features = [ "compat" ] }
@ -80,13 +92,6 @@ aws-sdk-s3 = "1"
aws-smithy-runtime = "1" aws-smithy-runtime = "1"
aws-smithy-runtime-api = "1" aws-smithy-runtime-api = "1"
[dev-dependencies]
[patch.crates-io] [patch.crates-io]
imap-types = { git = "https://github.com/superboum/imap-codec", branch = "custom/aerogramme" } imap-types = { git = "https://github.com/superboum/imap-codec", branch = "custom/aerogramme" }
imap-codec = { git = "https://github.com/superboum/imap-codec", branch = "custom/aerogramme" } imap-codec = { git = "https://github.com/superboum/imap-codec", branch = "custom/aerogramme" }
[[test]]
name = "behavior"
path = "tests/behavior.rs"
harness = false

17
aero-bayou/Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
name = "aero-bayou"
version = "0.3.0"
authors = ["Alex Auvolat <alex@adnab.me>", "Quentin Dufour <quentin@dufour.io>"]
edition = "2021"
license = "EUPL-1.2"
description = "A simplified version of Bayou by Terry et al. (ACM SIGOPS 1995)"
[dependencies]
aero-user.workspace = true
anyhow.workspace = true
log.workspace = true
rand.workspace = true
serde.workspace = true
tokio.workspace = true

View file

@ -1,3 +1,5 @@
mod timestamp
use std::sync::{Arc, Weak}; use std::sync::{Arc, Weak};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@ -7,9 +9,10 @@ use rand::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::{watch, Notify}; use tokio::sync::{watch, Notify};
use crate::cryptoblob::*; use aero_foundations::cryptoblob::*;
use crate::login::Credentials; use aero_foundations::login::Credentials;
use crate::storage; use aero_foundations::storage;
use crate::timestamp::*; use crate::timestamp::*;
const KEEP_STATE_EVERY: usize = 64; const KEEP_STATE_EVERY: usize = 64;

View file

@ -1,7 +1,8 @@
use rand::prelude::*;
use std::str::FromStr; use std::str::FromStr;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use rand::prelude::*;
/// Returns milliseconds since UNIX Epoch /// Returns milliseconds since UNIX Epoch
pub fn now_msec() -> u64 { pub fn now_msec() -> u64 {
SystemTime::now() SystemTime::now()

1
aero-dav/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
target/

14
aero-dav/Cargo.toml Normal file
View file

@ -0,0 +1,14 @@
[package]
name = "aero-dav"
version = "0.3.0"
authors = ["Alex Auvolat <alex@adnab.me>", "Quentin Dufour <quentin@dufour.io>"]
edition = "2021"
license = "EUPL-1.2"
description = "A partial and standalone implementation of the WebDAV protocol and its extensions (eg. CalDAV or CardDAV)"
[dependencies]
quick-xml.workspace = true
http.workspace = true
chrono.workspace = true
tokio.workspace = true
futures.workspace = true

View file

@ -8,17 +8,14 @@ edition = "2021"
cargo-fuzz = true cargo-fuzz = true
[dependencies] [dependencies]
libfuzzer-sys = "0.4" arbitrary = { version = "1", optional = true, features = ["derive"] }
libfuzzer-sys = { version = "0.4", features = ["arbitrary-derive"] }
tokio = { version = "1.18", default-features = false, features = ["rt", "rt-multi-thread", "io-util", "net", "time", "macros", "sync", "signal", "fs"] } tokio = { version = "1.18", default-features = false, features = ["rt", "rt-multi-thread", "io-util", "net", "time", "macros", "sync", "signal", "fs"] }
quick-xml = { version = "0.31", features = ["async-tokio"] } quick-xml = { version = "0.31", features = ["async-tokio"] }
[dependencies.aerogramme] [dependencies.aero-dav]
path = ".." path = ".."
[patch.crates-io]
imap-types = { git = "https://github.com/superboum/imap-codec", branch = "custom/aerogramme" }
imap-codec = { git = "https://github.com/superboum/imap-codec", branch = "custom/aerogramme" }
[[bin]] [[bin]]
name = "dav" name = "dav"
path = "fuzz_targets/dav.rs" path = "fuzz_targets/dav.rs"

126
aero-dav/fuzz/dav.dict Normal file
View file

@ -0,0 +1,126 @@
#
# AFL dictionary for XML
# ----------------------
#
# Several basic syntax elements and attributes, modeled on libxml2.
#
# Created by Michal Zalewski
#
attr_encoding=" encoding=\"1\""
attr_generic=" a=\"1\""
attr_href=" href=\"1\""
attr_standalone=" standalone=\"no\""
attr_version=" version=\"1\""
attr_xml_base=" xml:base=\"1\""
attr_xml_id=" xml:id=\"1\""
attr_xml_lang=" xml:lang=\"1\""
attr_xml_space=" xml:space=\"1\""
attr_xmlns=" xmlns=\"1\""
entity_builtin="&lt;"
entity_decimal="&#1;"
entity_external="&a;"
entity_hex="&#x1;"
string_any="ANY"
string_brackets="[]"
string_cdata="CDATA"
string_col_fallback=":fallback"
string_col_generic=":a"
string_col_include=":include"
string_dashes="--"
string_empty="EMPTY"
string_empty_dblquotes="\"\""
string_empty_quotes="''"
string_entities="ENTITIES"
string_entity="ENTITY"
string_fixed="#FIXED"
string_id="ID"
string_idref="IDREF"
string_idrefs="IDREFS"
string_implied="#IMPLIED"
string_nmtoken="NMTOKEN"
string_nmtokens="NMTOKENS"
string_notation="NOTATION"
string_parentheses="()"
string_pcdata="#PCDATA"
string_percent="%a"
string_public="PUBLIC"
string_required="#REQUIRED"
string_schema=":schema"
string_system="SYSTEM"
string_ucs4="UCS-4"
string_utf16="UTF-16"
string_utf8="UTF-8"
string_xmlns="xmlns:"
tag_attlist="<!ATTLIST"
tag_cdata="<![CDATA["
tag_close="</a>"
tag_doctype="<!DOCTYPE"
tag_element="<!ELEMENT"
tag_entity="<!ENTITY"
tag_ignore="<![IGNORE["
tag_include="<![INCLUDE["
tag_notation="<!NOTATION"
tag_open="<a>"
tag_open_close="<a />"
tag_open_exclamation="<!"
tag_open_q="<?"
tag_sq2_close="]]>"
tag_xml_q="<?xml?>"
"0"
"1"
"activelock"
"allprop"
"cannot-modify-protected-property"
"collection"
"creationdate"
"DAV:"
"depth"
"displayname"
"error"
"exclusive"
"getcontentlanguage"
"getcontentlength"
"getcontenttype"
"getetag"
"getlastmodified"
"href"
"include"
"Infinite"
"infinity"
"location"
"lockdiscovery"
"lockentry"
"lockinfo"
"lockroot"
"lockscope"
"locktoken"
"lock-token-matches-request-uri"
"lock-token-submitted"
"locktype"
"multistatus"
"no-conflicting-lock"
"no-external-entities"
"owner"
"preserved-live-properties"
"prop"
"propertyupdate"
"propfind"
"propfind-finite-depth"
"propname"
"propstat"
"remove"
"resourcetype"
"response"
"responsedescription"
"set"
"shared"
"status"
"supportedlock"
"text/html"
"timeout"
"write"

View file

@ -0,0 +1,196 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use libfuzzer_sys::arbitrary;
use libfuzzer_sys::arbitrary::Arbitrary;
use aero_dav::{types, realization, xml};
use quick_xml::reader::NsReader;
use tokio::runtime::Runtime;
use tokio::io::AsyncWriteExt;
const tokens: [&str; 63] = [
"0",
"1",
"activelock",
"allprop",
"encoding",
"utf-8",
"http://ns.example.com/boxschema/",
"HTTP/1.1 200 OK",
"1997-12-01T18:27:21-08:00",
"Mon, 12 Jan 1998 09:25:56 GMT",
"\"abcdef\"",
"cannot-modify-protected-property",
"collection",
"creationdate",
"DAV:",
"D",
"C",
"xmlns:D",
"depth",
"displayname",
"error",
"exclusive",
"getcontentlanguage",
"getcontentlength",
"getcontenttype",
"getetag",
"getlastmodified",
"href",
"include",
"Infinite",
"infinity",
"location",
"lockdiscovery",
"lockentry",
"lockinfo",
"lockroot",
"lockscope",
"locktoken",
"lock-token-matches-request-uri",
"lock-token-submitted",
"locktype",
"multistatus",
"no-conflicting-lock",
"no-external-entities",
"owner",
"preserved-live-properties",
"prop",
"propertyupdate",
"propfind",
"propfind-finite-depth",
"propname",
"propstat",
"remove",
"resourcetype",
"response",
"responsedescription",
"set",
"shared",
"status",
"supportedlock",
"text/html",
"timeout",
"write",
];
#[derive(Arbitrary)]
enum Token {
Known(usize),
//Unknown(String),
}
impl Token {
fn serialize(&self) -> String {
match self {
Self::Known(i) => tokens[i % tokens.len()].to_string(),
//Self::Unknown(v) => v.to_string(),
}
}
}
#[derive(Arbitrary)]
struct Tag {
//prefix: Option<Token>,
name: Token,
attr: Option<(Token, Token)>,
}
impl Tag {
fn start(&self) -> String {
let mut acc = String::new();
/*if let Some(p) = &self.prefix {
acc.push_str(p.serialize().as_str());
acc.push_str(":");
}*/
acc.push_str("D:");
acc.push_str(self.name.serialize().as_str());
if let Some((k,v)) = &self.attr {
acc.push_str(" ");
acc.push_str(k.serialize().as_str());
acc.push_str("=\"");
acc.push_str(v.serialize().as_str());
acc.push_str("\"");
}
acc
}
fn end(&self) -> String {
let mut acc = String::new();
acc.push_str("D:");
acc.push_str(self.name.serialize().as_str());
acc
}
}
#[derive(Arbitrary)]
enum XmlNode {
Node(Tag, Vec<Self>),
Number(u64),
Text(Token),
}
impl std::fmt::Debug for XmlNode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.serialize())
}
}
impl XmlNode {
fn serialize(&self) -> String {
match self {
Self::Node(tag, children) => {
let stag = tag.start();
match children.is_empty() {
true => format!("<{}/>", stag),
false => format!("<{}>{}</{}>", stag, children.iter().map(|v| v.serialize()).collect::<String>(), tag.end()),
}
},
Self::Number(v) => format!("{}", v),
Self::Text(v) => v.serialize(),
}
}
}
async fn serialize(elem: &impl xml::QWrite) -> Vec<u8> {
let mut buffer = Vec::new();
let mut tokio_buffer = tokio::io::BufWriter::new(&mut buffer);
let q = quick_xml::writer::Writer::new_with_indent(&mut tokio_buffer, b' ', 4);
let ns_to_apply = vec![ ("xmlns:D".into(), "DAV:".into()) ];
let mut writer = xml::Writer { q, ns_to_apply };
elem.qwrite(&mut writer).await.expect("xml serialization");
tokio_buffer.flush().await.expect("tokio buffer flush");
return buffer
}
type Object = types::Multistatus<realization::Core, types::PropValue<realization::Core>>;
fuzz_target!(|nodes: XmlNode| {
let gen = format!("<D:multistatus xmlns:D=\"DAV:\">{}<D:/multistatus>", nodes.serialize());
//println!("--------\n{}", gen);
let data = gen.as_bytes();
let rt = Runtime::new().expect("tokio runtime initialization");
rt.block_on(async {
// 1. Setup fuzzing by finding an input that seems correct, do not crash yet then.
let mut rdr = match xml::Reader::new(NsReader::from_reader(data)).await {
Err(_) => return,
Ok(r) => r,
};
let reference = match rdr.find::<Object>().await {
Err(_) => return,
Ok(m) => m,
};
// 2. Re-serialize the input
let my_serialization = serialize(&reference).await;
// 3. De-serialize my serialization
let mut rdr2 = xml::Reader::new(NsReader::from_reader(my_serialization.as_slice())).await.expect("XML Reader init");
let comparison = rdr2.find::<Object>().await.expect("Deserialize again");
// 4. Both the first decoding and last decoding must be identical
assert_eq!(reference, comparison);
})
});

View file

@ -665,8 +665,8 @@ impl QWrite for TimeRange {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::dav::types as dav; use crate::types as dav;
use crate::dav::realization::Calendar; use crate::realization::Calendar;
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use chrono::{Utc,TimeZone,DateTime}; use chrono::{Utc,TimeZone,DateTime};

View file

@ -26,7 +26,9 @@ use super::xml;
/// processing details can be found in the definition of the DAV:set /// processing details can be found in the definition of the DAV:set
/// instruction in Section 12.13.2 of [RFC2518]. /// instruction in Section 12.13.2 of [RFC2518].
/// ///
/// ```xmlschema
/// <!ELEMENT mkcalendar (DAV:set)> /// <!ELEMENT mkcalendar (DAV:set)>
/// ```
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub struct MkCalendar<E: dav::Extension>(pub dav::Set<E>); pub struct MkCalendar<E: dav::Extension>(pub dav::Set<E>);
@ -197,12 +199,15 @@ pub enum Property {
/// sequence "]]>", which is the end delimiter for the CDATA section. /// sequence "]]>", which is the end delimiter for the CDATA section.
/// ///
/// Definition: /// Definition:
/// ///
/// ```xmlschema
/// <!ELEMENT calendar-timezone (#PCDATA)> /// <!ELEMENT calendar-timezone (#PCDATA)>
/// PCDATA value: an iCalendar object with exactly one VTIMEZONE component. /// PCDATA value: an iCalendar object with exactly one VTIMEZONE component.
/// ```
/// ///
/// Example: /// Example:
/// ///
/// ```xmlschema
/// <C:calendar-timezone /// <C:calendar-timezone
/// xmlns:C="urn:ietf:params:xml:ns:caldav">BEGIN:VCALENDAR /// xmlns:C="urn:ietf:params:xml:ns:caldav">BEGIN:VCALENDAR
/// PRODID:-//Example Corp.//CalDAV Client//EN /// PRODID:-//Example Corp.//CalDAV Client//EN
@ -227,6 +232,7 @@ pub enum Property {
/// END:VTIMEZONE /// END:VTIMEZONE
/// END:VCALENDAR /// END:VCALENDAR
/// </C:calendar-timezone> /// </C:calendar-timezone>
/// ```
//@FIXME we might want to put a buffer here or an iCal parsed object //@FIXME we might want to put a buffer here or an iCal parsed object
CalendarTimezone(String), CalendarTimezone(String),
@ -1123,12 +1129,15 @@ pub enum CalendarSelector<E: dav::Extension> {
/// the targeted calendar component. /// the targeted calendar component.
/// ///
/// Definition: /// Definition:
///
/// ```xmlschema
/// <!ELEMENT comp-filter (is-not-defined | (time-range?, /// <!ELEMENT comp-filter (is-not-defined | (time-range?,
/// prop-filter*, comp-filter*))> /// prop-filter*, comp-filter*))>
/// ///
/// <!ATTLIST comp-filter name CDATA #REQUIRED> /// <!ATTLIST comp-filter name CDATA #REQUIRED>
/// name value: a calendar object or calendar component /// name value: a calendar object or calendar component
/// type (e.g., VEVENT) /// type (e.g., VEVENT)
/// ```
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub struct CompFilter { pub struct CompFilter {
pub name: Component, pub name: Component,
@ -1187,12 +1196,14 @@ pub struct CompFilterMatch {
/// ///
/// Definition: /// Definition:
/// ///
/// <!ELEMENT prop-filter (is-not-defined | /// ```xmlschema
/// ((time-range | text-match)?, /// <!ELEMENT prop-filter (is-not-defined |
/// param-filter*))> /// ((time-range | text-match)?,
/// param-filter*))>
/// ///
/// <!ATTLIST prop-filter name CDATA #REQUIRED> /// <!ATTLIST prop-filter name CDATA #REQUIRED>
/// name value: a calendar property name (e.g., ATTENDEE) /// name value: a calendar property name (e.g., ATTENDEE)
/// ```
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub struct PropFilter { pub struct PropFilter {
pub name: Component, pub name: Component,
@ -1278,10 +1289,12 @@ pub struct TextMatch {
/// ///
/// Definition: /// Definition:
/// ///
/// ```xmlschema
/// <!ELEMENT param-filter (is-not-defined | text-match?)> /// <!ELEMENT param-filter (is-not-defined | text-match?)>
/// ///
/// <!ATTLIST param-filter name CDATA #REQUIRED> /// <!ATTLIST param-filter name CDATA #REQUIRED>
/// name value: a property parameter name (e.g., PARTSTAT) /// name value: a property parameter name (e.g., PARTSTAT)
/// ```
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub struct ParamFilter { pub struct ParamFilter {
pub name: PropertyParameter, pub name: PropertyParameter,

View file

@ -1,7 +1,6 @@
use std::borrow::Cow;
use std::future::Future; use std::future::Future;
use quick_xml::events::{Event, BytesStart, BytesDecl, BytesText}; use quick_xml::events::Event;
use quick_xml::events::attributes::AttrError; use quick_xml::events::attributes::AttrError;
use quick_xml::name::{Namespace, QName, PrefixDeclaration, ResolveResult, ResolveResult::*}; use quick_xml::name::{Namespace, QName, PrefixDeclaration, ResolveResult, ResolveResult::*};
use quick_xml::reader::NsReader; use quick_xml::reader::NsReader;
@ -603,7 +602,7 @@ impl QRead<Href> for Href {
mod tests { mod tests {
use super::*; use super::*;
use chrono::{FixedOffset, DateTime, TimeZone, Utc}; use chrono::{FixedOffset, DateTime, TimeZone, Utc};
use crate::dav::realization::Core; use crate::realization::Core;
#[tokio::test] #[tokio::test]
async fn basic_propfind_propname() { async fn basic_propfind_propname() {

View file

@ -1,10 +1,5 @@
use std::io::Cursor;
use quick_xml::Error as QError; use quick_xml::Error as QError;
use quick_xml::events::{Event, BytesEnd, BytesStart, BytesText}; use quick_xml::events::{Event, BytesText};
use quick_xml::writer::ElementWriter;
use quick_xml::name::PrefixDeclaration;
use tokio::io::AsyncWrite;
use super::types::*; use super::types::*;
use super::xml::{Node, Writer,QWrite,IWrite}; use super::xml::{Node, Writer,QWrite,IWrite};
@ -638,7 +633,7 @@ impl<E: Extension> QWrite for Violation<E> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::dav::realization::Core; use crate::realization::Core;
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
/// To run only the unit tests and avoid the behavior ones: /// To run only the unit tests and avoid the behavior ones:

25
aero-dav/src/lib.rs Normal file
View file

@ -0,0 +1,25 @@
#![feature(type_alias_impl_trait)]
#![feature(async_fn_in_trait)]
#![feature(async_closure)]
#![feature(trait_alias)]
// utils
pub mod error;
pub mod xml;
// webdav
pub mod types;
pub mod encoder;
pub mod decoder;
// calendar
pub mod caltypes;
pub mod calencoder;
pub mod caldecoder;
// wip
mod acltypes;
mod versioningtypes;
// final type
pub mod realization;

View file

@ -3,7 +3,6 @@ use std::fmt::Debug;
use chrono::{DateTime,FixedOffset}; use chrono::{DateTime,FixedOffset};
use super::xml; use super::xml;
use super::error;
/// It's how we implement a DAV extension /// It's how we implement a DAV extension
/// (That's the dark magic part...) /// (That's the dark magic part...)

View file

@ -1,7 +1,8 @@
use tokio::io::{AsyncWrite, AsyncBufRead}; use futures::Future;
use quick_xml::events::{Event, BytesEnd, BytesStart, BytesText}; use quick_xml::events::{Event, BytesStart};
use quick_xml::name::{Namespace, QName, PrefixDeclaration, ResolveResult, ResolveResult::*}; use quick_xml::name::ResolveResult;
use quick_xml::reader::NsReader; use quick_xml::reader::NsReader;
use tokio::io::{AsyncWrite, AsyncBufRead};
use super::error::ParsingError; use super::error::ParsingError;
@ -16,10 +17,10 @@ pub trait IRead = AsyncBufRead + Unpin;
// Serialization/Deserialization traits // Serialization/Deserialization traits
pub trait QWrite { pub trait QWrite {
async fn qwrite(&self, xml: &mut Writer<impl IWrite>) -> Result<(), quick_xml::Error>; fn qwrite(&self, xml: &mut Writer<impl IWrite>) -> impl Future<Output = Result<(), quick_xml::Error>>;
} }
pub trait QRead<T> { pub trait QRead<T> {
async fn qread(xml: &mut Reader<impl IRead>) -> Result<T, ParsingError>; fn qread(xml: &mut Reader<impl IRead>) -> impl Future<Output = Result<T, ParsingError>>;
} }
// The representation of an XML node in Rust // The representation of an XML node in Rust

View file

@ -1,25 +1,3 @@
// utils
pub mod error;
pub mod xml;
// webdav
pub mod types;
pub mod encoder;
pub mod decoder;
// calendar
mod caltypes;
mod calencoder;
mod caldecoder;
// wip
mod acltypes;
mod versioningtypes;
// final type
pub mod realization;
use std::net::SocketAddr; use std::net::SocketAddr;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};

140
aero-proto/sasl.rs Normal file
View file

@ -0,0 +1,140 @@
use std::net::SocketAddr;
use anyhow::{anyhow, bail, Result};
use futures::stream::{FuturesUnordered, StreamExt};
use tokio::io::BufStream;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::watch;
use aero_user::config::AuthConfig;
use aero_user::login::ArcLoginProvider;
pub struct AuthServer {
login_provider: ArcLoginProvider,
bind_addr: SocketAddr,
}
impl AuthServer {
pub fn new(config: AuthConfig, login_provider: ArcLoginProvider) -> Self {
Self {
bind_addr: config.bind_addr,
login_provider,
}
}
pub async fn run(self: Self, mut must_exit: watch::Receiver<bool>) -> Result<()> {
let tcp = TcpListener::bind(self.bind_addr).await?;
tracing::info!(
"SASL Authentication Protocol listening on {:#}",
self.bind_addr
);
let mut connections = FuturesUnordered::new();
while !*must_exit.borrow() {
let wait_conn_finished = async {
if connections.is_empty() {
futures::future::pending().await
} else {
connections.next().await
}
};
let (socket, remote_addr) = tokio::select! {
a = tcp.accept() => a?,
_ = wait_conn_finished => continue,
_ = must_exit.changed() => continue,
};
tracing::info!("AUTH: accepted connection from {}", remote_addr);
let conn = tokio::spawn(
NetLoop::new(socket, self.login_provider.clone(), must_exit.clone()).run_error(),
);
connections.push(conn);
}
drop(tcp);
tracing::info!("AUTH server shutting down, draining remaining connections...");
while connections.next().await.is_some() {}
Ok(())
}
}
struct NetLoop {
login: ArcLoginProvider,
stream: BufStream<TcpStream>,
stop: watch::Receiver<bool>,
state: State,
read_buf: Vec<u8>,
write_buf: BytesMut,
}
impl NetLoop {
fn new(stream: TcpStream, login: ArcLoginProvider, stop: watch::Receiver<bool>) -> Self {
Self {
login,
stream: BufStream::new(stream),
state: State::Init,
stop,
read_buf: Vec::new(),
write_buf: BytesMut::new(),
}
}
async fn run_error(self) {
match self.run().await {
Ok(()) => tracing::info!("Auth session succeeded"),
Err(e) => tracing::error!(err=?e, "Auth session failed"),
}
}
async fn run(mut self) -> Result<()> {
loop {
tokio::select! {
read_res = self.stream.read_until(b'\n', &mut self.read_buf) => {
// Detect EOF / socket close
let bread = read_res?;
if bread == 0 {
tracing::info!("Reading buffer empty, connection has been closed. Exiting AUTH session.");
return Ok(())
}
// Parse command
let (_, cmd) = client_command(&self.read_buf).map_err(|_| anyhow!("Unable to parse command"))?;
tracing::trace!(cmd=?cmd, "Received command");
// Make some progress in our local state
self.state.progress(cmd, &self.login).await;
if matches!(self.state, State::Error) {
bail!("Internal state is in error, previous logs explain what went wrong");
}
// Build response
let srv_cmds = self.state.response();
srv_cmds.iter().try_for_each(|r| {
tracing::trace!(cmd=?r, "Sent command");
r.encode(&mut self.write_buf)
})?;
// Send responses if at least one command response has been generated
if !srv_cmds.is_empty() {
self.stream.write_all(&self.write_buf).await?;
self.stream.flush().await?;
}
// Reset buffers
self.read_buf.clear();
self.write_buf.clear();
},
_ = self.stop.changed() => {
tracing::debug!("Server is stopping, quitting this runner");
return Ok(())
}
}
}
}
}

22
aero-sasl/Cargo.toml Normal file
View file

@ -0,0 +1,22 @@
[package]
name = "aero-sasl"
version = "0.3.0"
authors = ["Alex Auvolat <alex@adnab.me>", "Quentin Dufour <quentin@dufour.io>"]
edition = "2021"
license = "EUPL-1.2"
description = "A partial and standalone implementation of the Dovecot SASL Auth Protocol"
[dependencies]
anyhow.workspace = true
base64.workspace = true
futures.workspace = true
nom.workspace = true
rand.workspace = true
tokio.workspace = true
tokio-util.workspace = true
tracing.workspace = true
hex.workspace = true
#log.workspace = true
#serde.workspace = true

243
aero-sasl/src/decode.rs Normal file
View file

@ -0,0 +1,243 @@
use base64::Engine;
use nom::{
branch::alt,
bytes::complete::{tag, tag_no_case, take, take_while, take_while1},
character::complete::{tab, u16, u64},
combinator::{map, opt, recognize, rest, value},
error::{Error, ErrorKind},
multi::{many1, separated_list0},
sequence::{pair, preceded, tuple},
IResult,
};
use super::types::*;
pub fn client_command<'a>(input: &'a [u8]) -> IResult<&'a [u8], ClientCommand> {
alt((version_command, cpid_command, auth_command, cont_command))(input)
}
/*
fn server_command(buf: &u8) -> IResult<&u8, ServerCommand> {
unimplemented!();
}
*/
// ---------------------
fn version_command<'a>(input: &'a [u8]) -> IResult<&'a [u8], ClientCommand> {
let mut parser = tuple((tag_no_case(b"VERSION"), tab, u64, tab, u64));
let (input, (_, _, major, _, minor)) = parser(input)?;
Ok((input, ClientCommand::Version(Version { major, minor })))
}
pub fn cpid_command<'a>(input: &'a [u8]) -> IResult<&'a [u8], ClientCommand> {
preceded(
pair(tag_no_case(b"CPID"), tab),
map(u64, |v| ClientCommand::Cpid(v)),
)(input)
}
fn mechanism<'a>(input: &'a [u8]) -> IResult<&'a [u8], Mechanism> {
alt((
value(Mechanism::Plain, tag_no_case(b"PLAIN")),
value(Mechanism::Login, tag_no_case(b"LOGIN")),
))(input)
}
fn is_not_tab_or_esc_or_lf(c: u8) -> bool {
c != 0x09 && c != 0x01 && c != 0x0a // TAB or 0x01 or LF
}
fn is_esc<'a>(input: &'a [u8]) -> IResult<&'a [u8], &[u8]> {
preceded(tag(&[0x01]), take(1usize))(input)
}
fn parameter<'a>(input: &'a [u8]) -> IResult<&'a [u8], &[u8]> {
recognize(many1(alt((take_while1(is_not_tab_or_esc_or_lf), is_esc))))(input)
}
fn parameter_str(input: &[u8]) -> IResult<&[u8], String> {
let (input, buf) = parameter(input)?;
std::str::from_utf8(buf)
.map(|v| (input, v.to_string()))
.map_err(|_| nom::Err::Failure(Error::new(input, ErrorKind::TakeWhile1)))
}
fn is_param_name_char(c: u8) -> bool {
is_not_tab_or_esc_or_lf(c) && c != 0x3d // =
}
fn parameter_name(input: &[u8]) -> IResult<&[u8], String> {
let (input, buf) = take_while1(is_param_name_char)(input)?;
std::str::from_utf8(buf)
.map(|v| (input, v.to_string()))
.map_err(|_| nom::Err::Failure(Error::new(input, ErrorKind::TakeWhile1)))
}
fn service<'a>(input: &'a [u8]) -> IResult<&'a [u8], String> {
preceded(tag_no_case("service="), parameter_str)(input)
}
fn auth_option<'a>(input: &'a [u8]) -> IResult<&'a [u8], AuthOption> {
use AuthOption::*;
alt((
alt((
value(Debug, tag_no_case(b"debug")),
value(NoPenalty, tag_no_case(b"no-penalty")),
value(ClientId, tag_no_case(b"client_id")),
value(NoLogin, tag_no_case(b"nologin")),
map(preceded(tag_no_case(b"session="), u64), |id| Session(id)),
map(preceded(tag_no_case(b"lip="), parameter_str), |ip| {
LocalIp(ip)
}),
map(preceded(tag_no_case(b"rip="), parameter_str), |ip| {
RemoteIp(ip)
}),
map(preceded(tag_no_case(b"lport="), u16), |port| {
LocalPort(port)
}),
map(preceded(tag_no_case(b"rport="), u16), |port| {
RemotePort(port)
}),
map(preceded(tag_no_case(b"real_rip="), parameter_str), |ip| {
RealRemoteIp(ip)
}),
map(preceded(tag_no_case(b"real_lip="), parameter_str), |ip| {
RealLocalIp(ip)
}),
map(preceded(tag_no_case(b"real_lport="), u16), |port| {
RealLocalPort(port)
}),
map(preceded(tag_no_case(b"real_rport="), u16), |port| {
RealRemotePort(port)
}),
)),
alt((
map(
preceded(tag_no_case(b"local_name="), parameter_str),
|name| LocalName(name),
),
map(
preceded(tag_no_case(b"forward_views="), parameter),
|views| ForwardViews(views.into()),
),
map(preceded(tag_no_case(b"secured="), parameter_str), |info| {
Secured(Some(info))
}),
value(Secured(None), tag_no_case(b"secured")),
value(CertUsername, tag_no_case(b"cert_username")),
map(preceded(tag_no_case(b"transport="), parameter_str), |ts| {
Transport(ts)
}),
map(
preceded(tag_no_case(b"tls_cipher="), parameter_str),
|cipher| TlsCipher(cipher),
),
map(
preceded(tag_no_case(b"tls_cipher_bits="), parameter_str),
|bits| TlsCipherBits(bits),
),
map(preceded(tag_no_case(b"tls_pfs="), parameter_str), |pfs| {
TlsPfs(pfs)
}),
map(
preceded(tag_no_case(b"tls_protocol="), parameter_str),
|proto| TlsProtocol(proto),
),
map(
preceded(tag_no_case(b"valid-client-cert="), parameter_str),
|cert| ValidClientCert(cert),
),
)),
alt((
map(preceded(tag_no_case(b"resp="), base64), |data| Resp(data)),
map(
tuple((parameter_name, tag(b"="), parameter)),
|(n, _, v)| UnknownPair(n, v.into()),
),
map(parameter, |v| UnknownBool(v.into())),
)),
))(input)
}
fn auth_command<'a>(input: &'a [u8]) -> IResult<&'a [u8], ClientCommand> {
let mut parser = tuple((
tag_no_case(b"AUTH"),
tab,
u64,
tab,
mechanism,
tab,
service,
map(opt(preceded(tab, separated_list0(tab, auth_option))), |o| {
o.unwrap_or(vec![])
}),
));
let (input, (_, _, id, _, mech, _, service, options)) = parser(input)?;
Ok((
input,
ClientCommand::Auth {
id,
mech,
service,
options,
},
))
}
fn is_base64_core(c: u8) -> bool {
c >= 0x30 && c <= 0x39 // 0-9
|| c >= 0x41 && c <= 0x5a // A-Z
|| c >= 0x61 && c <= 0x7a // a-z
|| c == 0x2b // +
|| c == 0x2f // /
}
fn is_base64_pad(c: u8) -> bool {
c == 0x3d // =
}
fn base64(input: &[u8]) -> IResult<&[u8], Vec<u8>> {
let (input, (b64, _)) = tuple((take_while1(is_base64_core), take_while(is_base64_pad)))(input)?;
let data = base64::engine::general_purpose::STANDARD_NO_PAD
.decode(b64)
.map_err(|_| nom::Err::Failure(Error::new(input, ErrorKind::TakeWhile1)))?;
Ok((input, data))
}
/// @FIXME Dovecot does not say if base64 content must be padded or not
fn cont_command<'a>(input: &'a [u8]) -> IResult<&'a [u8], ClientCommand> {
let mut parser = tuple((tag_no_case(b"CONT"), tab, u64, tab, base64));
let (input, (_, _, id, _, data)) = parser(input)?;
Ok((input, ClientCommand::Cont { id, data }))
}
// -----------------------------------------------------------------
//
// SASL DECODING
//
// -----------------------------------------------------------------
fn not_null(c: u8) -> bool {
c != 0x0
}
// impersonated user, login, password
pub fn auth_plain<'a>(input: &'a [u8]) -> IResult<&'a [u8], (&'a [u8], &'a [u8], &'a [u8])> {
map(
tuple((
take_while(not_null),
take(1usize),
take_while(not_null),
take(1usize),
rest,
)),
|(imp, _, user, _, pass)| (imp, user, pass),
)(input)
}

157
aero-sasl/src/encode.rs Normal file
View file

@ -0,0 +1,157 @@
use anyhow::Result;
use base64::Engine;
use tokio_util::bytes::{BufMut, BytesMut};
use super::types::*;
pub trait Encode {
fn encode(&self, out: &mut BytesMut) -> Result<()>;
}
fn tab_enc(out: &mut BytesMut) {
out.put(&[0x09][..])
}
fn lf_enc(out: &mut BytesMut) {
out.put(&[0x0A][..])
}
impl Encode for Mechanism {
fn encode(&self, out: &mut BytesMut) -> Result<()> {
match self {
Self::Plain => out.put(&b"PLAIN"[..]),
Self::Login => out.put(&b"LOGIN"[..]),
}
Ok(())
}
}
impl Encode for MechanismParameters {
fn encode(&self, out: &mut BytesMut) -> Result<()> {
match self {
Self::Anonymous => out.put(&b"anonymous"[..]),
Self::PlainText => out.put(&b"plaintext"[..]),
Self::Dictionary => out.put(&b"dictionary"[..]),
Self::Active => out.put(&b"active"[..]),
Self::ForwardSecrecy => out.put(&b"forward-secrecy"[..]),
Self::MutualAuth => out.put(&b"mutual-auth"[..]),
Self::Private => out.put(&b"private"[..]),
}
Ok(())
}
}
impl Encode for FailCode {
fn encode(&self, out: &mut BytesMut) -> Result<()> {
match self {
Self::TempFail => out.put(&b"temp_fail"[..]),
Self::AuthzFail => out.put(&b"authz_fail"[..]),
Self::UserDisabled => out.put(&b"user_disabled"[..]),
Self::PassExpired => out.put(&b"pass_expired"[..]),
};
Ok(())
}
}
impl Encode for ServerCommand {
fn encode(&self, out: &mut BytesMut) -> Result<()> {
match self {
Self::Version(Version { major, minor }) => {
out.put(&b"VERSION"[..]);
tab_enc(out);
out.put(major.to_string().as_bytes());
tab_enc(out);
out.put(minor.to_string().as_bytes());
lf_enc(out);
}
Self::Spid(pid) => {
out.put(&b"SPID"[..]);
tab_enc(out);
out.put(pid.to_string().as_bytes());
lf_enc(out);
}
Self::Cuid(pid) => {
out.put(&b"CUID"[..]);
tab_enc(out);
out.put(pid.to_string().as_bytes());
lf_enc(out);
}
Self::Cookie(cval) => {
out.put(&b"COOKIE"[..]);
tab_enc(out);
out.put(hex::encode(cval).as_bytes());
lf_enc(out);
}
Self::Mech { kind, parameters } => {
out.put(&b"MECH"[..]);
tab_enc(out);
kind.encode(out)?;
for p in parameters.iter() {
tab_enc(out);
p.encode(out)?;
}
lf_enc(out);
}
Self::Done => {
out.put(&b"DONE"[..]);
lf_enc(out);
}
Self::Cont { id, data } => {
out.put(&b"CONT"[..]);
tab_enc(out);
out.put(id.to_string().as_bytes());
tab_enc(out);
if let Some(rdata) = data {
let b64 = base64::engine::general_purpose::STANDARD.encode(rdata);
out.put(b64.as_bytes());
}
lf_enc(out);
}
Self::Ok {
id,
user_id,
extra_parameters,
} => {
out.put(&b"OK"[..]);
tab_enc(out);
out.put(id.to_string().as_bytes());
if let Some(user) = user_id {
tab_enc(out);
out.put(&b"user="[..]);
out.put(user.as_bytes());
}
for p in extra_parameters.iter() {
tab_enc(out);
out.put(&p[..]);
}
lf_enc(out);
}
Self::Fail {
id,
user_id,
code,
extra_parameters,
} => {
out.put(&b"FAIL"[..]);
tab_enc(out);
out.put(id.to_string().as_bytes());
if let Some(user) = user_id {
tab_enc(out);
out.put(&b"user="[..]);
out.put(user.as_bytes());
}
if let Some(code_val) = code {
tab_enc(out);
out.put(&b"code="[..]);
code_val.encode(out)?;
}
for p in extra_parameters.iter() {
tab_enc(out);
out.put(&p[..]);
}
lf_enc(out);
}
}
Ok(())
}
}

201
aero-sasl/src/flow.rs Normal file
View file

@ -0,0 +1,201 @@
use futures::Future;
use rand::prelude::*;
use super::types::*;
use super::decode::auth_plain;
#[derive(Debug)]
pub enum AuthRes {
Success(String),
Failed(Option<String>, Option<FailCode>),
}
#[derive(Debug)]
pub enum State {
Error,
Init,
HandshakePart(Version),
HandshakeDone,
AuthPlainProgress { id: u64 },
AuthDone { id: u64, res: AuthRes },
}
const SERVER_MAJOR: u64 = 1;
const SERVER_MINOR: u64 = 2;
const EMPTY_AUTHZ: &[u8] = &[];
impl State {
pub fn new() -> Self {
Self::Init
}
async fn try_auth_plain<'a, X, F>(&self, data: &'a [u8], login: X) -> AuthRes
where
X: FnOnce(&'a str, &'a str) -> F,
F: Future<Output=bool>,
{
// Check that we can extract user's login+pass
let (ubin, pbin) = match auth_plain(&data) {
Ok(([], (authz, user, pass))) if authz == user || authz == EMPTY_AUTHZ => (user, pass),
Ok(_) => {
tracing::error!("Impersonating user is not supported");
return AuthRes::Failed(None, None);
}
Err(e) => {
tracing::error!(err=?e, "Could not parse the SASL PLAIN data chunk");
return AuthRes::Failed(None, None);
}
};
// Try to convert it to UTF-8
let (user, password) = match (std::str::from_utf8(ubin), std::str::from_utf8(pbin)) {
(Ok(u), Ok(p)) => (u, p),
_ => {
tracing::error!("Username or password contain invalid UTF-8 characters");
return AuthRes::Failed(None, None);
}
};
// Try to connect user
match login(user, password).await {
true => AuthRes::Success(user.to_string()),
false => {
tracing::warn!("login failed");
AuthRes::Failed(Some(user.to_string()), None)
}
}
}
pub async fn progress<F,X>(&mut self, cmd: ClientCommand, login: X)
where
X: FnOnce(&str, &str) -> F,
F: Future<Output=bool>,
{
let new_state = 'state: {
match (std::mem::replace(self, State::Error), cmd) {
(Self::Init, ClientCommand::Version(v)) => Self::HandshakePart(v),
(Self::HandshakePart(version), ClientCommand::Cpid(_cpid)) => {
if version.major != SERVER_MAJOR {
tracing::error!(
client_major = version.major,
server_major = SERVER_MAJOR,
"Unsupported client major version"
);
break 'state Self::Error;
}
Self::HandshakeDone
}
(
Self::HandshakeDone { .. },
ClientCommand::Auth {
id, mech, options, ..
},
)
| (
Self::AuthDone { .. },
ClientCommand::Auth {
id, mech, options, ..
},
) => {
if mech != Mechanism::Plain {
tracing::error!(mechanism=?mech, "Unsupported Authentication Mechanism");
break 'state Self::AuthDone {
id,
res: AuthRes::Failed(None, None),
};
}
match options.last() {
Some(AuthOption::Resp(data)) => Self::AuthDone {
id,
res: self.try_auth_plain(&data, login).await,
},
_ => Self::AuthPlainProgress { id },
}
}
(Self::AuthPlainProgress { id }, ClientCommand::Cont { id: cid, data }) => {
// Check that ID matches
if cid != id {
tracing::error!(
auth_id = id,
cont_id = cid,
"CONT id does not match AUTH id"
);
break 'state Self::AuthDone {
id,
res: AuthRes::Failed(None, None),
};
}
Self::AuthDone {
id,
res: self.try_auth_plain(&data, login).await,
}
}
_ => {
tracing::error!("This command is not valid in this context");
Self::Error
}
}
};
tracing::debug!(state=?new_state, "Made progress");
*self = new_state;
}
pub fn response(&self) -> Vec<ServerCommand> {
let mut srv_cmd: Vec<ServerCommand> = Vec::new();
match self {
Self::HandshakeDone { .. } => {
srv_cmd.push(ServerCommand::Version(Version {
major: SERVER_MAJOR,
minor: SERVER_MINOR,
}));
srv_cmd.push(ServerCommand::Mech {
kind: Mechanism::Plain,
parameters: vec![MechanismParameters::PlainText],
});
srv_cmd.push(ServerCommand::Spid(15u64));
srv_cmd.push(ServerCommand::Cuid(19350u64));
let mut cookie = [0u8; 16];
thread_rng().fill(&mut cookie);
srv_cmd.push(ServerCommand::Cookie(cookie));
srv_cmd.push(ServerCommand::Done);
}
Self::AuthPlainProgress { id } => {
srv_cmd.push(ServerCommand::Cont {
id: *id,
data: None,
});
}
Self::AuthDone {
id,
res: AuthRes::Success(user),
} => {
srv_cmd.push(ServerCommand::Ok {
id: *id,
user_id: Some(user.to_string()),
extra_parameters: vec![],
});
}
Self::AuthDone {
id,
res: AuthRes::Failed(maybe_user, maybe_failcode),
} => {
srv_cmd.push(ServerCommand::Fail {
id: *id,
user_id: maybe_user.clone(),
code: maybe_failcode.clone(),
extra_parameters: vec![],
});
}
_ => (),
};
srv_cmd
}
}

43
aero-sasl/src/lib.rs Normal file
View file

@ -0,0 +1,43 @@
/// Seek compatibility with the Dovecot Authentication Protocol
///
/// ## Trace
///
/// ```text
/// S: VERSION 1 2
/// S: MECH PLAIN plaintext
/// S: MECH LOGIN plaintext
/// S: SPID 15
/// S: CUID 17654
/// S: COOKIE f56692bee41f471ed01bd83520025305
/// S: DONE
/// C: VERSION 1 2
/// C: CPID 1
///
/// C: AUTH 2 PLAIN service=smtp
/// S: CONT 2
/// C: CONT 2 base64stringFollowingRFC4616==
/// S: OK 2 user=alice@example.tld
///
/// C: AUTH 42 LOGIN service=smtp
/// S: CONT 42 VXNlcm5hbWU6
/// C: CONT 42 b64User
/// S: CONT 42 UGFzc3dvcmQ6
/// C: CONT 42 b64Pass
/// S: FAIL 42 user=alice
/// ```
///
/// ## RFC References
///
/// PLAIN SASL - https://datatracker.ietf.org/doc/html/rfc4616
///
///
/// ## Dovecot References
///
/// https://doc.dovecot.org/developer_manual/design/auth_protocol/
/// https://doc.dovecot.org/configuration_manual/authentication/authentication_mechanisms/#authentication-authentication-mechanisms
/// https://doc.dovecot.org/configuration_manual/howto/simple_virtual_install/#simple-virtual-install-smtp-auth
/// https://doc.dovecot.org/configuration_manual/howto/postfix_and_dovecot_sasl/#howto-postfix-and-dovecot-sasl
pub mod types;
pub mod encode;
pub mod decode;
pub mod flow;

163
aero-sasl/src/types.rs Normal file
View file

@ -0,0 +1,163 @@
#[derive(Debug, Clone, PartialEq)]
pub enum Mechanism {
Plain,
Login,
}
#[derive(Clone, Debug)]
pub enum AuthOption {
/// Unique session ID. Mainly used for logging.
Session(u64),
/// Local IP connected to by the client. In standard string format, e.g. 127.0.0.1 or ::1.
LocalIp(String),
/// Remote client IP
RemoteIp(String),
/// Local port connected to by the client.
LocalPort(u16),
/// Remote client port
RemotePort(u16),
/// When Dovecot proxy is used, the real_rip/real_port are the proxys IP/port and real_lip/real_lport are the backends IP/port where the proxy was connected to.
RealRemoteIp(String),
RealLocalIp(String),
RealLocalPort(u16),
RealRemotePort(u16),
/// TLS SNI name
LocalName(String),
/// Enable debugging for this lookup.
Debug,
/// List of fields that will become available via %{forward_*} variables. The list is double-tab-escaped, like: tab_escaped[tab_escaped(key=value)[<TAB>...]
/// Note: we do not unescape the tabulation, and thus we don't parse the data
ForwardViews(Vec<u8>),
/// Remote user has secured transport to auth client (e.g. localhost, SSL, TLS).
Secured(Option<String>),
/// The value can be “insecure”, “trusted” or “TLS”.
Transport(String),
/// TLS cipher being used.
TlsCipher(String),
/// The number of bits in the TLS cipher.
/// @FIXME: I don't know how if it's a string or an integer
TlsCipherBits(String),
/// TLS perfect forward secrecy algorithm (e.g. DH, ECDH)
TlsPfs(String),
/// TLS protocol name (e.g. SSLv3, TLSv1.2)
TlsProtocol(String),
/// Remote user has presented a valid SSL certificate.
ValidClientCert(String),
/// Ignore auth penalty tracking for this request
NoPenalty,
/// Unknown option sent by Postfix
NoLogin,
/// Username taken from clients SSL certificate.
CertUsername,
/// IMAP ID string
ClientId,
/// An unknown key
UnknownPair(String, Vec<u8>),
UnknownBool(Vec<u8>),
/// Initial response for authentication mechanism.
/// NOTE: This must be the last parameter. Everything after it is ignored.
/// This is to avoid accidental security holes if user-given data is directly put to base64 string without filtering out tabs.
/// **This field is used when the data to pass is small, it's a way to "inline a continuation".
Resp(Vec<u8>),
}
#[derive(Debug, Clone)]
pub struct Version {
pub major: u64,
pub minor: u64,
}
#[derive(Debug)]
pub enum ClientCommand {
/// Both client and server should check that they support the same major version number. If they dont, the other side isnt expected to be talking the same protocol and should be disconnected. Minor version can be ignored. This document specifies the version number 1.2.
Version(Version),
/// CPID finishes the handshake from client.
Cpid(u64),
Auth {
/// ID is a connection-specific unique request identifier. It must be a 32bit number, so typically youd just increment it by one.
id: u64,
/// A SASL mechanism (eg. LOGIN, PLAIN, etc.)
/// See: https://doc.dovecot.org/configuration_manual/authentication/authentication_mechanisms/#authentication-authentication-mechanisms
mech: Mechanism,
/// Service is the service requesting authentication, eg. pop3, imap, smtp.
service: String,
/// All the optional parameters
options: Vec<AuthOption>,
},
Cont {
/// The <id> must match the <id> of the AUTH command.
id: u64,
/// Data that will be serialized to / deserialized from base64
data: Vec<u8>,
},
}
#[derive(Debug)]
pub enum MechanismParameters {
/// Anonymous authentication
Anonymous,
/// Transfers plaintext passwords
PlainText,
/// Subject to passive (dictionary) attack
Dictionary,
/// Subject to active (non-dictionary) attack
Active,
/// Provides forward secrecy between sessions
ForwardSecrecy,
/// Provides mutual authentication
MutualAuth,
/// Dont advertise this as available SASL mechanism (eg. APOP)
Private,
}
#[derive(Debug, Clone)]
pub enum FailCode {
/// This is a temporary internal failure, e.g. connection was lost to SQL database.
TempFail,
/// Authentication succeeded, but authorization failed (master users password was ok, but destination user was not ok).
AuthzFail,
/// User is disabled (password may or may not have been correct)
UserDisabled,
/// Users password has expired.
PassExpired,
}
#[derive(Debug)]
pub enum ServerCommand {
/// Both client and server should check that they support the same major version number. If they dont, the other side isnt expected to be talking the same protocol and should be disconnected. Minor version can be ignored. This document specifies the version number 1.2.
Version(Version),
/// CPID and SPID specify client and server Process Identifiers (PIDs). They should be unique identifiers for the specific process. UNIX process IDs are good choices.
/// SPID can be used by authentication client to tell master which server process handled the authentication.
Spid(u64),
/// CUID is a server process-specific unique connection identifier. Its different each time a connection is established for the server.
/// CUID is currently useful only for APOP authentication.
Cuid(u64),
Mech {
kind: Mechanism,
parameters: Vec<MechanismParameters>,
},
/// COOKIE returns connection-specific 128 bit cookie in hex. It must be given to REQUEST command. (Protocol v1.1+ / Dovecot v2.0+)
Cookie([u8; 16]),
/// DONE finishes the handshake from server.
Done,
Fail {
id: u64,
user_id: Option<String>,
code: Option<FailCode>,
extra_parameters: Vec<Vec<u8>>,
},
Cont {
id: u64,
data: Option<Vec<u8>>,
},
/// FAIL and OK may contain multiple unspecified parameters which authentication client may handle specially.
/// The only one specified here is user=<userid> parameter, which should always be sent if the userid is known.
Ok {
id: u64,
user_id: Option<String>,
extra_parameters: Vec<Vec<u8>>,
},
}

30
aero-user/Cargo.toml Normal file
View file

@ -0,0 +1,30 @@
[package]
name = "aero-user"
version = "0.3.0"
authors = ["Alex Auvolat <alex@adnab.me>", "Quentin Dufour <quentin@dufour.io>"]
edition = "2021"
license = "EUPL-1.2"
description = "Represent an encrypted user profile"
[dependencies]
anyhow.workspace = true
serde.workspace = true
zstd.workspace = true
sodiumoxide.workspace = true
log.workspace = true
async-trait.workspace = true
ldap3.workspace = true
base64.workspace = true
rand.workspace = true
tokio.workspace = true
aws-config.workspace = true
aws-sdk-s3.workspace = true
aws-smithy-runtime.workspace = true
aws-smithy-runtime-api.workspace = true
hyper-rustls.workspace = true
hyper-util.workspace = true
k2v-client.workspace = true
rmp-serde.workspace = true
toml.workspace = true
tracing.workspace = true
argon2.workspace = true

9
aero-user/src/lib.rs Normal file
View file

@ -0,0 +1,9 @@
pub mod config;
pub mod cryptoblob;
pub mod login;
pub mod storage;
// A user is composed of 3 things:
// - An identity (login)
// - A storage profile (storage)
// - Some cryptography data (cryptoblob)

View file

@ -1,11 +1,10 @@
use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use ldap3::{LdapConnAsync, Scope, SearchEntry}; use ldap3::{LdapConnAsync, Scope, SearchEntry};
use log::debug; use log::debug;
use crate::config::*; use crate::config::*;
use crate::login::*;
use crate::storage; use crate::storage;
use super::*;
pub struct LdapLoginProvider { pub struct LdapLoginProvider {
ldap_server: String, ldap_server: String,

View file

@ -2,11 +2,11 @@ pub mod demo_provider;
pub mod ldap_provider; pub mod ldap_provider;
pub mod static_provider; pub mod static_provider;
use base64::Engine;
use std::sync::Arc; use std::sync::Arc;
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use async_trait::async_trait; use async_trait::async_trait;
use base64::Engine;
use rand::prelude::*; use rand::prelude::*;
use crate::cryptoblob::*; use crate::cryptoblob::*;

View file

@ -1,12 +1,11 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{anyhow, bail};
use async_trait::async_trait;
use tokio::signal::unix::{signal, SignalKind}; use tokio::signal::unix::{signal, SignalKind};
use tokio::sync::watch; use tokio::sync::watch;
use anyhow::{anyhow, bail, Result};
use async_trait::async_trait;
use crate::config::*; use crate::config::*;
use crate::login::*; use crate::login::*;
use crate::storage; use crate::storage;

View file

@ -6,7 +6,7 @@ use hyper_util::client::legacy::{connect::HttpConnector, Client as HttpClient};
use hyper_util::rt::TokioExecutor; use hyper_util::rt::TokioExecutor;
use serde::Serialize; use serde::Serialize;
use crate::storage::*; use super::*;
pub struct GarageRoot { pub struct GarageRoot {
k2v_http: HttpClient<HttpsConnector<HttpConnector>, k2v_client::Body>, k2v_http: HttpClient<HttpsConnector<HttpConnector>, k2v_client::Body>,

View file

@ -1,7 +1,7 @@
use crate::storage::*; use crate::storage::*;
use std::collections::{BTreeMap, HashMap}; use std::collections::BTreeMap;
use std::ops::Bound::{self, Excluded, Included, Unbounded}; use std::ops::Bound::{self, Excluded, Included, Unbounded};
use std::sync::{Arc, RwLock}; use std::sync::RwLock;
use tokio::sync::Notify; use tokio::sync::Notify;
/// This implementation is very inneficient, and not completely correct /// This implementation is very inneficient, and not completely correct

View file

@ -11,11 +11,12 @@
pub mod garage; pub mod garage;
pub mod in_memory; pub mod in_memory;
use async_trait::async_trait;
use std::collections::HashMap; use std::collections::HashMap;
use std::hash::Hash; use std::hash::Hash;
use std::sync::Arc; use std::sync::Arc;
use async_trait::async_trait;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Alternative { pub enum Alternative {
Tombstone, Tombstone,

12
aerogramme/Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "aerogramme"
version = "0.3.0"
authors = ["Alex Auvolat <alex@adnab.me>", "Quentin Dufour <quentin@dufour.io>"]
edition = "2021"
license = "EUPL-1.2"
description = "A robust email server"
[[test]]
name = "behavior"
path = "tests/behavior.rs"
harness = false

View file

@ -186,12 +186,12 @@
shell = gpkgs.mkShell { shell = gpkgs.mkShell {
buildInputs = [ buildInputs = [
cargo2nix.packages.x86_64-linux.default cargo2nix.packages.x86_64-linux.default
fenix.packages.x86_64-linux.minimal.toolchain fenix.packages.x86_64-linux.complete.toolchain
fenix.packages.x86_64-linux.rust-analyzer #fenix.packages.x86_64-linux.rust-analyzer
]; ];
shellHook = '' shellHook = ''
echo "AEROGRAME DEVELOPMENT SHELL ${fenix.packages.x86_64-linux.minimal.rustc}" echo "AEROGRAME DEVELOPMENT SHELL ${fenix.packages.x86_64-linux.complete.toolchain}"
export RUST_SRC_PATH="${fenix.packages.x86_64-linux.latest.rust-src}/lib/rustlib/src/rust/library" export RUST_SRC_PATH="${fenix.packages.x86_64-linux.complete.toolchain}/lib/rustlib/src/rust/library"
export RUST_ANALYZER_INTERNALS_DO_NOT_USE='this is unstable' export RUST_ANALYZER_INTERNALS_DO_NOT_USE='this is unstable'
''; '';
}; };

View file

@ -1,48 +0,0 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use aerogramme::dav::{types, realization, xml};
use quick_xml::reader::NsReader;
use tokio::runtime::Runtime;
use tokio::io::AsyncWriteExt;
async fn serialize(elem: &impl xml::QWrite) -> Vec<u8> {
let mut buffer = Vec::new();
let mut tokio_buffer = tokio::io::BufWriter::new(&mut buffer);
let q = quick_xml::writer::Writer::new_with_indent(&mut tokio_buffer, b' ', 4);
let ns_to_apply = vec![ ("xmlns:D".into(), "DAV:".into()) ];
let mut writer = xml::Writer { q, ns_to_apply };
elem.qwrite(&mut writer).await.expect("xml serialization");
tokio_buffer.flush().await.expect("tokio buffer flush");
return buffer
}
type Object = types::Multistatus<realization::Core, types::PropValue<realization::Core>>;
fuzz_target!(|data: &[u8]| {
let rt = Runtime::new().expect("tokio runtime initialization");
rt.block_on(async {
// 1. Setup fuzzing by finding an input that seems correct, do not crash yet then.
let mut rdr = match xml::Reader::new(NsReader::from_reader(data)).await {
Err(_) => return,
Ok(r) => r,
};
let reference = match rdr.find::<Object>().await {
Err(_) => return,
Ok(m) => m,
};
// 2. Re-serialize the input
let my_serialization = serialize(&reference).await;
// 3. De-serialize my serialization
let mut rdr2 = xml::Reader::new(NsReader::from_reader(my_serialization.as_slice())).await.expect("XML Reader init");
let comparison = rdr2.find::<Object>().await.expect("Deserialize again");
// 4. Both the first decoding and last decoding must be identical
assert_eq!(reference, comparison);
})
});

View file

@ -1,941 +0,0 @@
use std::net::SocketAddr;
use anyhow::{anyhow, bail, Result};
use futures::stream::{FuturesUnordered, StreamExt};
use tokio::io::BufStream;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::watch;
use crate::config::AuthConfig;
use crate::login::ArcLoginProvider;
/// Seek compatibility with the Dovecot Authentication Protocol
///
/// ## Trace
///
/// ```text
/// S: VERSION 1 2
/// S: MECH PLAIN plaintext
/// S: MECH LOGIN plaintext
/// S: SPID 15
/// S: CUID 17654
/// S: COOKIE f56692bee41f471ed01bd83520025305
/// S: DONE
/// C: VERSION 1 2
/// C: CPID 1
///
/// C: AUTH 2 PLAIN service=smtp
/// S: CONT 2
/// C: CONT 2 base64stringFollowingRFC4616==
/// S: OK 2 user=alice@example.tld
///
/// C: AUTH 42 LOGIN service=smtp
/// S: CONT 42 VXNlcm5hbWU6
/// C: CONT 42 b64User
/// S: CONT 42 UGFzc3dvcmQ6
/// C: CONT 42 b64Pass
/// S: FAIL 42 user=alice
/// ```
///
/// ## RFC References
///
/// PLAIN SASL - https://datatracker.ietf.org/doc/html/rfc4616
///
///
/// ## Dovecot References
///
/// https://doc.dovecot.org/developer_manual/design/auth_protocol/
/// https://doc.dovecot.org/configuration_manual/authentication/authentication_mechanisms/#authentication-authentication-mechanisms
/// https://doc.dovecot.org/configuration_manual/howto/simple_virtual_install/#simple-virtual-install-smtp-auth
/// https://doc.dovecot.org/configuration_manual/howto/postfix_and_dovecot_sasl/#howto-postfix-and-dovecot-sasl
pub struct AuthServer {
login_provider: ArcLoginProvider,
bind_addr: SocketAddr,
}
impl AuthServer {
pub fn new(config: AuthConfig, login_provider: ArcLoginProvider) -> Self {
Self {
bind_addr: config.bind_addr,
login_provider,
}
}
pub async fn run(self: Self, mut must_exit: watch::Receiver<bool>) -> Result<()> {
let tcp = TcpListener::bind(self.bind_addr).await?;
tracing::info!(
"SASL Authentication Protocol listening on {:#}",
self.bind_addr
);
let mut connections = FuturesUnordered::new();
while !*must_exit.borrow() {
let wait_conn_finished = async {
if connections.is_empty() {
futures::future::pending().await
} else {
connections.next().await
}
};
let (socket, remote_addr) = tokio::select! {
a = tcp.accept() => a?,
_ = wait_conn_finished => continue,
_ = must_exit.changed() => continue,
};
tracing::info!("AUTH: accepted connection from {}", remote_addr);
let conn = tokio::spawn(
NetLoop::new(socket, self.login_provider.clone(), must_exit.clone()).run_error(),
);
connections.push(conn);
}
drop(tcp);
tracing::info!("AUTH server shutting down, draining remaining connections...");
while connections.next().await.is_some() {}
Ok(())
}
}
struct NetLoop {
login: ArcLoginProvider,
stream: BufStream<TcpStream>,
stop: watch::Receiver<bool>,
state: State,
read_buf: Vec<u8>,
write_buf: BytesMut,
}
impl NetLoop {
fn new(stream: TcpStream, login: ArcLoginProvider, stop: watch::Receiver<bool>) -> Self {
Self {
login,
stream: BufStream::new(stream),
state: State::Init,
stop,
read_buf: Vec::new(),
write_buf: BytesMut::new(),
}
}
async fn run_error(self) {
match self.run().await {
Ok(()) => tracing::info!("Auth session succeeded"),
Err(e) => tracing::error!(err=?e, "Auth session failed"),
}
}
async fn run(mut self) -> Result<()> {
loop {
tokio::select! {
read_res = self.stream.read_until(b'\n', &mut self.read_buf) => {
// Detect EOF / socket close
let bread = read_res?;
if bread == 0 {
tracing::info!("Reading buffer empty, connection has been closed. Exiting AUTH session.");
return Ok(())
}
// Parse command
let (_, cmd) = client_command(&self.read_buf).map_err(|_| anyhow!("Unable to parse command"))?;
tracing::trace!(cmd=?cmd, "Received command");
// Make some progress in our local state
self.state.progress(cmd, &self.login).await;
if matches!(self.state, State::Error) {
bail!("Internal state is in error, previous logs explain what went wrong");
}
// Build response
let srv_cmds = self.state.response();
srv_cmds.iter().try_for_each(|r| {
tracing::trace!(cmd=?r, "Sent command");
r.encode(&mut self.write_buf)
})?;
// Send responses if at least one command response has been generated
if !srv_cmds.is_empty() {
self.stream.write_all(&self.write_buf).await?;
self.stream.flush().await?;
}
// Reset buffers
self.read_buf.clear();
self.write_buf.clear();
},
_ = self.stop.changed() => {
tracing::debug!("Server is stopping, quitting this runner");
return Ok(())
}
}
}
}
}
// -----------------------------------------------------------------
//
// BUSINESS LOGIC
//
// -----------------------------------------------------------------
use rand::prelude::*;
#[derive(Debug)]
enum AuthRes {
Success(String),
Failed(Option<String>, Option<FailCode>),
}
#[derive(Debug)]
enum State {
Error,
Init,
HandshakePart(Version),
HandshakeDone,
AuthPlainProgress { id: u64 },
AuthDone { id: u64, res: AuthRes },
}
const SERVER_MAJOR: u64 = 1;
const SERVER_MINOR: u64 = 2;
const EMPTY_AUTHZ: &[u8] = &[];
impl State {
async fn try_auth_plain<'a>(&self, data: &'a [u8], login: &ArcLoginProvider) -> AuthRes {
// Check that we can extract user's login+pass
let (ubin, pbin) = match auth_plain(&data) {
Ok(([], (authz, user, pass))) if authz == user || authz == EMPTY_AUTHZ => (user, pass),
Ok(_) => {
tracing::error!("Impersonating user is not supported");
return AuthRes::Failed(None, None);
}
Err(e) => {
tracing::error!(err=?e, "Could not parse the SASL PLAIN data chunk");
return AuthRes::Failed(None, None);
}
};
// Try to convert it to UTF-8
let (user, password) = match (std::str::from_utf8(ubin), std::str::from_utf8(pbin)) {
(Ok(u), Ok(p)) => (u, p),
_ => {
tracing::error!("Username or password contain invalid UTF-8 characters");
return AuthRes::Failed(None, None);
}
};
// Try to connect user
match login.login(user, password).await {
Ok(_) => AuthRes::Success(user.to_string()),
Err(e) => {
tracing::warn!(err=?e, "login failed");
AuthRes::Failed(Some(user.to_string()), None)
}
}
}
async fn progress(&mut self, cmd: ClientCommand, login: &ArcLoginProvider) {
let new_state = 'state: {
match (std::mem::replace(self, State::Error), cmd) {
(Self::Init, ClientCommand::Version(v)) => Self::HandshakePart(v),
(Self::HandshakePart(version), ClientCommand::Cpid(_cpid)) => {
if version.major != SERVER_MAJOR {
tracing::error!(
client_major = version.major,
server_major = SERVER_MAJOR,
"Unsupported client major version"
);
break 'state Self::Error;
}
Self::HandshakeDone
}
(
Self::HandshakeDone { .. },
ClientCommand::Auth {
id, mech, options, ..
},
)
| (
Self::AuthDone { .. },
ClientCommand::Auth {
id, mech, options, ..
},
) => {
if mech != Mechanism::Plain {
tracing::error!(mechanism=?mech, "Unsupported Authentication Mechanism");
break 'state Self::AuthDone {
id,
res: AuthRes::Failed(None, None),
};
}
match options.last() {
Some(AuthOption::Resp(data)) => Self::AuthDone {
id,
res: self.try_auth_plain(&data, login).await,
},
_ => Self::AuthPlainProgress { id },
}
}
(Self::AuthPlainProgress { id }, ClientCommand::Cont { id: cid, data }) => {
// Check that ID matches
if cid != id {
tracing::error!(
auth_id = id,
cont_id = cid,
"CONT id does not match AUTH id"
);
break 'state Self::AuthDone {
id,
res: AuthRes::Failed(None, None),
};
}
Self::AuthDone {
id,
res: self.try_auth_plain(&data, login).await,
}
}
_ => {
tracing::error!("This command is not valid in this context");
Self::Error
}
}
};
tracing::debug!(state=?new_state, "Made progress");
*self = new_state;
}
fn response(&self) -> Vec<ServerCommand> {
let mut srv_cmd: Vec<ServerCommand> = Vec::new();
match self {
Self::HandshakeDone { .. } => {
srv_cmd.push(ServerCommand::Version(Version {
major: SERVER_MAJOR,
minor: SERVER_MINOR,
}));
srv_cmd.push(ServerCommand::Mech {
kind: Mechanism::Plain,
parameters: vec![MechanismParameters::PlainText],
});
srv_cmd.push(ServerCommand::Spid(15u64));
srv_cmd.push(ServerCommand::Cuid(19350u64));
let mut cookie = [0u8; 16];
thread_rng().fill(&mut cookie);
srv_cmd.push(ServerCommand::Cookie(cookie));
srv_cmd.push(ServerCommand::Done);
}
Self::AuthPlainProgress { id } => {
srv_cmd.push(ServerCommand::Cont {
id: *id,
data: None,
});
}
Self::AuthDone {
id,
res: AuthRes::Success(user),
} => {
srv_cmd.push(ServerCommand::Ok {
id: *id,
user_id: Some(user.to_string()),
extra_parameters: vec![],
});
}
Self::AuthDone {
id,
res: AuthRes::Failed(maybe_user, maybe_failcode),
} => {
srv_cmd.push(ServerCommand::Fail {
id: *id,
user_id: maybe_user.clone(),
code: maybe_failcode.clone(),
extra_parameters: vec![],
});
}
_ => (),
};
srv_cmd
}
}
// -----------------------------------------------------------------
//
// DOVECOT AUTH TYPES
//
// -----------------------------------------------------------------
#[derive(Debug, Clone, PartialEq)]
enum Mechanism {
Plain,
Login,
}
#[derive(Clone, Debug)]
enum AuthOption {
/// Unique session ID. Mainly used for logging.
Session(u64),
/// Local IP connected to by the client. In standard string format, e.g. 127.0.0.1 or ::1.
LocalIp(String),
/// Remote client IP
RemoteIp(String),
/// Local port connected to by the client.
LocalPort(u16),
/// Remote client port
RemotePort(u16),
/// When Dovecot proxy is used, the real_rip/real_port are the proxys IP/port and real_lip/real_lport are the backends IP/port where the proxy was connected to.
RealRemoteIp(String),
RealLocalIp(String),
RealLocalPort(u16),
RealRemotePort(u16),
/// TLS SNI name
LocalName(String),
/// Enable debugging for this lookup.
Debug,
/// List of fields that will become available via %{forward_*} variables. The list is double-tab-escaped, like: tab_escaped[tab_escaped(key=value)[<TAB>...]
/// Note: we do not unescape the tabulation, and thus we don't parse the data
ForwardViews(Vec<u8>),
/// Remote user has secured transport to auth client (e.g. localhost, SSL, TLS).
Secured(Option<String>),
/// The value can be “insecure”, “trusted” or “TLS”.
Transport(String),
/// TLS cipher being used.
TlsCipher(String),
/// The number of bits in the TLS cipher.
/// @FIXME: I don't know how if it's a string or an integer
TlsCipherBits(String),
/// TLS perfect forward secrecy algorithm (e.g. DH, ECDH)
TlsPfs(String),
/// TLS protocol name (e.g. SSLv3, TLSv1.2)
TlsProtocol(String),
/// Remote user has presented a valid SSL certificate.
ValidClientCert(String),
/// Ignore auth penalty tracking for this request
NoPenalty,
/// Unknown option sent by Postfix
NoLogin,
/// Username taken from clients SSL certificate.
CertUsername,
/// IMAP ID string
ClientId,
/// An unknown key
UnknownPair(String, Vec<u8>),
UnknownBool(Vec<u8>),
/// Initial response for authentication mechanism.
/// NOTE: This must be the last parameter. Everything after it is ignored.
/// This is to avoid accidental security holes if user-given data is directly put to base64 string without filtering out tabs.
/// @FIXME: I don't understand this parameter
Resp(Vec<u8>),
}
#[derive(Debug, Clone)]
struct Version {
major: u64,
minor: u64,
}
#[derive(Debug)]
enum ClientCommand {
/// Both client and server should check that they support the same major version number. If they dont, the other side isnt expected to be talking the same protocol and should be disconnected. Minor version can be ignored. This document specifies the version number 1.2.
Version(Version),
/// CPID finishes the handshake from client.
Cpid(u64),
Auth {
/// ID is a connection-specific unique request identifier. It must be a 32bit number, so typically youd just increment it by one.
id: u64,
/// A SASL mechanism (eg. LOGIN, PLAIN, etc.)
/// See: https://doc.dovecot.org/configuration_manual/authentication/authentication_mechanisms/#authentication-authentication-mechanisms
mech: Mechanism,
/// Service is the service requesting authentication, eg. pop3, imap, smtp.
service: String,
/// All the optional parameters
options: Vec<AuthOption>,
},
Cont {
/// The <id> must match the <id> of the AUTH command.
id: u64,
/// Data that will be serialized to / deserialized from base64
data: Vec<u8>,
},
}
#[derive(Debug)]
enum MechanismParameters {
/// Anonymous authentication
Anonymous,
/// Transfers plaintext passwords
PlainText,
/// Subject to passive (dictionary) attack
Dictionary,
/// Subject to active (non-dictionary) attack
Active,
/// Provides forward secrecy between sessions
ForwardSecrecy,
/// Provides mutual authentication
MutualAuth,
/// Dont advertise this as available SASL mechanism (eg. APOP)
Private,
}
#[derive(Debug, Clone)]
enum FailCode {
/// This is a temporary internal failure, e.g. connection was lost to SQL database.
TempFail,
/// Authentication succeeded, but authorization failed (master users password was ok, but destination user was not ok).
AuthzFail,
/// User is disabled (password may or may not have been correct)
UserDisabled,
/// Users password has expired.
PassExpired,
}
#[derive(Debug)]
enum ServerCommand {
/// Both client and server should check that they support the same major version number. If they dont, the other side isnt expected to be talking the same protocol and should be disconnected. Minor version can be ignored. This document specifies the version number 1.2.
Version(Version),
/// CPID and SPID specify client and server Process Identifiers (PIDs). They should be unique identifiers for the specific process. UNIX process IDs are good choices.
/// SPID can be used by authentication client to tell master which server process handled the authentication.
Spid(u64),
/// CUID is a server process-specific unique connection identifier. Its different each time a connection is established for the server.
/// CUID is currently useful only for APOP authentication.
Cuid(u64),
Mech {
kind: Mechanism,
parameters: Vec<MechanismParameters>,
},
/// COOKIE returns connection-specific 128 bit cookie in hex. It must be given to REQUEST command. (Protocol v1.1+ / Dovecot v2.0+)
Cookie([u8; 16]),
/// DONE finishes the handshake from server.
Done,
Fail {
id: u64,
user_id: Option<String>,
code: Option<FailCode>,
extra_parameters: Vec<Vec<u8>>,
},
Cont {
id: u64,
data: Option<Vec<u8>>,
},
/// FAIL and OK may contain multiple unspecified parameters which authentication client may handle specially.
/// The only one specified here is user=<userid> parameter, which should always be sent if the userid is known.
Ok {
id: u64,
user_id: Option<String>,
extra_parameters: Vec<Vec<u8>>,
},
}
// -----------------------------------------------------------------
//
// DOVECOT AUTH DECODING
//
// ------------------------------------------------------------------
use base64::Engine;
use nom::{
branch::alt,
bytes::complete::{is_not, tag, tag_no_case, take, take_while, take_while1},
character::complete::{tab, u16, u64},
combinator::{map, opt, recognize, rest, value},
error::{Error, ErrorKind},
multi::{many1, separated_list0},
sequence::{pair, preceded, tuple},
IResult,
};
fn version_command<'a>(input: &'a [u8]) -> IResult<&'a [u8], ClientCommand> {
let mut parser = tuple((tag_no_case(b"VERSION"), tab, u64, tab, u64));
let (input, (_, _, major, _, minor)) = parser(input)?;
Ok((input, ClientCommand::Version(Version { major, minor })))
}
fn cpid_command<'a>(input: &'a [u8]) -> IResult<&'a [u8], ClientCommand> {
preceded(
pair(tag_no_case(b"CPID"), tab),
map(u64, |v| ClientCommand::Cpid(v)),
)(input)
}
fn mechanism<'a>(input: &'a [u8]) -> IResult<&'a [u8], Mechanism> {
alt((
value(Mechanism::Plain, tag_no_case(b"PLAIN")),
value(Mechanism::Login, tag_no_case(b"LOGIN")),
))(input)
}
fn is_not_tab_or_esc_or_lf(c: u8) -> bool {
c != 0x09 && c != 0x01 && c != 0x0a // TAB or 0x01 or LF
}
fn is_esc<'a>(input: &'a [u8]) -> IResult<&'a [u8], &[u8]> {
preceded(tag(&[0x01]), take(1usize))(input)
}
fn parameter<'a>(input: &'a [u8]) -> IResult<&'a [u8], &[u8]> {
recognize(many1(alt((take_while1(is_not_tab_or_esc_or_lf), is_esc))))(input)
}
fn parameter_str(input: &[u8]) -> IResult<&[u8], String> {
let (input, buf) = parameter(input)?;
std::str::from_utf8(buf)
.map(|v| (input, v.to_string()))
.map_err(|_| nom::Err::Failure(Error::new(input, ErrorKind::TakeWhile1)))
}
fn is_param_name_char(c: u8) -> bool {
is_not_tab_or_esc_or_lf(c) && c != 0x3d // =
}
fn parameter_name(input: &[u8]) -> IResult<&[u8], String> {
let (input, buf) = take_while1(is_param_name_char)(input)?;
std::str::from_utf8(buf)
.map(|v| (input, v.to_string()))
.map_err(|_| nom::Err::Failure(Error::new(input, ErrorKind::TakeWhile1)))
}
fn service<'a>(input: &'a [u8]) -> IResult<&'a [u8], String> {
preceded(tag_no_case("service="), parameter_str)(input)
}
fn auth_option<'a>(input: &'a [u8]) -> IResult<&'a [u8], AuthOption> {
use AuthOption::*;
alt((
alt((
value(Debug, tag_no_case(b"debug")),
value(NoPenalty, tag_no_case(b"no-penalty")),
value(ClientId, tag_no_case(b"client_id")),
value(NoLogin, tag_no_case(b"nologin")),
map(preceded(tag_no_case(b"session="), u64), |id| Session(id)),
map(preceded(tag_no_case(b"lip="), parameter_str), |ip| {
LocalIp(ip)
}),
map(preceded(tag_no_case(b"rip="), parameter_str), |ip| {
RemoteIp(ip)
}),
map(preceded(tag_no_case(b"lport="), u16), |port| {
LocalPort(port)
}),
map(preceded(tag_no_case(b"rport="), u16), |port| {
RemotePort(port)
}),
map(preceded(tag_no_case(b"real_rip="), parameter_str), |ip| {
RealRemoteIp(ip)
}),
map(preceded(tag_no_case(b"real_lip="), parameter_str), |ip| {
RealLocalIp(ip)
}),
map(preceded(tag_no_case(b"real_lport="), u16), |port| {
RealLocalPort(port)
}),
map(preceded(tag_no_case(b"real_rport="), u16), |port| {
RealRemotePort(port)
}),
)),
alt((
map(
preceded(tag_no_case(b"local_name="), parameter_str),
|name| LocalName(name),
),
map(
preceded(tag_no_case(b"forward_views="), parameter),
|views| ForwardViews(views.into()),
),
map(preceded(tag_no_case(b"secured="), parameter_str), |info| {
Secured(Some(info))
}),
value(Secured(None), tag_no_case(b"secured")),
value(CertUsername, tag_no_case(b"cert_username")),
map(preceded(tag_no_case(b"transport="), parameter_str), |ts| {
Transport(ts)
}),
map(
preceded(tag_no_case(b"tls_cipher="), parameter_str),
|cipher| TlsCipher(cipher),
),
map(
preceded(tag_no_case(b"tls_cipher_bits="), parameter_str),
|bits| TlsCipherBits(bits),
),
map(preceded(tag_no_case(b"tls_pfs="), parameter_str), |pfs| {
TlsPfs(pfs)
}),
map(
preceded(tag_no_case(b"tls_protocol="), parameter_str),
|proto| TlsProtocol(proto),
),
map(
preceded(tag_no_case(b"valid-client-cert="), parameter_str),
|cert| ValidClientCert(cert),
),
)),
alt((
map(preceded(tag_no_case(b"resp="), base64), |data| Resp(data)),
map(
tuple((parameter_name, tag(b"="), parameter)),
|(n, _, v)| UnknownPair(n, v.into()),
),
map(parameter, |v| UnknownBool(v.into())),
)),
))(input)
}
fn auth_command<'a>(input: &'a [u8]) -> IResult<&'a [u8], ClientCommand> {
let mut parser = tuple((
tag_no_case(b"AUTH"),
tab,
u64,
tab,
mechanism,
tab,
service,
map(opt(preceded(tab, separated_list0(tab, auth_option))), |o| {
o.unwrap_or(vec![])
}),
));
let (input, (_, _, id, _, mech, _, service, options)) = parser(input)?;
Ok((
input,
ClientCommand::Auth {
id,
mech,
service,
options,
},
))
}
fn is_base64_core(c: u8) -> bool {
c >= 0x30 && c <= 0x39 // 0-9
|| c >= 0x41 && c <= 0x5a // A-Z
|| c >= 0x61 && c <= 0x7a // a-z
|| c == 0x2b // +
|| c == 0x2f // /
}
fn is_base64_pad(c: u8) -> bool {
c == 0x3d // =
}
fn base64(input: &[u8]) -> IResult<&[u8], Vec<u8>> {
let (input, (b64, _)) = tuple((take_while1(is_base64_core), take_while(is_base64_pad)))(input)?;
let data = base64::engine::general_purpose::STANDARD_NO_PAD
.decode(b64)
.map_err(|_| nom::Err::Failure(Error::new(input, ErrorKind::TakeWhile1)))?;
Ok((input, data))
}
/// @FIXME Dovecot does not say if base64 content must be padded or not
fn cont_command<'a>(input: &'a [u8]) -> IResult<&'a [u8], ClientCommand> {
let mut parser = tuple((tag_no_case(b"CONT"), tab, u64, tab, base64));
let (input, (_, _, id, _, data)) = parser(input)?;
Ok((input, ClientCommand::Cont { id, data }))
}
fn client_command<'a>(input: &'a [u8]) -> IResult<&'a [u8], ClientCommand> {
alt((version_command, cpid_command, auth_command, cont_command))(input)
}
/*
fn server_command(buf: &u8) -> IResult<&u8, ServerCommand> {
unimplemented!();
}
*/
// -----------------------------------------------------------------
//
// SASL DECODING
//
// -----------------------------------------------------------------
fn not_null(c: u8) -> bool {
c != 0x0
}
// impersonated user, login, password
fn auth_plain<'a>(input: &'a [u8]) -> IResult<&'a [u8], (&'a [u8], &'a [u8], &'a [u8])> {
map(
tuple((
take_while(not_null),
take(1usize),
take_while(not_null),
take(1usize),
rest,
)),
|(imp, _, user, _, pass)| (imp, user, pass),
)(input)
}
// -----------------------------------------------------------------
//
// DOVECOT AUTH ENCODING
//
// ------------------------------------------------------------------
use tokio_util::bytes::{BufMut, BytesMut};
trait Encode {
fn encode(&self, out: &mut BytesMut) -> Result<()>;
}
fn tab_enc(out: &mut BytesMut) {
out.put(&[0x09][..])
}
fn lf_enc(out: &mut BytesMut) {
out.put(&[0x0A][..])
}
impl Encode for Mechanism {
fn encode(&self, out: &mut BytesMut) -> Result<()> {
match self {
Self::Plain => out.put(&b"PLAIN"[..]),
Self::Login => out.put(&b"LOGIN"[..]),
}
Ok(())
}
}
impl Encode for MechanismParameters {
fn encode(&self, out: &mut BytesMut) -> Result<()> {
match self {
Self::Anonymous => out.put(&b"anonymous"[..]),
Self::PlainText => out.put(&b"plaintext"[..]),
Self::Dictionary => out.put(&b"dictionary"[..]),
Self::Active => out.put(&b"active"[..]),
Self::ForwardSecrecy => out.put(&b"forward-secrecy"[..]),
Self::MutualAuth => out.put(&b"mutual-auth"[..]),
Self::Private => out.put(&b"private"[..]),
}
Ok(())
}
}
impl Encode for FailCode {
fn encode(&self, out: &mut BytesMut) -> Result<()> {
match self {
Self::TempFail => out.put(&b"temp_fail"[..]),
Self::AuthzFail => out.put(&b"authz_fail"[..]),
Self::UserDisabled => out.put(&b"user_disabled"[..]),
Self::PassExpired => out.put(&b"pass_expired"[..]),
};
Ok(())
}
}
impl Encode for ServerCommand {
fn encode(&self, out: &mut BytesMut) -> Result<()> {
match self {
Self::Version(Version { major, minor }) => {
out.put(&b"VERSION"[..]);
tab_enc(out);
out.put(major.to_string().as_bytes());
tab_enc(out);
out.put(minor.to_string().as_bytes());
lf_enc(out);
}
Self::Spid(pid) => {
out.put(&b"SPID"[..]);
tab_enc(out);
out.put(pid.to_string().as_bytes());
lf_enc(out);
}
Self::Cuid(pid) => {
out.put(&b"CUID"[..]);
tab_enc(out);
out.put(pid.to_string().as_bytes());
lf_enc(out);
}
Self::Cookie(cval) => {
out.put(&b"COOKIE"[..]);
tab_enc(out);
out.put(hex::encode(cval).as_bytes());
lf_enc(out);
}
Self::Mech { kind, parameters } => {
out.put(&b"MECH"[..]);
tab_enc(out);
kind.encode(out)?;
for p in parameters.iter() {
tab_enc(out);
p.encode(out)?;
}
lf_enc(out);
}
Self::Done => {
out.put(&b"DONE"[..]);
lf_enc(out);
}
Self::Cont { id, data } => {
out.put(&b"CONT"[..]);
tab_enc(out);
out.put(id.to_string().as_bytes());
tab_enc(out);
if let Some(rdata) = data {
let b64 = base64::engine::general_purpose::STANDARD.encode(rdata);
out.put(b64.as_bytes());
}
lf_enc(out);
}
Self::Ok {
id,
user_id,
extra_parameters,
} => {
out.put(&b"OK"[..]);
tab_enc(out);
out.put(id.to_string().as_bytes());
if let Some(user) = user_id {
tab_enc(out);
out.put(&b"user="[..]);
out.put(user.as_bytes());
}
for p in extra_parameters.iter() {
tab_enc(out);
out.put(&p[..]);
}
lf_enc(out);
}
Self::Fail {
id,
user_id,
code,
extra_parameters,
} => {
out.put(&b"FAIL"[..]);
tab_enc(out);
out.put(id.to_string().as_bytes());
if let Some(user) = user_id {
tab_enc(out);
out.put(&b"user="[..]);
out.put(user.as_bytes());
}
if let Some(code_val) = code {
tab_enc(out);
out.put(&b"code="[..]);
code_val.encode(out)?;
}
for p in extra_parameters.iter() {
tab_enc(out);
out.put(&p[..]);
}
lf_enc(out);
}
}
Ok(())
}
}