Files
crank/apps/admin-api/src/bin/crank_migrate/admin_auth_command.rs
T

343 lines
12 KiB
Rust

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<ExitCode, CliError> {
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(&registry, options).await?,
"bootstrap-complete" => complete_bootstrap_contract(&registry, options).await?,
"recover" => recover_admin_password(&registry, 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<String>,
display_name: Option<String>,
ttl_seconds: Option<i64>,
token_file: Option<PathBuf>,
password_file: Option<PathBuf>,
password_pepper_file: Option<PathBuf>,
master_key_file: Option<PathBuf>,
}
impl AdminAuthOptions {
fn parse(arguments: &[String]) -> Result<Self, CliError> {
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::<i64>().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<String, CliError> {
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())
}