use serde::{Deserialize, Serialize};
use crate::signature::{self, SignatureError, VerifyingKey};
pub const ENROLL_PATH: &str = "/v1/devices/enroll";
pub const ENROLL_POLL_PATH: &str = "/v1/devices/enroll/poll";
pub const DEVICES_PATH: &str = "/v1/devices";
pub const DEVICES_ME_PATH: &str = "/v1/devices/me";
pub const APPROVE_PATH: &str = "/v1/devices/approve";
pub const DENY_PATH: &str = "/v1/devices/deny";
pub const AUTHKEYS_PATH: &str = "/v1/authkeys";
pub fn revoke_device_path(id: &str) -> String {
format!("{DEVICES_PATH}/{id}/revoke")
}
pub fn revoke_authkey_path(id: &str) -> String {
format!("{AUTHKEYS_PATH}/{id}/revoke")
}
pub fn pending_path(user_code: &str) -> String {
format!("{DEVICES_PATH}/pending/{user_code}")
}
pub const SCOPE_SYNC: &str = "sync";
pub const SCOPE_ADMIN: &str = "admin";
pub const SCOPE_WORKER: &str = "worker";
pub const CODE_TTL_SECONDS: u64 = 900;
pub const POLL_INTERVAL_SECONDS: u64 = 5;
pub const AUTHKEY_PREFIX: &str = "recall-ak-";
pub const USER_CODE_ALPHABET: &[u8; 20] = b"BCDFGHJKLMNPQRSTVWXZ";
pub const MAX_NAME_CHARS: usize = 64;
pub const MAX_AGENT_CHARS: usize = 256;
pub const MAX_TAG_CHARS: usize = 32;
pub const MAX_AUTHKEY_DAYS: u32 = 365;
pub const DEFAULT_MAX_DEVICES: u32 = 25;
pub const AUTHORIZATION_PENDING: &str = "authorization_pending";
pub const SLOW_DOWN: &str = "slow_down";
pub const EXPIRED_TOKEN: &str = "expired_token";
pub const ACCESS_DENIED: &str = "access_denied";
pub const INVALID_GRANT: &str = "invalid_grant";
pub fn normalize_user_code(input: &str) -> Option<String> {
let chars: Vec<char> = input
.chars()
.map(|c| c.to_ascii_uppercase())
.filter(|c| c.is_ascii() && USER_CODE_ALPHABET.contains(&(*c as u8)))
.collect();
if chars.len() != 8 {
return None;
}
let (a, b) = chars.split_at(4);
Some(format!(
"{}-{}",
a.iter().collect::<String>(),
b.iter().collect::<String>()
))
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct EnrollRequest {
pub name: String,
pub public_key: String,
#[serde(default)]
pub agent: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authkey: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum EnrollError {
#[error("name and public_key are required")]
Missing,
#[error("name must be at most 64 characters, with no control, format or invisible characters")]
Name,
#[error(
"agent must be at most 256 characters, with no control, format or invisible characters"
)]
Agent,
#[error("{0}")]
PublicKey(SignatureError),
}
const HIDDEN: &[(u32, u32)] = &[
(0x00AD, 0x00AD),
(0x034F, 0x034F),
(0x0600, 0x0605),
(0x061C, 0x061C),
(0x06DD, 0x06DD),
(0x070F, 0x070F),
(0x0890, 0x0891),
(0x08E2, 0x08E2),
(0x115F, 0x1160),
(0x17B4, 0x17B5),
(0x180B, 0x180F),
(0x200B, 0x200F),
(0x2028, 0x202E),
(0x2060, 0x2064),
(0x2066, 0x206F),
(0x2800, 0x2800),
(0x3164, 0x3164),
(0xFE00, 0xFE0F),
(0xFEFF, 0xFEFF),
(0xFFA0, 0xFFA0),
(0xFFF9, 0xFFFB),
(0x110BD, 0x110BD),
(0x110CD, 0x110CD),
(0x13430, 0x1343F),
(0x1BCA0, 0x1BCA3),
(0x1D173, 0x1D17A),
(0xE0001, 0xE0001),
(0xE0020, 0xE007F),
(0xE0100, 0xE01EF),
];
pub fn is_hidden(c: char) -> bool {
let cp = u32::from(c);
c.is_control() || HIDDEN.iter().any(|&(lo, hi)| (lo..=hi).contains(&cp))
}
pub fn displayable(text: &str, max: usize) -> bool {
text.chars().count() <= max && !text.chars().any(is_hidden)
}
impl EnrollRequest {
pub fn validate(&self) -> Result<VerifyingKey, EnrollError> {
if self.name.trim().is_empty() || self.public_key.is_empty() {
return Err(EnrollError::Missing);
}
if !displayable(&self.name, MAX_NAME_CHARS) {
return Err(EnrollError::Name);
}
if !displayable(&self.agent, MAX_AGENT_CHARS) {
return Err(EnrollError::Agent);
}
signature::parse_public_key(&self.public_key).map_err(EnrollError::PublicKey)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnrollPending {
pub enrollment_id: String,
pub user_code: String,
pub expires_in: u64,
pub interval: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnrollApproved {
pub device_id: String,
pub name: String,
pub scope: String,
pub ephemeral: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnrollPollRequest {
pub enrollment_id: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnrollPollResponse {
pub device_id: String,
pub scope: String,
}
fn default_scope() -> String {
SCOPE_SYNC.to_string()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApproveRequest {
pub user_code: String,
#[serde(default = "default_scope")]
pub scope: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fingerprint: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DenyRequest {
pub user_code: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PendingEnrollment {
pub user_code: String,
pub name: String,
pub agent: String,
pub fingerprint: String,
pub expires_in: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DenyResponse {
pub user_code: String,
pub name: String,
pub denied: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Device {
pub id: String,
pub name: String,
pub scope: String,
pub ephemeral: bool,
pub agent: String,
pub fingerprint: String,
pub public_key: String,
pub authkey_id: Option<String>,
pub created_at: String,
pub last_seen: Option<String>,
pub revoked_at: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeviceIdentity {
pub device_id: String,
pub name: String,
pub scope: String,
pub ephemeral: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeviceList {
pub devices: Vec<Device>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthkeyRequest {
#[serde(default)]
pub tag: String,
pub expires_in_days: u32,
#[serde(default = "default_true")]
pub ephemeral: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_devices: Option<u32>,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthkeyRevokeRequest {
#[serde(default)]
pub revoke_devices: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Authkey {
pub id: String,
pub tag: String,
pub ephemeral: bool,
pub max_devices: Option<u32>,
pub created_at: String,
pub expires_at: String,
pub revoked_at: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthkeyCreated {
pub id: String,
pub key: String,
pub tag: String,
pub ephemeral: bool,
pub max_devices: Option<u32>,
pub created_at: String,
pub expires_at: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthkeyList {
pub authkeys: Vec<Authkey>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DevicesCapability {
pub enroll_path: String,
pub code_ttl_seconds: u64,
pub poll_interval_seconds: u64,
pub signature_window_seconds: u64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_codes_are_read_the_way_rfc8628_suggests() {
assert_eq!(
normalize_user_code("WDJB-MJHT").as_deref(),
Some("WDJB-MJHT")
);
assert_eq!(
normalize_user_code("wdjbmjht").as_deref(),
Some("WDJB-MJHT")
);
assert_eq!(
normalize_user_code(" wdjb mjht ").as_deref(),
Some("WDJB-MJHT")
);
assert_eq!(normalize_user_code("WDJB-MJHA"), None);
assert_eq!(normalize_user_code("WDJB-MJH"), None);
assert_eq!(normalize_user_code("WDJB-MJHTB"), None);
assert_eq!(normalize_user_code(""), None);
}
#[test]
fn enrolment_requests_are_checked_the_same_way_on_both_sides() {
let key = "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs";
let ok = EnrollRequest {
name: "laptop".into(),
public_key: key.into(),
agent: "recall/0.4.1 (macos-aarch64)".into(),
authkey: None,
};
assert!(ok.validate().is_ok());
for (req, want) in [
(
EnrollRequest {
name: " ".into(),
..ok.clone()
},
EnrollError::Missing,
),
(
EnrollRequest {
public_key: String::new(),
..ok.clone()
},
EnrollError::Missing,
),
(
EnrollRequest {
name: "x".repeat(65),
..ok.clone()
},
EnrollError::Name,
),
(
EnrollRequest {
name: "lap\ntop".into(),
..ok.clone()
},
EnrollError::Name,
),
(
EnrollRequest {
agent: "a".repeat(257),
..ok.clone()
},
EnrollError::Agent,
),
(
EnrollRequest {
public_key: "short".into(),
..ok.clone()
},
EnrollError::PublicKey(SignatureError::PublicKey),
),
] {
assert_eq!(req.validate(), Err(want), "{req:?}");
}
let wide = EnrollRequest {
name: "é".repeat(64),
..ok
};
assert!(wide.validate().is_ok());
}
#[test]
fn approve_defaults_to_the_narrow_scope() {
let req: ApproveRequest = serde_json::from_str(r#"{"user_code":"WDJB-MJHT"}"#).unwrap();
assert_eq!(req.scope, SCOPE_SYNC);
}
#[test]
fn an_authkey_request_needs_an_expiry() {
assert!(serde_json::from_str::<AuthkeyRequest>(r#"{"tag":"cloud"}"#).is_err());
let req: AuthkeyRequest = serde_json::from_str(r#"{"expires_in_days":90}"#).unwrap();
assert_eq!(req.tag, "");
assert!(req.ephemeral, "a key's devices are ephemeral unless asked");
assert_eq!(req.max_devices, None);
let req: AuthkeyRevokeRequest = serde_json::from_str("{}").unwrap();
assert!(!req.revoke_devices);
}
#[test]
fn a_name_cannot_hide_characters() {
for hidden in [
'\u{200B}',
'\u{202E}',
'\u{2066}',
'\u{FEFF}',
'\u{3164}',
'\u{00AD}',
'\u{FE0F}',
'\u{E0041}',
'\u{2028}',
'\u{0007}',
] {
let name = format!("lap{hidden}top");
assert!(
!displayable(&name, MAX_NAME_CHARS),
"{:04X}",
u32::from(hidden)
);
}
for fine in [
"laptop",
"Pim's MacBook Air",
"büro-rechner",
"ノートPC",
"laptop 2",
] {
assert!(displayable(fine, MAX_NAME_CHARS), "{fine}");
}
}
#[test]
fn device_nulls_are_sent_not_omitted() {
let text = serde_json::to_string(&Device::default()).unwrap();
for key in ["authkey_id", "last_seen", "revoked_at"] {
assert!(text.contains(&format!("\"{key}\":null")), "{text}");
}
}
}