Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion src/cli/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::vault::ops::{
change_passphrase, create_vault, default_vault_path, get_secret, list_secrets, remove_secret,
rotate_keys, set_secret, unlock_vault,
};
use anyhow::Result;
use anyhow::{Context, Result};
use rpassword::prompt_password;

/// Read passphrase from DOTA_PASSPHRASE env var, falling back to interactive prompt.
Expand Down Expand Up @@ -167,6 +167,7 @@ pub fn handle_info(vault_path: Option<String>) -> Result<()> {
println!("─────────────────");
println!("Location: {}", vault_path);
println!("Version: {}", unlocked.vault.version);
println!("Min version: {}", unlocked.vault.min_version);
println!(
"Created: {}",
unlocked.vault.created.format("%Y-%m-%d %H:%M:%S")
Expand Down Expand Up @@ -234,3 +235,45 @@ pub fn handle_rotate_keys(vault_path: Option<String>) -> Result<()> {

Ok(())
}

/// Handle 'upgrade' command — explicitly migrate a vault to the latest version.
///
/// This is the same migration that `unlock` performs automatically, but
/// exposed as a standalone command for operators who want to upgrade vaults
/// in a controlled manner (e.g. before deploying a new binary fleet-wide).
pub fn handle_upgrade(vault_path: Option<String>) -> Result<()> {
use crate::vault::format::VAULT_VERSION;
use crate::vault::migrate;

let vault_path = vault_path.unwrap_or_else(default_vault_path);

// Read vault to check version before prompting for passphrase
let json =
std::fs::read_to_string(&vault_path).context("Failed to read vault file")?;
let vault: crate::vault::format::Vault =
serde_json::from_str(&json).context("Failed to parse vault file")?;

// Validate version range (catches corrupt min_version, future versions, etc.)
migrate::validate_version(&vault)?;

if !migrate::needs_migration(&vault) {
println!(
"Vault is already at the latest version (v{}). No upgrade needed.",
VAULT_VERSION
);
return Ok(());
}

println!(
"Vault is at v{}, latest is v{}. Upgrading...",
vault.version, VAULT_VERSION
);

// unlock_vault handles backup, migration, and persistence
let passphrase = read_passphrase("Vault passphrase: ")?;
let _unlocked = unlock_vault(&passphrase, &vault_path)?;

println!("Vault upgraded successfully to v{}", VAULT_VERSION);

Ok(())
}
3 changes: 3 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ pub enum Commands {
/// Rotate encryption keys (re-encrypt all secrets)
RotateKeys,

/// Upgrade vault to the latest format version (no downgrade)
Upgrade,

/// Show vault information
Info,
}
3 changes: 3 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ fn main() -> Result<()> {
Some(Commands::RotateKeys) => {
cli::commands::handle_rotate_keys(args.vault)?;
}
Some(Commands::Upgrade) => {
cli::commands::handle_upgrade(args.vault)?;
}
Some(Commands::Info) => {
cli::commands::handle_info(args.vault)?;
}
Expand Down
1 change: 1 addition & 0 deletions src/tui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ pub fn launch_tui(vault_path: String) -> Result<()> {
println!("─────────────────");
println!("Location: {}", vault_path);
println!("Version: {}", unlocked.vault.version);
println!("Min version: {}", unlocked.vault.min_version);
println!(
"Created: {}",
unlocked.vault.created.format("%Y-%m-%d %H:%M:%S")
Expand Down
22 changes: 20 additions & 2 deletions src/vault/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,35 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

/// Vault file format version
/// Current vault file format version.
///
/// Version history:
/// v3: Initial stable format. Master key used directly as AES wrapping key.
/// v3 → v4: Purpose-labeled HKDF-Expand for key wrapping (key separation).
/// The master key is no longer used directly as an AES key. Instead,
/// separate wrapping keys are derived via HKDF-Expand with distinct
/// purpose labels for ML-KEM and X25519 private key encryption.
pub const VAULT_VERSION: u32 = 4;
/// v4 → v5: Refreshed HKDF wrapping labels (v5-specific domain separation).
/// Added min_version anti-rollback field. Automatic migration with
/// backup. No downgrade path: once upgraded, older binaries refuse
/// to open the vault.
pub const VAULT_VERSION: u32 = 5;

/// Minimum vault version that can be migrated forward to VAULT_VERSION.
/// Vaults older than this require an intermediate binary version first.
pub const MIN_SUPPORTED_VERSION: u32 = 3;

/// Top-level vault structure
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Vault {
pub version: u32,
/// Anti-rollback floor: the minimum binary version required to open this
/// vault. Set to VAULT_VERSION on creation and on every migration. Older
/// binaries that do not understand this field will already fail on the
/// `version` check, but this provides an additional explicit safeguard
/// against targeted downgrade attacks.
Comment on lines +29 to +33
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The min_version docstring says it is the “minimum binary version required”, but the code compares it against VAULT_VERSION (vault format version) and sets it to VAULT_VERSION during creation/migration. To avoid confusion for operators/users, please adjust the documentation/error wording to refer to the vault format version (or rename the field) rather than implying it maps to the application’s semver.

Suggested change
/// Anti-rollback floor: the minimum binary version required to open this
/// vault. Set to VAULT_VERSION on creation and on every migration. Older
/// binaries that do not understand this field will already fail on the
/// `version` check, but this provides an additional explicit safeguard
/// against targeted downgrade attacks.
/// Anti-rollback floor: the minimum vault format version required to open
/// this vault. Set to VAULT_VERSION (the current vault format version) on
/// creation and on every migration. Older binaries that do not understand
/// this field will already fail on the `version` (format) check, but this
/// provides an additional explicit safeguard against targeted downgrade
/// attacks.

Copilot uses AI. Check for mistakes.
#[serde(default)]
pub min_version: u32,
pub created: DateTime<Utc>,
pub kdf: KdfParams,
pub kem: KemKeyPair,
Expand Down Expand Up @@ -102,6 +119,7 @@ mod tests {
fn test_vault_serialization_round_trip() {
let vault = Vault {
version: VAULT_VERSION,
min_version: VAULT_VERSION,
created: Utc::now(),
kdf: KdfParams {
algorithm: "argon2id".to_string(),
Expand Down
Loading
Loading