use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use crank_community_auth::hash_password; use crank_config::{ConfigSource, parse_migrator}; use crank_registry::{ CreateAdminBootstrapContractRequest, MASTER_KEY_CIPHER_CONTRACT, PostgresRegistry, RecoverAdminPasswordRequest, }; use crank_runtime::SecretCrypto; use rand::RngExt; use serde_json::json; use sha2::{Digest, Sha256}; use std::{ path::{Path, PathBuf}, process::ExitCode, }; use time::{Duration as TimeDuration, OffsetDateTime}; use super::{CliError, db_connect::connect_registry}; pub(super) async fn run_admin_auth(arguments: &[String]) -> Result { let Some(command) = arguments.first().map(String::as_str) else { return Err(CliError::new( "invalid_command", "cli.arguments", "run_admin_auth_bootstrap_create", )); }; let options = AdminAuthOptions::parse(&arguments[1..])?; let config = parse_migrator( ConfigSource::from_os_for_migrator() .map_err(|_| CliError::new("config_invalid", "config.source", "run_preflight"))?, ) .map_err(|_| CliError::new("config_invalid", "config.validate", "run_preflight"))?; let registry = connect_registry(&config.database).await?; match command { "bootstrap-create" => create_bootstrap_contract(®istry, options).await?, "bootstrap-complete" => complete_bootstrap_contract(®istry, options).await?, "recover" => recover_admin_password(®istry, options).await?, _ => { return Err(CliError::new( "invalid_command", "cli.arguments", "run_admin_auth_bootstrap_create", )); } } Ok(ExitCode::SUCCESS) } async fn complete_bootstrap_contract( registry: &PostgresRegistry, options: AdminAuthOptions, ) -> Result<(), CliError> { let token_path = options .token_file .as_deref() .ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "provide_token_file"))?; let password_path = options.password_file.as_deref().ok_or_else(|| { CliError::new("invalid_command", "cli.arguments", "provide_password_file") })?; let pepper_path = options.password_pepper_file.as_deref().ok_or_else(|| { CliError::new( "invalid_command", "cli.arguments", "provide_password_pepper_file", ) })?; let token = read_secret_file(token_path, "admin_auth.bootstrap_token")?; let password = read_secret_file(password_path, "admin_auth.password")?; let pepper = read_secret_file(pepper_path, "admin_auth.password_pepper")?; if !(32..=256).contains(&token.len()) || !(12..=256).contains(&password.len()) || pepper.is_empty() || pepper.len() > 1_024 { return Err(CliError::new( "invalid_command", "cli.arguments", "provide_bounded_secret_files", )); } let token_hash = admin_auth_hash("bootstrap", &token); let password_hash = hash_password(&password, &pepper).map_err(|_| { CliError::new( "admin_bootstrap_rejected", "admin_auth.bootstrap", "verify_local_inputs", ) })?; let now = OffsetDateTime::now_utc(); let user_id = registry .consume_admin_bootstrap_contract(crank_registry::ConsumeAdminBootstrapContractRequest { token_hash: &token_hash, password_hash: &password_hash, now: &now, }) .await .map_err(CliError::from_registry)?; println!( "{}", json!({ "status": "bootstrap_completed", "user_id": user_id.as_str() }) ); Ok(()) } async fn recover_admin_password( registry: &PostgresRegistry, options: AdminAuthOptions, ) -> Result<(), CliError> { let email = options .email .as_deref() .ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "provide_email"))?; let password_path = options.password_file.as_deref().ok_or_else(|| { CliError::new("invalid_command", "cli.arguments", "provide_password_file") })?; let pepper_path = options.password_pepper_file.as_deref().ok_or_else(|| { CliError::new( "invalid_command", "cli.arguments", "provide_password_pepper_file", ) })?; let master_key_path = options.master_key_file.as_deref().ok_or_else(|| { CliError::new( "invalid_command", "cli.arguments", "provide_master_key_file", ) })?; let master_key = read_secret_file(master_key_path, "master_key.input")?; let active = registry .active_master_key_identity() .await .map_err(CliError::from_registry)? .ok_or_else(|| { CliError::new( "master_key_identity_missing", "master_key.identity", "start_service_once_with_current_master_key", ) })?; let crypto = SecretCrypto::with_epoch(&master_key, active.epoch).map_err(|_| { CliError::new( "master_key_invalid", "master_key.input", "provide_current_master_key_file", ) })?; if active.cipher_contract != MASTER_KEY_CIPHER_CONTRACT || active.fingerprint != crypto.master_key_fingerprint() { return Err(CliError::new( "master_key_identity_mismatch", "master_key.identity", "use_matching_master_key", )); } let password = read_secret_file(password_path, "admin_auth.password")?; let pepper = read_secret_file(pepper_path, "admin_auth.password_pepper")?; if !(12..=256).contains(&password.len()) || pepper.is_empty() || pepper.len() > 1_024 { return Err(CliError::new( "invalid_command", "cli.arguments", "provide_bounded_secret_files", )); } let password_hash = hash_password(&password, &pepper).map_err(|_| { CliError::new( "admin_recovery_rejected", "admin_auth.recovery", "verify_local_inputs", ) })?; let audit_id = format!("audit_{}", uuid::Uuid::now_v7().simple()); let user_id = registry .recover_admin_password(RecoverAdminPasswordRequest { email, password_hash: &password_hash, audit_id: &audit_id, }) .await .map_err(CliError::from_registry)?; println!( "{}", json!({ "status": "admin_recovered", "user_id": user_id.as_str(), "sessions_revoked": true, "audit_id": audit_id }) ); Ok(()) } async fn create_bootstrap_contract( registry: &PostgresRegistry, options: AdminAuthOptions, ) -> Result<(), CliError> { let email = options .email .as_deref() .ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "provide_email"))?; let display_name = options.display_name.as_deref().unwrap_or("Crank Owner"); let ttl_seconds = options.ttl_seconds.unwrap_or(900); if !(60..=86_400).contains(&ttl_seconds) { return Err(CliError::new( "invalid_command", "cli.arguments", "set_ttl_between_60_and_86400", )); } let token = random_token(); let contract_id = format!("boot_{}", uuid::Uuid::now_v7().simple()); let token_hash = admin_auth_hash("bootstrap", &token); let now = OffsetDateTime::now_utc(); let expires_at = now .checked_add(TimeDuration::seconds(ttl_seconds)) .ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "reduce_ttl"))?; let contract = registry .create_admin_bootstrap_contract(CreateAdminBootstrapContractRequest { id: &contract_id, token_hash: &token_hash, email, display_name, expires_at: &expires_at, }) .await .map_err(CliError::from_registry)?; println!( "{}", json!({ "status": "bootstrap_created", "contract_id": contract.id, "expires_at": contract.expires_at, "bootstrap_token": token, "warning": "copy_once_token_not_logged_by_services" }) ); Ok(()) } #[derive(Default)] struct AdminAuthOptions { email: Option, display_name: Option, ttl_seconds: Option, token_file: Option, password_file: Option, password_pepper_file: Option, master_key_file: Option, } impl AdminAuthOptions { fn parse(arguments: &[String]) -> Result { let mut options = Self::default(); let mut index = 0; while index < arguments.len() { let key = arguments[index].as_str(); let Some(value) = arguments.get(index + 1) else { return Err(CliError::new( "invalid_command", "cli.arguments", "run_admin_auth_bootstrap_create", )); }; match key { "--email" => options.email = Some(value.clone()), "--display-name" => options.display_name = Some(value.clone()), "--ttl-seconds" => { options.ttl_seconds = Some(value.parse::().map_err(|_| { CliError::new("invalid_command", "cli.arguments", "set_ttl_seconds") })?); } "--password-file" => options.password_file = Some(PathBuf::from(value)), "--token-file" => options.token_file = Some(PathBuf::from(value)), "--password-pepper-file" => { options.password_pepper_file = Some(PathBuf::from(value)); } "--master-key-file" => options.master_key_file = Some(PathBuf::from(value)), _ => { return Err(CliError::new( "invalid_command", "cli.arguments", "run_admin_auth_bootstrap_create", )); } } index += 2; } Ok(options) } } fn read_secret_file(path: &Path, stage: &'static str) -> Result { let metadata = std::fs::metadata(path) .map_err(|_| CliError::new("invalid_command", stage, "provide_readable_secret_file"))?; if !metadata.is_file() || metadata.len() > 8_192 { return Err(CliError::new( "invalid_command", stage, "provide_bounded_secret_file", )); } let bytes = std::fs::read(path) .map_err(|_| CliError::new("invalid_command", stage, "provide_readable_secret_file"))?; if bytes.len() > 8_192 { return Err(CliError::new( "invalid_command", stage, "provide_bounded_secret_file", )); } let mut value = String::from_utf8(bytes) .map_err(|_| CliError::new("invalid_command", stage, "provide_utf8_secret_file"))?; if value.ends_with("\r\n") { value.truncate(value.len() - 2); } else if value.ends_with('\n') { value.truncate(value.len() - 1); } Ok(value) } fn random_token() -> String { let mut bytes = [0_u8; 32]; rand::rng().fill(&mut bytes); URL_SAFE_NO_PAD.encode(bytes) } fn admin_auth_hash(scope: &str, token: &str) -> String { let mut digest = Sha256::new(); digest.update(scope.as_bytes()); digest.update(b":"); digest.update(token.as_bytes()); URL_SAFE_NO_PAD.encode(digest.finalize()) }