use core::fmt;
use std::path::{Path, PathBuf};
use ring::hmac;
const MAX_HEADER_LEN: usize = 1024;
pub struct Credentials {
expected: Vec<u8>,
}
impl Credentials {
fn from_pair(user: &str, password: &str) -> Self {
Self {
expected: format!("{user}:{password}").into_bytes(),
}
}
#[must_use]
pub fn satisfies(&self, header: Option<&str>) -> bool {
let Some(header) = header else {
return false;
};
let Some(encoded) = header.strip_prefix("Basic ") else {
return false;
};
if encoded.len() > MAX_HEADER_LEN {
return false;
}
let Some(presented) = base64_decode(encoded) else {
return false;
};
credentials_match(&presented, &self.expected)
}
}
#[derive(Debug)]
pub enum AuthError {
Io {
path: PathBuf,
source: std::io::Error,
},
Mode {
path: PathBuf,
mode: u32,
},
Malformed {
path: PathBuf,
},
}
impl fmt::Display for AuthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
Self::Mode { path, mode } => write!(
f,
"{}: mode {:03o} is readable by the group or the world; \
chmod 600 it",
path.display(),
mode & 0o7777
),
Self::Malformed { path } => write!(
f,
"{}: expected exactly one non-empty line of the form user:password",
path.display()
),
}
}
}
impl core::error::Error for AuthError {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
Self::Mode { .. } | Self::Malformed { .. } => None,
}
}
}
#[cfg(unix)]
fn check_mode(path: &Path) -> Result<(), AuthError> {
use std::os::unix::fs::PermissionsExt;
let metadata = std::fs::metadata(path).map_err(|source| AuthError::Io {
path: path.to_path_buf(),
source,
})?;
let mode = metadata.permissions().mode();
if mode & 0o077 != 0 {
return Err(AuthError::Mode {
path: path.to_path_buf(),
mode,
});
}
Ok(())
}
#[cfg(not(unix))]
fn check_mode(_path: &Path) -> Result<(), AuthError> {
Ok(())
}
pub fn load(path: &Path) -> Result<Credentials, AuthError> {
let contents = std::fs::read_to_string(path).map_err(|source| AuthError::Io {
path: path.to_path_buf(),
source,
})?;
let mut lines = contents.lines().filter(|line| !line.trim().is_empty());
let malformed = || AuthError::Malformed {
path: path.to_path_buf(),
};
let line = lines.next().ok_or_else(malformed)?;
if lines.next().is_some() {
return Err(malformed());
}
let (user, password) = line.split_once(':').ok_or_else(malformed)?;
check_mode(path)?;
Ok(Credentials::from_pair(user, password))
}
fn credentials_match(presented: &[u8], expected: &[u8]) -> bool {
let key = hmac::Key::new(hmac::HMAC_SHA256, &[0u8; 32]);
let tag = hmac::sign(&key, presented);
hmac::verify(&key, expected, tag.as_ref()).is_ok()
}
fn base64_decode(input: &str) -> Option<Vec<u8>> {
let bytes = input.as_bytes();
if bytes.is_empty() || !bytes.len().is_multiple_of(4) {
return None;
}
let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
for chunk in bytes.chunks_exact(4) {
let mut sextets = [0u8; 4];
let mut pad = 0u8;
for (i, &byte) in chunk.iter().enumerate() {
sextets[i] = match byte {
b'A'..=b'Z' => byte - b'A',
b'a'..=b'z' => byte - b'a' + 26,
b'0'..=b'9' => byte - b'0' + 52,
b'+' => 62,
b'/' => 63,
b'=' if i >= 2 => {
pad += 1;
0
}
_ => return None,
};
}
out.push((sextets[0] << 2) | (sextets[1] >> 4));
if pad < 2 {
out.push((sextets[1] << 4) | (sextets[2] >> 2));
}
if pad < 1 {
out.push((sextets[2] << 6) | sextets[3]);
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn base64(input: &str) -> String {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let bytes = input.as_bytes();
let mut out = String::new();
for chunk in bytes.chunks(3) {
let b0 = chunk[0];
let b1 = chunk.get(1).copied();
let b2 = chunk.get(2).copied();
out.push(ALPHABET[(b0 >> 2) as usize] as char);
out.push(ALPHABET[(((b0 << 4) | (b1.unwrap_or(0) >> 4)) & 0x3f) as usize] as char);
out.push(match b1 {
Some(b1) => {
ALPHABET[(((b1 << 2) | (b2.unwrap_or(0) >> 6)) & 0x3f) as usize] as char
}
None => '=',
});
out.push(match b2 {
Some(b2) => ALPHABET[(b2 & 0x3f) as usize] as char,
None => '=',
});
}
out
}
#[test]
fn only_the_exact_pair_is_accepted() {
let creds = Credentials::from_pair("alice", "s3cret");
let ok = format!("Basic {}", base64("alice:s3cret"));
assert!(creds.satisfies(Some(&ok)));
assert!(!creds.satisfies(None));
assert!(!creds.satisfies(Some(&format!("Basic {}", base64("alice:s3cres")))));
assert!(!creds.satisfies(Some(&format!("Basic {}", base64("alicf:s3cret")))));
assert!(!creds.satisfies(Some("Basic")), "no credentials at all");
assert!(
!creds.satisfies(Some(&format!("Bearer {}", base64("alice:s3cret")))),
"the scheme is part of the check"
);
}
#[cfg(unix)]
#[test]
fn a_group_readable_creds_file_is_refused() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("creds");
std::fs::write(&path, "alice:s3cret\n").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
let err = load(&path).err().unwrap();
assert!(matches!(err, AuthError::Mode { .. }), "{err:?}");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
assert!(load(&path).is_ok());
}
#[test]
fn a_mode_error_prints_the_permission_bits_not_the_raw_st_mode() {
let err = AuthError::Mode {
path: PathBuf::from("/srv/creds"),
mode: 0o100_644,
};
assert_eq!(
err.to_string(),
"/srv/creds: mode 644 is readable by the group or the world; chmod 600 it"
);
}
#[test]
fn no_error_message_quotes_the_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("creds");
std::fs::write(&path, "no-colon-here-s3cret\n").unwrap();
let message = load(&path).err().unwrap().to_string();
assert!(!message.contains("s3cret"), "{message}");
assert!(
message.contains("creds"),
"it must still name the file: {message}"
);
}
}