use std::sync::Arc;
use std::time::Duration;
use anyhow::Context;
use axum::body::Bytes;
use axum::extract::rejection::BytesRejection;
use axum::extract::{Path, State};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Extension;
use recall_wire::devices::{
self, displayable, normalize_user_code, ACCESS_DENIED, AUTHKEY_PREFIX, AUTHORIZATION_PENDING,
CODE_TTL_SECONDS, DEFAULT_MAX_DEVICES, EXPIRED_TOKEN, INVALID_GRANT, MAX_AUTHKEY_DAYS,
MAX_TAG_CHARS, POLL_INTERVAL_SECONDS, SCOPE_ADMIN, SCOPE_SYNC, SLOW_DOWN, USER_CODE_ALPHABET,
};
use recall_wire::signature::encode_public_key;
use recall_wire::{
ApproveRequest, AuthkeyCreated, AuthkeyList, AuthkeyRequest, AuthkeyRevokeRequest, DenyRequest,
DenyResponse, DeviceIdentity, DeviceList, EnrollApproved, EnrollPending, EnrollPollRequest,
EnrollPollResponse, EnrollRequest, PendingEnrollment,
};
use serde::de::DeserializeOwned;
use time::OffsetDateTime;
use super::auth::Caller;
use super::middleware::{too_large, ClientIp};
use super::respond::{error, internal, json, Refusal};
use super::AppState;
use crate::store::{
plain_name, Created, Decision, Inserted, NewAuthkey, NewDevice, NewEnrollment, Poll,
};
use crate::{format_timestamp, now, parse_timestamp};
const MAX_PENDING_ENROLLMENTS: usize = 1000;
const MAX_PENDING_PER_ADDRESS: usize = 5;
pub(super) const EXPIRED_ENROLLMENT_KEPT: Duration = Duration::from_secs(60 * 60);
const UNTAGGED: &str = "device";
fn body<T: DeserializeOwned>(bytes: &Bytes) -> Result<T, Refusal> {
serde_json::from_slice(bytes)
.map_err(|_| Refusal::new(StatusCode::BAD_REQUEST, "invalid json body"))
}
fn small_body(bytes: Result<Bytes, BytesRejection>) -> Result<Bytes, Refusal> {
bytes.map_err(|rejection| match rejection.status() {
StatusCode::PAYLOAD_TOO_LARGE => too_large(),
status => Refusal::new(status, "could not read the request body"),
})
}
fn no_store(mut resp: Response) -> Response {
resp.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
resp
}
fn random(bytes: usize) -> anyhow::Result<Vec<u8>> {
let mut buf = vec![0u8; bytes];
getrandom::fill(&mut buf).map_err(|e| anyhow::anyhow!("no randomness: {e}"))?;
Ok(buf)
}
fn base32(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
let mut out = String::with_capacity(bytes.len().div_ceil(5) * 8);
let (mut acc, mut bits) = (0u32, 0u32);
for &b in bytes {
acc = (acc << 8) | u32::from(b);
bits += 8;
while bits >= 5 {
bits -= 5;
out.push(ALPHABET[((acc >> bits) & 31) as usize] as char);
}
}
if bits > 0 {
out.push(ALPHABET[((acc << (5 - bits)) & 31) as usize] as char);
}
out
}
fn new_id(prefix: &str, bytes: usize) -> anyhow::Result<String> {
Ok(format!("{prefix}{}", base32(&random(bytes)?)))
}
fn new_user_code() -> anyhow::Result<String> {
let mut code = String::with_capacity(9);
while code.len() < 8 {
for b in random(16)? {
if code.len() == 8 {
break;
}
if b < 240 {
code.push(USER_CODE_ALPHABET[(b % 20) as usize] as char);
}
}
}
normalize_user_code(&code).context("a generated user code did not normalize")
}
fn later(by: Duration) -> String {
format_timestamp(OffsetDateTime::now_utc() + by)
}
fn name_taken(name: &str) -> Refusal {
Refusal::new(
StatusCode::CONFLICT,
format!(
"a device named {name} already exists; revoke it first, or enrol with another name"
),
)
}
pub(super) async fn handle_enroll(
State(state): State<Arc<AppState>>,
Extension(ClientIp(client_ip)): Extension<ClientIp>,
bytes: Result<Bytes, BytesRejection>,
) -> Response {
let req: EnrollRequest = match small_body(bytes).and_then(|b| body(&b)) {
Ok(req) => req,
Err(refused) => return refused.into_response(),
};
let key = match req.validate() {
Ok(key) => key,
Err(e) => return error(StatusCode::BAD_REQUEST, &e.to_string()),
};
let public_key = encode_public_key(&key);
if let Some(authkey) = req.authkey.as_deref() {
return enroll_with_authkey(&state, authkey, &public_key, &req.agent);
}
let name = &plain_name(&req.name);
let now = now();
let expires_at = later(Duration::from_secs(CODE_TTL_SECONDS));
let enrollment_id = match new_id("enr_", 16) {
Ok(id) => id,
Err(e) => return internal(e),
};
for _ in 0..8 {
let user_code = match new_user_code() {
Ok(code) => code,
Err(e) => return internal(e),
};
let created = state.store.create_enrollment(
&NewEnrollment {
enrollment_id: &enrollment_id,
user_code: &user_code,
name,
public_key: &public_key,
agent: &req.agent,
created_at: &now,
expires_at: &expires_at,
client_ip: &client_ip,
},
MAX_PENDING_ENROLLMENTS,
MAX_PENDING_PER_ADDRESS,
);
match created {
Ok(Created::Created) => {
return no_store(json(
StatusCode::OK,
&EnrollPending {
enrollment_id,
user_code,
expires_in: CODE_TTL_SECONDS,
interval: POLL_INTERVAL_SECONDS,
},
))
}
Ok(Created::CodeTaken) => continue,
Ok(Created::Full) => {
return error(
StatusCode::SERVICE_UNAVAILABLE,
"too many enrolments are waiting for approval, try again later",
)
}
Ok(Created::AddressFull) => {
return error(
StatusCode::TOO_MANY_REQUESTS,
"too many enrolments from this address are waiting for approval; \
approve or deny them, or let them expire",
)
}
Err(e) => return internal(e),
}
}
internal(anyhow::anyhow!("could not find a free user code"))
}
fn enroll_with_authkey(state: &AppState, authkey: &str, public_key: &str, agent: &str) -> Response {
let refused = |why: &str| {
error(
StatusCode::UNAUTHORIZED,
&format!("unauthorized: this authkey {why}"),
)
};
let key = match state
.store
.authkey_by_hash(&recall_wire::content_sha256(authkey.trim()))
{
Ok(Some(key)) => key,
Ok(None) => return refused("is not one this server issued"),
Err(e) => return internal(e),
};
let now = now();
if key.revoked_at.is_some() {
return refused("has been revoked");
}
if key.expires_at <= now {
return refused("has expired");
}
let device_id = match new_id("dev_", 16) {
Ok(id) => id,
Err(e) => return internal(e),
};
let tag = if key.tag.is_empty() {
UNTAGGED
} else {
&key.tag
};
let random = &device_id["dev_".len()..];
let max_devices = key.max_devices.unwrap_or(DEFAULT_MAX_DEVICES);
for name in [format!("{tag}-{}", &random[..8]), format!("{tag}-{random}")] {
let inserted = state.store.insert_device(
&NewDevice {
id: &device_id,
name: &name,
public_key,
scope: SCOPE_SYNC,
agent,
ephemeral: key.ephemeral,
authkey_id: Some(&key.id),
created_at: &now,
},
Some(max_devices),
);
match inserted {
Ok(Inserted::Done(device)) => {
return no_store(json(
StatusCode::OK,
&EnrollApproved {
device_id: device.id,
name: device.name,
scope: device.scope,
ephemeral: device.ephemeral,
},
))
}
Ok(Inserted::NameTaken) => continue,
Ok(Inserted::KeyFull) => {
return error(
StatusCode::FORBIDDEN,
&format!(
"forbidden: this authkey already has its {} devices; \
revoke one, or make another authkey",
max_devices
),
)
}
Err(e) => return internal(e),
}
}
internal(anyhow::anyhow!(
"could not find a free name for {device_id}"
))
}
pub(super) async fn handle_poll(
State(state): State<Arc<AppState>>,
bytes: Result<Bytes, BytesRejection>,
) -> Response {
let req: EnrollPollRequest = match small_body(bytes).and_then(|b| body(&b)) {
Ok(req) => req,
Err(refused) => return refused.into_response(),
};
if req.enrollment_id.is_empty() {
return error(StatusCode::BAD_REQUEST, "enrollment_id is required");
}
let poll = state.store.poll_enrollment(
&req.enrollment_id,
OffsetDateTime::now_utc(),
Duration::from_secs(POLL_INTERVAL_SECONDS),
);
let code = match poll {
Ok(Poll::Approved { device_id, scope }) => {
return no_store(json(
StatusCode::OK,
&EnrollPollResponse { device_id, scope },
))
}
Ok(Poll::Pending) => AUTHORIZATION_PENDING,
Ok(Poll::SlowDown) => SLOW_DOWN,
Ok(Poll::Expired) => EXPIRED_TOKEN,
Ok(Poll::Denied) => ACCESS_DENIED,
Ok(Poll::Unknown) => INVALID_GRANT,
Err(e) => return internal(e),
};
no_store(error(StatusCode::BAD_REQUEST, code))
}
fn user_code(input: &str) -> Result<String, Refusal> {
normalize_user_code(input).ok_or_else(|| {
Refusal::new(
StatusCode::BAD_REQUEST,
"user_code must be the 8 letters the device shows, such as WDJB-MJHT",
)
})
}
fn undecided<T>(decision: Decision<T>) -> Result<T, Refusal> {
match decision {
Decision::Done(v) => Ok(v),
Decision::NotFound => Err(Refusal::new(
StatusCode::NOT_FOUND,
"no enrolment is waiting with that code",
)),
Decision::Expired => Err(Refusal::new(
StatusCode::GONE,
"that code has expired; start the enrolment again",
)),
Decision::AlreadyDecided => Err(Refusal::new(
StatusCode::CONFLICT,
"that code was already approved or denied",
)),
Decision::KeyMismatch => Err(Refusal::new(
StatusCode::CONFLICT,
"that code's key does not have the fingerprint given; nothing was approved",
)),
Decision::NameTaken(name) => Err(name_taken(&name)),
}
}
pub(super) async fn handle_approve(State(state): State<Arc<AppState>>, bytes: Bytes) -> Response {
let req: ApproveRequest = match body(&bytes) {
Ok(req) => req,
Err(refused) => return refused.into_response(),
};
if req.scope != SCOPE_SYNC && req.scope != SCOPE_ADMIN {
return error(StatusCode::BAD_REQUEST, "scope must be sync or admin");
}
let code = match user_code(&req.user_code) {
Ok(code) => code,
Err(refused) => return refused.into_response(),
};
let device_id = match new_id("dev_", 16) {
Ok(id) => id,
Err(e) => return internal(e),
};
match state
.store
.approve_enrollment(
&code,
&device_id,
&req.scope,
&now(),
req.fingerprint.as_deref(),
)
.map(undecided)
{
Ok(Ok(device)) => json(StatusCode::OK, &device),
Ok(Err(refused)) => refused.into_response(),
Err(e) => internal(e),
}
}
pub(super) async fn handle_deny(State(state): State<Arc<AppState>>, bytes: Bytes) -> Response {
let req: DenyRequest = match body(&bytes) {
Ok(req) => req,
Err(refused) => return refused.into_response(),
};
let code = match user_code(&req.user_code) {
Ok(code) => code,
Err(refused) => return refused.into_response(),
};
match state.store.deny_enrollment(&code, &now()).map(undecided) {
Ok(Ok(name)) => json(
StatusCode::OK,
&DenyResponse {
user_code: code,
name,
denied: true,
},
),
Ok(Err(refused)) => refused.into_response(),
Err(e) => internal(e),
}
}
pub(super) async fn handle_pending(
State(state): State<Arc<AppState>>,
Path(input): Path<String>,
) -> Response {
let code = match user_code(&input) {
Ok(code) => code,
Err(refused) => return refused.into_response(),
};
let now = OffsetDateTime::now_utc();
match state
.store
.pending_enrollment(&code, &format_timestamp(now))
.map(undecided)
{
Ok(Ok(waiting)) => {
let fingerprint = recall_wire::signature::parse_public_key(&waiting.public_key)
.map(|k| recall_wire::signature::fingerprint(&k))
.unwrap_or_default();
let expires_in = parse_timestamp(&waiting.expires_at)
.map(|at| (at - now).whole_seconds().max(0) as u64)
.unwrap_or(0);
no_store(json(
StatusCode::OK,
&PendingEnrollment {
user_code: code,
name: waiting.name,
agent: waiting.agent,
fingerprint,
expires_in,
},
))
}
Ok(Err(refused)) => no_store(refused.into_response()),
Err(e) => internal(e),
}
}
pub(super) async fn handle_me(Extension(caller): Extension<Caller>) -> Response {
match caller {
Caller::Device {
id,
name,
scope,
ephemeral,
} => json(
StatusCode::OK,
&DeviceIdentity {
device_id: id,
name,
scope,
ephemeral,
},
),
Caller::Operator => error(
StatusCode::NOT_FOUND,
"not a device: this request was authenticated with RECALL_TOKEN",
),
}
}
pub(super) async fn handle_list_devices(State(state): State<Arc<AppState>>) -> Response {
match state.store.devices() {
Ok(devices) => json(StatusCode::OK, &DeviceList { devices }),
Err(e) => internal(e),
}
}
pub(super) async fn handle_revoke_device(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
match state.store.revoke_device(&id, &now()) {
Ok(Some(device)) => json(StatusCode::OK, &device),
Ok(None) => error(StatusCode::NOT_FOUND, "no device has that id"),
Err(e) => internal(e),
}
}
pub(super) async fn handle_create_authkey(
State(state): State<Arc<AppState>>,
bytes: Bytes,
) -> Response {
let req: AuthkeyRequest = match body(&bytes) {
Ok(req) => req,
Err(refused) => return refused.into_response(),
};
if !(1..=MAX_AUTHKEY_DAYS).contains(&req.expires_in_days) {
return error(StatusCode::BAD_REQUEST, "expires_in_days must be 1 to 365");
}
if !displayable(&req.tag, MAX_TAG_CHARS) {
return error(
StatusCode::BAD_REQUEST,
"tag must be at most 32 characters, with no control, format or invisible characters",
);
}
if req.max_devices == Some(0) {
return error(StatusCode::BAD_REQUEST, "max_devices must be at least 1");
}
let max_devices = req.max_devices.unwrap_or(DEFAULT_MAX_DEVICES);
let (id, secret) = match (new_id("ak_", 10), new_id(AUTHKEY_PREFIX, 32)) {
(Ok(id), Ok(secret)) => (id, secret),
(Err(e), _) | (_, Err(e)) => return internal(e),
};
let expires_at = later(Duration::from_secs(
u64::from(req.expires_in_days) * 24 * 60 * 60,
));
let stored = state.store.insert_authkey(&NewAuthkey {
id: &id,
key_sha256: &recall_wire::content_sha256(&secret),
tag: &plain_name(&req.tag),
ephemeral: req.ephemeral,
max_devices: Some(max_devices),
created_at: &now(),
expires_at: &expires_at,
});
match stored {
Ok(key) => no_store(json(
StatusCode::OK,
&AuthkeyCreated {
id: key.id,
key: secret,
tag: key.tag,
ephemeral: key.ephemeral,
max_devices: key.max_devices,
created_at: key.created_at,
expires_at: key.expires_at,
},
)),
Err(e) => internal(e),
}
}
pub(super) async fn handle_list_authkeys(State(state): State<Arc<AppState>>) -> Response {
match state.store.authkeys() {
Ok(authkeys) => json(StatusCode::OK, &AuthkeyList { authkeys }),
Err(e) => internal(e),
}
}
pub(super) async fn handle_revoke_authkey(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
bytes: Bytes,
) -> Response {
let req: AuthkeyRevokeRequest = if bytes.iter().all(u8::is_ascii_whitespace) {
AuthkeyRevokeRequest::default()
} else {
match body(&bytes) {
Ok(req) => req,
Err(refused) => return refused.into_response(),
}
};
match state.store.revoke_authkey(&id, &now(), req.revoke_devices) {
Ok(Some(key)) => json(StatusCode::OK, &key),
Ok(None) => error(StatusCode::NOT_FOUND, "no authkey has that id"),
Err(e) => internal(e),
}
}
pub(super) fn capability() -> recall_wire::DevicesCapability {
recall_wire::DevicesCapability {
enroll_path: devices::ENROLL_PATH.to_string(),
code_ttl_seconds: CODE_TTL_SECONDS,
poll_interval_seconds: POLL_INTERVAL_SECONDS,
signature_window_seconds: super::auth::WINDOW,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base32_matches_rfc4648() {
for (input, want) in [
("", ""),
("f", "my"),
("fo", "mzxq"),
("foo", "mzxw6"),
("foob", "mzxw6yq"),
("fooba", "mzxw6ytb"),
("foobar", "mzxw6ytboi"),
] {
assert_eq!(base32(input.as_bytes()), want, "{input:?}");
}
}
#[test]
fn ids_have_their_prefix_and_128_bits() {
let id = new_id("dev_", 16).unwrap();
assert!(id.starts_with("dev_") && id.len() == 4 + 26, "{id}");
assert_ne!(id, new_id("dev_", 16).unwrap());
let key = new_id(AUTHKEY_PREFIX, 32).unwrap();
assert_eq!(key.len(), AUTHKEY_PREFIX.len() + 52, "{key}");
}
#[test]
fn user_codes_use_only_the_alphabet() {
for _ in 0..100 {
let code = new_user_code().unwrap();
assert_eq!(code.len(), 9, "{code}");
assert_eq!(&code[4..5], "-");
assert!(code
.chars()
.filter(|c| *c != '-')
.all(|c| USER_CODE_ALPHABET.contains(&(c as u8))));
}
}
}