feat: complete Epic 1 production foundation

This commit is contained in:
2026-08-25 01:24:11 +03:00
parent 767428436d
commit 182bde8ac0
298 changed files with 35719 additions and 5299 deletions
+758 -48
View File
@@ -1,15 +1,19 @@
use std::{process::ExitCode, time::Duration};
#[path = "crank_migrate/admin_auth_command.rs"]
mod admin_auth_command;
#[path = "crank_migrate/db_connect.rs"]
mod db_connect;
use crank_config::{ConfigSource, DatabaseSettings, parse_migrator};
use crank_config::{ConfigSource, parse_migrator};
use crank_registry::{
BackfillPolicy, MigrationApplyResult, MigrationAuthority, MigrationPreflight,
BackfillPolicy, MASTER_KEY_CIPHER_CONTRACT, MasterKeyIdentityCandidate,
MasterKeyRotationRecord, MigrationApplyResult, MigrationAuthority, MigrationPreflight,
PostgresRegistry, RegistryError, SecretVersionRecord,
};
use crank_runtime::SecretCrypto;
use serde_json::json;
use sqlx::{
PgPool,
postgres::{PgConnectOptions, PgPoolOptions},
};
use std::{path::Path, process::ExitCode};
use time::OffsetDateTime;
const MASTER_KEY_ROTATION_PAGE_SIZE: i64 = 1_000;
#[tokio::main]
async fn main() -> ExitCode {
match run().await {
@@ -37,7 +41,6 @@ struct CliError {
recovery: &'static str,
version: Option<i64>,
}
impl CliError {
const fn new(code: &'static str, stage: &'static str, recovery: &'static str) -> Self {
Self {
@@ -47,7 +50,6 @@ impl CliError {
version: None,
}
}
fn from_migration(error: crank_registry::MigrationError) -> Self {
Self {
code: error.code(),
@@ -56,20 +58,87 @@ impl CliError {
version: error.version(),
}
}
fn from_registry(error: RegistryError) -> Self {
match error {
RegistryError::MasterKeyIdentityMismatch { .. } => Self::new(
"master_key_identity_mismatch",
"master_key.identity",
"use_matching_master_key",
),
RegistryError::InvalidMasterKeyIdentity => Self::new(
"master_key_identity_invalid",
"master_key.identity",
"verify_operator_input",
),
RegistryError::MasterKeyRotationInProgress => Self::new(
"master_key_rotation_in_progress",
"master_key.rotation",
"resume_verify_promote_or_abort_rotation",
),
RegistryError::MasterKeyRotationNotFound { .. } => Self::new(
"master_key_rotation_not_found",
"master_key.rotation",
"run_status",
),
RegistryError::MasterKeyRotationConflict => Self::new(
"master_key_rotation_conflict",
"master_key.rotation",
"run_status",
),
RegistryError::MasterKeyRotationVerificationFailed => Self::new(
"master_key_rotation_verification_failed",
"master_key.rotation",
"rerun_rotation_or_abort",
),
RegistryError::AdminBootstrapUnavailable => Self::new(
"admin_bootstrap_unavailable",
"admin_auth.bootstrap",
"use_existing_active_contract_or_wait_until_expired",
),
RegistryError::AdminBootstrapRejected => Self::new(
"admin_bootstrap_rejected",
"admin_auth.bootstrap",
"create_new_local_bootstrap_contract",
),
RegistryError::AdminRecoveryRejected => Self::new(
"admin_recovery_rejected",
"admin_auth.recovery",
"verify_local_inputs_and_master_key",
),
RegistryError::AdminLoginRateLimited { .. } => Self::new(
"admin_login_rate_limited",
"admin_auth.login",
"retry_after_delay",
),
RegistryError::AdminCsrfRejected => {
Self::new("admin_csrf_rejected", "admin_auth.csrf", "refresh_session")
}
RegistryError::Storage(_) => {
Self::new("storage_unavailable", "database.query", "contact_operator")
}
_ => Self::new("registry_error", "registry.operation", "contact_operator"),
}
}
}
async fn run() -> Result<ExitCode, CliError> {
let mut arguments = std::env::args().skip(1);
let requested = arguments.next();
let option = arguments.next();
if arguments.next().is_some() {
let args = std::env::args().skip(1).collect::<Vec<_>>();
if args.first().map(String::as_str) == Some("master-key") {
return run_master_key(&args[1..]).await;
}
if args.first().map(String::as_str) == Some("admin-auth") {
return admin_auth_command::run_admin_auth(&args[1..]).await;
}
let requested = args.first().map(String::as_str);
let option = args.get(1).map(String::as_str);
if args.len() > 2 {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_preflight",
));
}
let command = match requested.as_deref() {
let command = match requested {
None | Some("preflight") => "preflight",
Some("plan") => "plan",
Some("apply") => "apply",
@@ -117,7 +186,7 @@ async fn run() -> Result<ExitCode, CliError> {
})
.collect::<Vec<_>>();
let plan = json!({ "schema_version": 1, "sequence": sequence });
if option.as_deref() == Some("--check") {
if option == Some("--check") {
let bytes = std::fs::read("docs/schemas/migration-sequence.json")
.map_err(|_| CliError::new("contract_drift", "plan.read", "contact_operator"))?;
if bytes.len() > 65_536 {
@@ -162,7 +231,7 @@ async fn run() -> Result<ExitCode, CliError> {
.map_err(|_| CliError::new("config_invalid", "config.source", "run_preflight"))?,
)
.map_err(|_| CliError::new("config_invalid", "config.validate", "run_preflight"))?;
let pool = connect(&config.database).await?;
let pool = db_connect::connect(&config.database).await?;
if command == "apply" {
let result = MigrationAuthority::apply(&pool)
@@ -204,39 +273,680 @@ async fn run() -> Result<ExitCode, CliError> {
}
}
async fn connect(config: &DatabaseSettings) -> Result<PgPool, CliError> {
let options = if let Some(url) = &config.url {
url.expose_secret()
.parse::<PgConnectOptions>()
.map_err(|_| CliError::new("config_invalid", "database.source", "run_preflight"))?
} else {
PgConnectOptions::new()
.host(&config.host)
.port(config.port)
.database(&config.database)
.username(&config.username)
.password(config.password.expose_secret())
async fn run_master_key(arguments: &[String]) -> Result<ExitCode, CliError> {
let Some(command) = arguments.first().map(String::as_str) else {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_master_key_status",
));
};
for attempt in 1..=10 {
let result = PgPoolOptions::new()
.max_connections(config.pool.max_connections)
.min_connections(config.pool.min_connections)
.acquire_timeout(Duration::from_millis(config.pool.acquire_timeout_ms))
.idle_timeout(Duration::from_millis(config.pool.idle_timeout_ms))
.max_lifetime(Duration::from_millis(config.pool.max_lifetime_ms))
.connect_with(options.clone())
.await;
match result {
Ok(pool) => return Ok(pool),
Err(_) if attempt < 10 => {
tokio::time::sleep(Duration::from_secs(1)).await;
let options = MasterKeyOptions::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 = db_connect::connect_registry(&config.database).await?;
match command {
"status" => {
ensure_no_options(&options)?;
let status = registry
.master_key_rotation_status()
.await
.map_err(CliError::from_registry)?;
println!(
"{}",
json!({
"status": "ok",
"active_epoch": status.active_identity.as_ref().map(|identity| identity.epoch),
"rotation_count": status.rotations.len(),
"rotations": status.rotations.iter().map(rotation_json).collect::<Vec<_>>(),
})
);
}
"preflight" => {
let preflight = master_key_preflight(&registry, &options).await?;
println!(
"{}",
json!({
"status": "preflight_ok",
"source_epoch": preflight.source_epoch,
"target_epoch": preflight.target_epoch,
"affected_secret_versions": preflight.affected_secret_versions,
"backup_ref": preflight.backup_ref.map(|_| "configured"),
})
);
}
"rotate" => {
let (rotation, current_crypto, target_crypto) =
if let Some(rotation) = active_rotation(&registry, &["running"]).await? {
let (current_crypto, target_crypto) =
rotation_crypto_for_resume(&registry, &options, &rotation).await?;
(rotation, current_crypto, target_crypto)
} else {
let preflight = master_key_preflight(&registry, &options).await?;
let now = OffsetDateTime::now_utc();
let rotation = registry
.begin_master_key_rotation(
preflight.source_epoch,
preflight.target_epoch,
preflight.target_crypto.master_key_fingerprint(),
preflight.backup_ref.as_deref(),
&now,
)
.await
.map_err(CliError::from_registry)?;
(rotation, preflight.current_crypto, preflight.target_crypto)
};
let processed = process_rotation_batch(
&registry,
&rotation,
&current_crypto,
&target_crypto,
options.max_versions,
)
.await?;
let status = if rotation.processed_secret_versions + processed
>= rotation.total_secret_versions
{
let now = OffsetDateTime::now_utc();
registry
.finish_master_key_rotation_batches(&rotation.id, &now)
.await
.map_err(CliError::from_registry)?
} else {
registry
.master_key_rotation_status()
.await
.map_err(CliError::from_registry)?
.rotations
.into_iter()
.find(|candidate| candidate.id == rotation.id)
.ok_or_else(|| {
CliError::new(
"master_key_rotation_not_found",
"master_key.rotation",
"run_status",
)
})?
};
println!(
"{}",
json!({
"status": status.state,
"rotation_id": status.id,
"source_epoch": status.source_epoch,
"target_epoch": status.target_epoch,
"processed_secret_versions": status.processed_secret_versions,
"total_secret_versions": status.total_secret_versions,
})
);
}
"verify" => {
let target_key = read_required_key_file(options.target_key_file.as_deref())?;
let rotation = active_rotation(&registry, &["verifying"])
.await?
.ok_or_else(|| {
CliError::new(
"master_key_rotation_conflict",
"master_key.rotation",
"run_rotate",
)
})?;
let target_crypto = SecretCrypto::with_epoch(&target_key, rotation.target_epoch)
.map_err(|_| {
CliError::new(
"master_key_invalid",
"master_key.input",
"verify_operator_input",
)
})?;
if target_crypto.master_key_fingerprint() != rotation.target_fingerprint {
return Err(CliError::new(
"master_key_identity_mismatch",
"master_key.identity",
"use_matching_target_key",
));
}
Err(_) => break,
let verified = verify_staged_targets(&registry, &rotation, &target_crypto).await?;
let now = OffsetDateTime::now_utc();
let status = registry
.verify_master_key_rotation(&rotation.id, verified, &now)
.await
.map_err(CliError::from_registry)?;
println!(
"{}",
json!({
"status": status.state,
"rotation_id": status.id,
"verified_secret_versions": status.verified_secret_versions,
"total_secret_versions": status.total_secret_versions,
})
);
}
"promote" => {
let target_key = read_required_key_file(options.target_key_file.as_deref())?;
let rotation = active_rotation(&registry, &["verified"])
.await?
.ok_or_else(|| {
CliError::new(
"master_key_rotation_conflict",
"master_key.rotation",
"run_verify",
)
})?;
let target_crypto = SecretCrypto::with_epoch(&target_key, rotation.target_epoch)
.map_err(|_| {
CliError::new(
"master_key_invalid",
"master_key.input",
"verify_operator_input",
)
})?;
if target_crypto.master_key_fingerprint() != rotation.target_fingerprint {
return Err(CliError::new(
"master_key_identity_mismatch",
"master_key.identity",
"use_matching_target_key",
));
}
verify_staged_targets(&registry, &rotation, &target_crypto).await?;
let now = OffsetDateTime::now_utc();
let status = registry
.promote_master_key_rotation(
&rotation.id,
MasterKeyIdentityCandidate {
epoch: rotation.target_epoch,
fingerprint: target_crypto.master_key_fingerprint(),
cipher_contract: MASTER_KEY_CIPHER_CONTRACT,
observed_at: &now,
},
&now,
)
.await
.map_err(CliError::from_registry)?;
println!(
"{}",
json!({
"status": status.state,
"rotation_id": status.id,
"active_epoch": status.target_epoch,
})
);
}
"abort" => {
let rotation_id = options
.rotation_id
.as_deref()
.ok_or_else(|| CliError::new("invalid_command", "cli.arguments", "run_status"))?;
let now = OffsetDateTime::now_utc();
let status = registry
.abort_master_key_rotation(rotation_id, &now)
.await
.map_err(CliError::from_registry)?;
println!(
"{}",
json!({
"status": status.state,
"rotation_id": status.id,
"active_epoch": status.source_epoch,
})
);
}
_ => {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_master_key_status",
));
}
}
Err(CliError::new(
"storage_unavailable",
"database.connect",
"contact_operator",
))
Ok(ExitCode::SUCCESS)
}
#[derive(Default)]
struct MasterKeyOptions {
current_key_file: Option<String>,
target_key_file: Option<String>,
backup_ref: Option<String>,
rotation_id: Option<String>,
max_versions: Option<usize>,
}
impl MasterKeyOptions {
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_master_key_status",
));
};
match key {
"--current-key-file" => options.current_key_file = Some(value.clone()),
"--target-key-file" => options.target_key_file = Some(value.clone()),
"--backup-ref" => options.backup_ref = Some(value.clone()),
"--rotation-id" => options.rotation_id = Some(value.clone()),
"--max-versions" => {
let parsed = value.parse::<usize>().map_err(|_| {
CliError::new("invalid_command", "cli.arguments", "run_master_key_status")
})?;
if parsed == 0 || parsed > 10_000 {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_master_key_status",
));
}
options.max_versions = Some(parsed);
}
_ => {
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_master_key_status",
));
}
}
index += 2;
}
Ok(options)
}
}
struct MasterKeyPreflight {
source_epoch: i64,
target_epoch: i64,
affected_secret_versions: usize,
backup_ref: Option<String>,
current_crypto: SecretCrypto,
target_crypto: SecretCrypto,
}
async fn master_key_preflight(
registry: &PostgresRegistry,
options: &MasterKeyOptions,
) -> Result<MasterKeyPreflight, CliError> {
let current_key = read_required_key_file(options.current_key_file.as_deref())?;
let target_key = read_required_key_file(options.target_key_file.as_deref())?;
let status = registry
.master_key_rotation_status()
.await
.map_err(CliError::from_registry)?;
if status.rotations.iter().any(|rotation| {
matches!(
rotation.state.as_str(),
"running" | "verifying" | "verified"
)
}) {
return Err(CliError::new(
"master_key_rotation_in_progress",
"master_key.rotation",
"resume_verify_promote_or_abort_rotation",
));
}
let active = status.active_identity.ok_or_else(|| {
CliError::new(
"master_key_identity_missing",
"master_key.identity",
"start_secret_process_once",
)
})?;
let current_crypto = SecretCrypto::with_epoch(&current_key, active.epoch).map_err(|_| {
CliError::new(
"master_key_invalid",
"master_key.input",
"verify_operator_input",
)
})?;
if current_crypto.master_key_fingerprint() != active.fingerprint {
return Err(CliError::new(
"master_key_identity_mismatch",
"master_key.identity",
"use_matching_current_key",
));
}
let target_epoch = active.epoch + 1;
let target_crypto = SecretCrypto::with_epoch(&target_key, target_epoch).map_err(|_| {
CliError::new(
"master_key_invalid",
"master_key.input",
"verify_operator_input",
)
})?;
if target_crypto.master_key_fingerprint() == current_crypto.master_key_fingerprint()
|| registry
.master_key_fingerprint_exists(target_crypto.master_key_fingerprint())
.await
.map_err(CliError::from_registry)?
{
return Err(CliError::new(
"master_key_rotation_conflict",
"master_key.rotation",
"choose_new_target_key",
));
}
let affected_secret_versions =
count_and_verify_current_versions(registry, active.epoch, &current_crypto).await?;
Ok(MasterKeyPreflight {
source_epoch: active.epoch,
target_epoch,
affected_secret_versions,
backup_ref: options.backup_ref.clone(),
current_crypto,
target_crypto,
})
}
async fn rotation_crypto_for_resume(
registry: &PostgresRegistry,
options: &MasterKeyOptions,
rotation: &MasterKeyRotationRecord,
) -> Result<(SecretCrypto, SecretCrypto), CliError> {
let current_key = read_required_key_file(options.current_key_file.as_deref())?;
let target_key = read_required_key_file(options.target_key_file.as_deref())?;
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_secret_process_once",
)
})?;
let current_crypto =
SecretCrypto::with_epoch(&current_key, rotation.source_epoch).map_err(|_| {
CliError::new(
"master_key_invalid",
"master_key.input",
"verify_operator_input",
)
})?;
if active.epoch != rotation.source_epoch
|| active.fingerprint != current_crypto.master_key_fingerprint()
{
return Err(CliError::new(
"master_key_identity_mismatch",
"master_key.identity",
"use_matching_current_key",
));
}
let target_crypto =
SecretCrypto::with_epoch(&target_key, rotation.target_epoch).map_err(|_| {
CliError::new(
"master_key_invalid",
"master_key.input",
"verify_operator_input",
)
})?;
if target_crypto.master_key_fingerprint() != rotation.target_fingerprint {
return Err(CliError::new(
"master_key_identity_mismatch",
"master_key.identity",
"use_matching_target_key",
));
}
Ok((current_crypto, target_crypto))
}
async fn process_rotation_batch(
registry: &PostgresRegistry,
rotation: &MasterKeyRotationRecord,
current_crypto: &SecretCrypto,
target_crypto: &SecretCrypto,
max_versions: Option<usize>,
) -> Result<i64, CliError> {
let mut processed = 0_i64;
let mut after_secret_id: Option<String> = None;
let mut after_version: Option<u32> = None;
loop {
let versions = registry
.list_secret_versions_for_master_key_epoch_page(
rotation.source_epoch,
after_secret_id.as_deref(),
after_version,
MASTER_KEY_ROTATION_PAGE_SIZE,
)
.await
.map_err(CliError::from_registry)?;
if versions.is_empty() {
break;
}
for version in versions {
after_secret_id = Some(version.secret_version.secret_id.as_str().to_owned());
after_version = Some(version.secret_version.version);
if version.target_master_key_epoch == Some(rotation.target_epoch) {
continue;
}
if max_versions.is_some_and(|limit| processed as usize >= limit) {
return Ok(processed);
}
let plaintext = decrypt_current(&version, current_crypto)?;
let target_ciphertext = target_crypto.encrypt(&plaintext).map_err(|_| {
CliError::new(
"master_key_rotation_verification_failed",
"master_key.rotation",
"rerun_rotation_or_abort",
)
})?;
let now = OffsetDateTime::now_utc();
registry
.stage_master_key_rotation_ciphertext(
&rotation.id,
&version.secret_version.secret_id,
version.secret_version.version,
rotation.source_epoch,
&target_ciphertext,
target_crypto.key_version(),
rotation.target_epoch,
&now,
)
.await
.map_err(CliError::from_registry)?;
processed += 1;
}
}
Ok(processed)
}
async fn verify_staged_targets(
registry: &PostgresRegistry,
rotation: &MasterKeyRotationRecord,
target_crypto: &SecretCrypto,
) -> Result<i64, CliError> {
let mut verified = 0_i64;
let mut after_secret_id: Option<String> = None;
let mut after_version: Option<u32> = None;
loop {
let versions = registry
.list_target_secret_versions_for_master_key_rotation_page(
rotation.target_epoch,
after_secret_id.as_deref(),
after_version,
MASTER_KEY_ROTATION_PAGE_SIZE,
)
.await
.map_err(CliError::from_registry)?;
if versions.is_empty() {
break;
}
for version in versions {
after_secret_id = Some(version.secret_version.secret_id.as_str().to_owned());
after_version = Some(version.secret_version.version);
let ciphertext = version.target_ciphertext.as_deref().ok_or_else(|| {
CliError::new(
"master_key_rotation_verification_failed",
"master_key.rotation",
"rerun_rotation_or_abort",
)
})?;
let key_version = version.target_key_version.as_deref().ok_or_else(|| {
CliError::new(
"master_key_rotation_verification_failed",
"master_key.rotation",
"rerun_rotation_or_abort",
)
})?;
target_crypto
.decrypt_for_epoch(key_version, rotation.target_epoch, ciphertext)
.map_err(|_| {
CliError::new(
"master_key_rotation_verification_failed",
"master_key.rotation",
"rerun_rotation_or_abort",
)
})?;
verified += 1;
}
}
if verified != rotation.total_secret_versions {
return Err(CliError::new(
"master_key_rotation_verification_failed",
"master_key.rotation",
"rerun_rotation_or_abort",
));
}
Ok(verified)
}
async fn count_and_verify_current_versions(
registry: &PostgresRegistry,
epoch: i64,
current_crypto: &SecretCrypto,
) -> Result<usize, CliError> {
let mut count = 0_usize;
let mut after_secret_id: Option<String> = None;
let mut after_version: Option<u32> = None;
loop {
let versions = registry
.list_secret_versions_for_master_key_epoch_page(
epoch,
after_secret_id.as_deref(),
after_version,
MASTER_KEY_ROTATION_PAGE_SIZE,
)
.await
.map_err(CliError::from_registry)?;
if versions.is_empty() {
break;
}
for version in versions {
after_secret_id = Some(version.secret_version.secret_id.as_str().to_owned());
after_version = Some(version.secret_version.version);
decrypt_current(&version, current_crypto)?;
count += 1;
}
}
Ok(count)
}
fn decrypt_current(
version: &SecretVersionRecord,
current_crypto: &SecretCrypto,
) -> Result<serde_json::Value, CliError> {
current_crypto
.decrypt_for_epoch(
&version.secret_version.key_version,
version.master_key_epoch,
&version.secret_version.ciphertext,
)
.map_err(|_| {
CliError::new(
"master_key_rotation_verification_failed",
"master_key.rotation",
"rerun_rotation_or_abort",
)
})
}
async fn active_rotation(
registry: &PostgresRegistry,
allowed_states: &[&str],
) -> Result<Option<MasterKeyRotationRecord>, CliError> {
let status = registry
.master_key_rotation_status()
.await
.map_err(CliError::from_registry)?;
Ok(status
.rotations
.into_iter()
.find(|rotation| allowed_states.contains(&rotation.state.as_str())))
}
fn rotation_json(rotation: &MasterKeyRotationRecord) -> serde_json::Value {
json!({
"id": rotation.id,
"source_epoch": rotation.source_epoch,
"target_epoch": rotation.target_epoch,
"state": rotation.state,
"backup_ref": rotation.backup_ref.as_ref().map(|_| "configured"),
"checkpoint_secret_id": rotation.checkpoint_secret_id,
"total_secret_versions": rotation.total_secret_versions,
"processed_secret_versions": rotation.processed_secret_versions,
"verified_secret_versions": rotation.verified_secret_versions,
"failure_code": rotation.failure_code,
})
}
fn read_required_key_file(path: Option<&str>) -> Result<String, CliError> {
let path = path.ok_or_else(|| {
CliError::new("invalid_command", "cli.arguments", "run_master_key_status")
})?;
if path.len() > 512 || path.bytes().any(|byte| byte.is_ascii_control()) {
return Err(CliError::new(
"master_key_input_invalid",
"master_key.input",
"verify_operator_input",
));
}
let metadata = std::fs::metadata(Path::new(path)).map_err(|_| {
CliError::new(
"master_key_input_invalid",
"master_key.input",
"verify_operator_input",
)
})?;
if !metadata.is_file() || metadata.len() > 16_384 {
return Err(CliError::new(
"master_key_input_invalid",
"master_key.input",
"verify_operator_input",
));
}
let value = std::fs::read_to_string(Path::new(path)).map_err(|_| {
CliError::new(
"master_key_input_invalid",
"master_key.input",
"verify_operator_input",
)
})?;
if value.trim().is_empty() {
return Err(CliError::new(
"master_key_input_invalid",
"master_key.input",
"verify_operator_input",
));
}
Ok(value)
}
fn ensure_no_options(options: &MasterKeyOptions) -> Result<(), CliError> {
if options.current_key_file.is_some()
|| options.target_key_file.is_some()
|| options.backup_ref.is_some()
|| options.rotation_id.is_some()
|| options.max_versions.is_some()
{
return Err(CliError::new(
"invalid_command",
"cli.arguments",
"run_master_key_status",
));
}
Ok(())
}
@@ -0,0 +1,342 @@
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())
}
@@ -0,0 +1,78 @@
use crank_config::DatabaseSettings;
use crank_registry::{PostgresPoolConfig, PostgresRegistry, RegistryError};
use sqlx::{
PgPool,
postgres::{PgConnectOptions, PgPoolOptions},
};
use std::time::Duration;
use super::CliError;
pub(super) async fn connect(config: &DatabaseSettings) -> Result<PgPool, CliError> {
let options = connect_options(config)?;
for attempt in 1..=10 {
let result = PgPoolOptions::new()
.max_connections(config.pool.max_connections)
.min_connections(config.pool.min_connections)
.acquire_timeout(Duration::from_millis(config.pool.acquire_timeout_ms))
.idle_timeout(Duration::from_millis(config.pool.idle_timeout_ms))
.max_lifetime(Duration::from_millis(config.pool.max_lifetime_ms))
.connect_with(options.clone())
.await;
match result {
Ok(pool) => return Ok(pool),
Err(_) if attempt < 10 => tokio::time::sleep(Duration::from_secs(1)).await,
Err(_) => break,
}
}
Err(CliError::new(
"storage_unavailable",
"database.connect",
"contact_operator",
))
}
pub(super) async fn connect_registry(
config: &DatabaseSettings,
) -> Result<PostgresRegistry, CliError> {
let options = connect_options(config)?;
let pool_config = PostgresPoolConfig {
max_connections: config.pool.max_connections,
min_connections: config.pool.min_connections,
acquire_timeout_ms: config.pool.acquire_timeout_ms,
idle_timeout_ms: config.pool.idle_timeout_ms,
max_lifetime_ms: config.pool.max_lifetime_ms,
};
for attempt in 1..=10 {
let result =
PostgresRegistry::connect_with_options_and_pool_config(options.clone(), pool_config)
.await;
match result {
Ok(registry) => return Ok(registry),
Err(RegistryError::Storage(_)) if attempt < 10 => {
tokio::time::sleep(Duration::from_secs(1)).await;
}
Err(error) => return Err(CliError::from_registry(error)),
}
}
Err(CliError::new(
"storage_unavailable",
"database.connect",
"contact_operator",
))
}
fn connect_options(config: &DatabaseSettings) -> Result<PgConnectOptions, CliError> {
if let Some(url) = &config.url {
url.expose_secret()
.parse::<PgConnectOptions>()
.map_err(|_| CliError::new("config_invalid", "database.source", "run_preflight"))
} else {
Ok(PgConnectOptions::new()
.host(&config.host)
.port(config.port)
.database(&config.database)
.username(&config.username)
.password(config.password.expose_secret()))
}
}