Blob: src/auth.rs
Raw · Blame
use sha2::{Digest, Sha256};
use ssh_key::{Algorithm, EcdsaCurve, HashAlg, PublicKey, SshSig};
use thiserror::Error;
use crate::codec::{decode_lower_hex, encode_lower_hex};
use url::Url;
const KEYLESS_CHALLENGE_HEADER: &str = "tit-auth-v2";
const CHALLENGE_PURPOSE: &str = "web-login";
const SIGNATURE_NAMESPACE: &str = "tit-auth";
const MAX_KEY_BYTES: usize = 16 * 1024;
const MAX_CHALLENGE_BYTES: usize = 4 * 1024;
const MAX_SIGNATURE_BYTES: usize = 16 * 1024;
const MAX_CHALLENGE_LIFETIME_SECONDS: u64 = 5 * 60;
const NONCE_BYTES: usize = 32;
const MINIMUM_RSA_BITS: u32 = 3_072;
#[derive(Clone, Debug)]
pub(crate) struct SshPublicKey {
key: PublicKey,
canonical: String,
fingerprint: String,
}
impl SshPublicKey {
pub(crate) fn parse(input: &str) -> Result<Self, AuthError> {
if input.len() > MAX_KEY_BYTES {
return Err(AuthError::InputTooLarge("SSH public key"));
}
let key = PublicKey::from_openssh(input).map_err(AuthError::PublicKey)?;
Self::from_public_key(key)
}
fn from_public_key(mut key: PublicKey) -> Result<Self, AuthError> {
validate_key_algorithm(&key)?;
key.set_comment("");
let canonical = key.to_openssh().map_err(AuthError::PublicKey)?;
let fingerprint = key.fingerprint(HashAlg::Sha256).to_string();
Ok(Self {
key,
canonical,
fingerprint,
})
}
pub(crate) fn canonical(&self) -> &str {
&self.canonical
}
pub(crate) fn fingerprint(&self) -> &str {
&self.fingerprint
}
pub(crate) fn public_key(&self) -> &PublicKey {
&self.key
}
}
pub(crate) fn format_keyless_login_challenge(
origin: &str,
username: &str,
nonce: &[u8; NONCE_BYTES],
issued_at: u64,
expires_at: u64,
) -> String {
format!(
"{KEYLESS_CHALLENGE_HEADER}\npurpose={CHALLENGE_PURPOSE}\norigin={origin}\nusername={username}\nnonce={}\nissued-at={issued_at}\nexpires-at={expires_at}\n",
encode_lower_hex(nonce)
)
}
pub(crate) fn verify_keyless_login_challenge(
origin: &str,
challenge: &str,
signature: &str,
expected_username: &str,
now: u64,
) -> Result<PersistentVerifiedLogin, AuthError> {
if challenge.len() > MAX_CHALLENGE_BYTES {
return Err(AuthError::InputTooLarge("login challenge"));
}
if signature.len() > MAX_SIGNATURE_BYTES {
return Err(AuthError::InputTooLarge("SSHSIG envelope"));
}
validate_username(expected_username)?;
let fields = KeylessChallengeFields::parse(challenge)?;
if fields.origin != origin {
return Err(AuthError::WrongOrigin);
}
if fields.username != expected_username {
return Err(AuthError::WrongUsername);
}
if fields.expires_at <= fields.issued_at
|| fields.expires_at - fields.issued_at > MAX_CHALLENGE_LIFETIME_SECONDS
{
return Err(AuthError::InvalidLifetime);
}
if now < fields.issued_at || now > fields.expires_at {
return Err(AuthError::ExpiredChallenge);
}
let sshsig = SshSig::from_pem(signature).map_err(AuthError::SignatureEnvelope)?;
let key = SshPublicKey::from_public_key(PublicKey::new(sshsig.public_key().clone(), ""))?;
validate_signature_algorithm(key.public_key(), &sshsig)?;
key.public_key()
.verify(SIGNATURE_NAMESPACE, challenge.as_bytes(), &sshsig)
.map_err(AuthError::SignatureVerification)?;
Ok(PersistentVerifiedLogin {
username: fields.username.to_owned(),
fingerprint: key.fingerprint().to_owned(),
nonce_hash: hash_nonce(&fields.nonce),
})
}
pub(crate) fn login_origin(public_url: &Url) -> Result<String, AuthError> {
if !matches!(public_url.scheme(), "http" | "https")
|| public_url.host().is_none()
|| !public_url.username().is_empty()
|| public_url.password().is_some()
|| public_url.query().is_some()
|| public_url.fragment().is_some()
|| public_url.path() != "/"
{
return Err(AuthError::InvalidOrigin);
}
Ok(public_url.origin().ascii_serialization())
}
pub(crate) struct PersistentVerifiedLogin {
pub(crate) username: String,
pub(crate) fingerprint: String,
pub(crate) nonce_hash: [u8; 32],
}
#[derive(Debug, Error)]
pub(crate) enum AuthError {
#[error("{0} is too large")]
InputTooLarge(&'static str),
#[error("SSH public key is not valid: {0}")]
PublicKey(ssh_key::Error),
#[error("SSH public key algorithm is not supported")]
UnsupportedKeyAlgorithm,
#[error("RSA key has {actual} bits, but a minimum of {minimum} bits is necessary")]
UndersizedRsa { actual: u32, minimum: u32 },
#[error("username is not valid")]
InvalidUsername,
#[error("canonical origin is not valid")]
InvalidOrigin,
#[error("challenge lifetime is not valid")]
InvalidLifetime,
#[error("login challenge is not valid")]
MalformedChallenge,
#[error("login challenge has the wrong origin")]
WrongOrigin,
#[error("login challenge has the wrong username")]
WrongUsername,
#[error("login challenge has expired or is not active")]
ExpiredChallenge,
#[error("SSHSIG envelope is not valid: {0}")]
SignatureEnvelope(ssh_key::Error),
#[error("SSHSIG algorithm is not supported")]
UnsupportedSignatureAlgorithm,
#[error("SSHSIG verification failed: {0}")]
SignatureVerification(ssh_key::Error),
}
struct KeylessChallengeFields<'a> {
origin: &'a str,
username: &'a str,
nonce: [u8; NONCE_BYTES],
issued_at: u64,
expires_at: u64,
}
impl<'a> KeylessChallengeFields<'a> {
fn parse(challenge: &'a str) -> Result<Self, AuthError> {
let body = challenge
.strip_suffix('\n')
.ok_or(AuthError::MalformedChallenge)?;
let mut lines = body.split('\n');
if lines.next() != Some(KEYLESS_CHALLENGE_HEADER)
|| lines.next() != Some("purpose=web-login")
{
return Err(AuthError::MalformedChallenge);
}
let origin = field(lines.next(), "origin=")?;
let username = field(lines.next(), "username=")?;
validate_username(username)?;
let nonce = decode_hex(field(lines.next(), "nonce=")?)?;
let issued_at = parse_time(field(lines.next(), "issued-at=")?)?;
let expires_at = parse_time(field(lines.next(), "expires-at=")?)?;
if lines.next().is_some() || origin.is_empty() || origin.contains(['\r', '\n']) {
return Err(AuthError::MalformedChallenge);
}
Ok(Self {
origin,
username,
nonce,
issued_at,
expires_at,
})
}
}
fn validate_key_algorithm(key: &PublicKey) -> Result<(), AuthError> {
match key.key_data() {
ssh_key::public::KeyData::Ed25519(_) => Ok(()),
ssh_key::public::KeyData::Ecdsa(key) if key.curve() == EcdsaCurve::NistP256 => Ok(()),
ssh_key::public::KeyData::Rsa(key) if key.key_size() < MINIMUM_RSA_BITS => {
Err(AuthError::UndersizedRsa {
actual: key.key_size(),
minimum: MINIMUM_RSA_BITS,
})
}
_ => Err(AuthError::UnsupportedKeyAlgorithm),
}
}
fn validate_signature_algorithm(key: &PublicKey, signature: &SshSig) -> Result<(), AuthError> {
let valid = match (key.key_data(), signature.algorithm()) {
(ssh_key::public::KeyData::Ed25519(_), Algorithm::Ed25519) => true,
(ssh_key::public::KeyData::Ecdsa(key), Algorithm::Ecdsa { curve }) => {
key.curve() == EcdsaCurve::NistP256 && curve == EcdsaCurve::NistP256
}
_ => false,
};
if valid {
Ok(())
} else {
Err(AuthError::UnsupportedSignatureAlgorithm)
}
}
pub(crate) fn validate_username(username: &str) -> Result<(), AuthError> {
let bytes = username.as_bytes();
let valid_character = |byte: u8| byte.is_ascii_lowercase() || byte.is_ascii_digit();
if !(1..=40).contains(&bytes.len())
|| !valid_character(bytes[0])
|| !valid_character(bytes[bytes.len() - 1])
|| !bytes
.iter()
.all(|byte| valid_character(*byte) || *byte == b'-')
|| matches!(
username,
"admin" | "api" | "assets" | "feeds" | "issues" | "setup"
)
{
return Err(AuthError::InvalidUsername);
}
Ok(())
}
fn field<'a>(line: Option<&'a str>, prefix: &str) -> Result<&'a str, AuthError> {
line.and_then(|line| line.strip_prefix(prefix))
.ok_or(AuthError::MalformedChallenge)
}
fn parse_time(value: &str) -> Result<u64, AuthError> {
if value.is_empty()
|| !value.bytes().all(|byte| byte.is_ascii_digit())
|| (value.len() > 1 && value.starts_with('0'))
{
return Err(AuthError::MalformedChallenge);
}
value
.parse::<u64>()
.map_err(|_| AuthError::MalformedChallenge)
}
fn hash_nonce(nonce: &[u8; NONCE_BYTES]) -> [u8; 32] {
Sha256::digest(nonce).into()
}
fn decode_hex(value: &str) -> Result<[u8; NONCE_BYTES], AuthError> {
let decoded = decode_lower_hex(value.as_bytes()).ok_or(AuthError::MalformedChallenge)?;
let decoded: [u8; NONCE_BYTES] = decoded
.try_into()
.map_err(|_| AuthError::MalformedChallenge)?;
Ok(decoded)
}