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)
}
}
#[cfg_attr(windows, allow(dead_code))]
#[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()
}
pub(crate) fn base64_encode(input: &[u8]) -> String {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for chunk in input.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
}
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 pad = bytes
.iter()
.rev()
.take(2)
.take_while(|&&b| b == b'=')
.count();
let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
let (chunks, _) = bytes.as_chunks::<4>();
let last = chunks.len() - 1;
for (n, chunk) in chunks.iter().enumerate() {
let pad = if n == last { pad } else { 0 };
let mut sextets = [0u8; 4];
for (i, &byte) in chunk.iter().enumerate().take(4 - pad) {
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,
_ => 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 {
base64_encode(input.as_bytes())
}
#[test]
fn base64_encode_round_trips_through_the_decoder_beside_it() {
for input in ["a", "ab", "abc", "alice:s3cret", "hunter2"] {
let encoded = base64_encode(input.as_bytes());
assert_eq!(
base64_decode(&encoded).as_deref(),
Some(input.as_bytes()),
"{input:?} did not survive the round trip as {encoded:?}"
);
}
}
#[test]
fn base64_decode_refuses_padding_that_is_not_the_end_of_the_input() {
for input in ["", "Zm=v", "Zg==Zg==", "Z===", "=Zg=", "Zg=A", "Zm8=Zm8="] {
assert_eq!(base64_decode(input), None, "{input:?} was accepted");
}
}
#[test]
fn base64_decode_decodes_the_rfc_4648_vectors() {
for (encoded, prefix_len) in [
("Zg==", 1),
("Zm8=", 2),
("Zm9v", 3),
("Zm9vYg==", 4),
("Zm9vYmE=", 5),
("Zm9vYmFy", 6),
] {
assert_eq!(
base64_decode(encoded).as_deref(),
Some(&b"foobar"[..prefix_len]),
"{encoded:?}"
);
}
}
#[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}"
);
}
}