openauth-cli 0.0.4

Command-line tools for OpenAuth.
Documentation
use base64::Engine;
use rand::RngCore;
use serde::Serialize;

const DEFAULT_SECRET: &str = "openauth-secret-123456789012345678901";

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum SecretSeverity {
    Ok,
    Warning,
    Error,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SecretAssessment {
    pub severity: SecretSeverity,
    pub message: String,
}

pub fn generate_secret(bytes: usize) -> String {
    let mut buffer = vec![0_u8; bytes.max(32)];
    rand::rngs::OsRng.fill_bytes(&mut buffer);
    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buffer)
}

pub fn assess_secret(secret: &str, production: bool) -> SecretAssessment {
    if secret.is_empty() {
        return error("OpenAuth secret is missing.");
    }
    if production && secret == DEFAULT_SECRET {
        return error("The default OpenAuth secret cannot be used in production.");
    }
    if looks_like_example_secret(secret) {
        return error("The configured secret looks like an example value.");
    }
    if secret.len() < 32 {
        return error("Secret is too short; use at least 32 bytes of random material.");
    }
    if character_classes(secret) < 3 {
        return error("Secret has low character diversity; generate a random secret.");
    }
    if repeated_single_character(secret) {
        return error("Secret has low entropy; generate a random secret.");
    }

    SecretAssessment {
        severity: SecretSeverity::Ok,
        message: "Secret strength looks good.".to_owned(),
    }
}

fn looks_like_example_secret(secret: &str) -> bool {
    let lower = secret.to_ascii_lowercase();
    lower.contains("secret-a-at-least-32-chars")
        || lower.contains("change-me")
        || lower.contains("example")
        || lower.contains("your-secret")
        || lower.contains("openauth-example")
}

fn character_classes(secret: &str) -> usize {
    [
        secret
            .chars()
            .any(|character| character.is_ascii_lowercase()),
        secret
            .chars()
            .any(|character| character.is_ascii_uppercase()),
        secret.chars().any(|character| character.is_ascii_digit()),
        secret
            .chars()
            .any(|character| !character.is_ascii_alphanumeric()),
    ]
    .into_iter()
    .filter(|present| *present)
    .count()
}

fn repeated_single_character(secret: &str) -> bool {
    let mut chars = secret.chars();
    let Some(first) = chars.next() else {
        return true;
    };
    chars.all(|character| character == first)
}

fn error(message: &str) -> SecretAssessment {
    SecretAssessment {
        severity: SecretSeverity::Error,
        message: message.to_owned(),
    }
}