343 lines
12 KiB
Rust
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(®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<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())
|
|
}
|