diff --git a/crates/lpm-cli/src/commands/env.rs b/crates/lpm-cli/src/commands/env.rs index d6a6754f..45e9744a 100644 --- a/crates/lpm-cli/src/commands/env.rs +++ b/crates/lpm-cli/src/commands/env.rs @@ -785,10 +785,13 @@ pub async fn run( let registry_url = lpm_common::resolve_lpm_registry_url(); let auth_token = resolve_lpm_bearer(®istry_url).await?; - // Ensure we have a keypair - let private_key = lpm_vault::sync::ensure_public_key(®istry_url, &auth_token) - .await - .map_err(LpmError::Script)?; + // Classify the sharing-key state before fetching the + // wrapped vault. RotationRequired refuses silent + // overwrite and routes the user to + // `lpm env rotate-sharing-key`; NeedsInitialSet prompts + // for step-up reauth and registers the local key. + let private_key = + ensure_sharing_key_ready_for_org_op(®istry_url, &auth_token, "pull").await?; output::info(&format!("pulling vault from org {}...", org_slug.bold())); @@ -989,7 +992,12 @@ pub async fn run( let registry_url = lpm_common::resolve_lpm_registry_url(); let auth_token = resolve_lpm_bearer(®istry_url).await?; - output::info("ensuring your X25519 public key is registered..."); + // Classify the sharing-key state before any wrap work. This + // refuses the silent-overwrite path on RotationRequired and + // prompts for step-up reauth on NeedsInitialSet, instead of + // letting `push_org_with_keys`'s internal `ensure_public_key` + // silently upload over whatever was on the server. + ensure_sharing_key_ready_for_org_op(®istry_url, &auth_token, "share").await?; let config = read_lpm_json_for_push(project_dir); let empty_env_map = HashMap::new(); @@ -1093,6 +1101,36 @@ pub async fn run( } } + // Rotate the user's X25519 sharing keypair — the per-account key + // that org members use to encrypt vault AES keys to this user. + // DISTINCT from `rotate-key` (above), which rotates the + // wrapping-key the personal-vault blob is encrypted under. + // + // Flow: + // 1. Refuse if non-interactive (no TTY). + // 2. Crash recovery: if a pending key exists and matches the + // server's current public key, the prior run committed the + // server-side rotation but didn't promote locally — + // finish the promotion and return. + // 3. Generate a fresh keypair into the pending slot WITHOUT + // touching the live slot. + // 4. Show blast radius + require typed `ROTATE` confirmation. + // 5. Acquire a CLI step-up proof for + // `vault:public-key:rotate`. + // 6. POST the new public key with the proof header. + // 7. On 200, atomically promote the pending slot into the + // live slot. The server's response carries the counts of + // invalidated wrapped keys + affected orgs so the user + // knows which orgs will need to re-share before pulls + // resume. + // + // A failure between steps 5 and 6 leaves the pending slot but + // no server-side change — the next `rotate-sharing-key` + // invocation discards the stale pending and starts over. + "rotate-sharing-key" => { + return env_rotate_sharing_key(args, json_output).await; + } + "list-remote" | "ls-remote" => { let org_flag = args .iter() @@ -1284,7 +1322,7 @@ pub async fn run( unknown => { return Err(LpmError::Script(format!( - "unknown vars action: '{unknown}'. Available: set, get, list, delete, import, export, push, pull, diff, validate, example, print, check, connect, status, log, share, rotate-key, pair, unpair, init, ls, copy" + "unknown vars action: '{unknown}'. Available: set, get, list, delete, import, export, push, pull, diff, validate, example, print, check, connect, status, log, share, rotate-key, rotate-sharing-key, pair, unpair, init, ls, copy" ))); } } @@ -3735,6 +3773,241 @@ fn expected_org_sync_version(project_dir: &std::path::Path, org_slug: &str) -> O lpm_vault::vault_id::read_org_sync_version(project_dir, org_slug) } +/// Shared classify-then-act helper for every org-vault flow that needs +/// the user's X25519 sharing keypair on the server (org `share`, org +/// `pull`). Closes the silent-overwrite vector the previous +/// `ensure_public_key` path enabled: that helper would happily upload +/// the local key over any server-stored key on first mismatch, so a +/// new device reading the user's auth token could rotate the user's +/// sharing key without re-authentication and without notification. +/// +/// Three outcomes, mirroring the classifier enum: +/// +/// - `Matches`: local key is already registered; return its private +/// half and let the caller proceed with the wrap operation. +/// - `NeedsInitialSet`: server has no key yet. Acquire a +/// `vault:public-key:set` step-up proof via the WS2 reauth flow +/// and upload the local public key. The user gets an out-of-band +/// security email confirming the registration. Return the private +/// half for the caller's wrap operation. +/// - `RotationRequired`: server has a DIFFERENT key. Refuse to +/// proceed and point the user at `lpm env rotate-sharing-key` — +/// the explicit, interactive, reauthed flow that handles the +/// blast-radius confirmation + email fan-out. The CLI MUST NOT +/// silently overwrite here; that's the headline H16 vulnerability +/// the WS3 server gate exists to close, and the client side has +/// to hold its side of the contract. +async fn ensure_sharing_key_ready_for_org_op( + registry_url: &str, + auth_token: &str, + op_label: &str, +) -> Result<[u8; 32], LpmError> { + use lpm_vault::sync::PublicKeyRegistrationState; + let state = lpm_vault::sync::classify_public_key_state(registry_url, auth_token) + .await + .map_err(LpmError::Script)?; + + match state { + PublicKeyRegistrationState::Matches(local) => Ok(local.private_key), + PublicKeyRegistrationState::NeedsInitialSet(local) => { + output::info(&format!( + "no sharing key on file for this account; registering this device's key \ + before continuing with `{op_label}`. You'll be prompted to confirm your \ + password (and authenticator code, if enrolled) to authorize the write." + )); + let proof = crate::step_up::request_cli_step_up_proof( + registry_url, + auth_token, + "vault:public-key:set", + ) + .await + .map_err(LpmError::Script)?; + lpm_vault::sync::upload_public_key( + registry_url, + auth_token, + &local.public_key_b64, + Some(&proof), + ) + .await + .map_err(LpmError::Script)?; + Ok(local.private_key) + } + PublicKeyRegistrationState::RotationRequired { .. } => Err(LpmError::Script(format!( + "refusing to {op_label}: this device's sharing key does NOT match the key on \ + the server. Silently overwriting the server-side key would invalidate every \ + org teammate's wrapped access without your knowledge — exactly the attack \ + surface the explicit rotation flow exists to close.\n\nIf you intentionally \ + want to rotate your sharing key (e.g. you lost the previous device's key), \ + run `lpm env rotate-sharing-key` here. That command prompts for step-up \ + reauth, shows the blast radius, and sends an out-of-band security email \ + before invalidating wrapped-key rows.", + ))), + } +} + +/// Implementation of the `lpm env rotate-sharing-key` command branch. +/// Extracted so the dispatcher arm stays small and the rotation flow +/// can be unit-tested independently in the future. See the dispatcher +/// docstring for the step-by-step flow. +async fn env_rotate_sharing_key(args: Vec<&str>, json_output: bool) -> Result<(), LpmError> { + use std::io::IsTerminal; + + // Non-interactive refusal — the prompt for typed confirmation + + // password / TOTP cannot work without a TTY, and silently advancing + // through stdin would either block forever or accept hostile input + // piped from an unattended runner. + let yes_skip = args.contains(&"--yes"); + if !std::io::stdin().is_terminal() || yes_skip { + return Err(LpmError::Script( + "`lpm env rotate-sharing-key` is an interactive recovery surface and \ + refuses to run without a TTY. Run it manually from your terminal." + .into(), + )); + } + + let registry_url = lpm_common::resolve_lpm_registry_url(); + let auth_token = resolve_lpm_bearer(®istry_url).await?; + + // Crash recovery — if a pending key exists and matches the server, + // the previous run committed the server side but didn't promote + // locally. Promote and return; no second rotation needed. + if let Some(pending) = + lpm_vault::sync::read_pending_x25519_keypair().map_err(LpmError::Script)? + { + let server_key = lpm_vault::sync::get_my_public_key(®istry_url, &auth_token) + .await + .map_err(LpmError::Script)?; + if server_key.as_deref() == Some(&pending.public_key_b64) { + lpm_vault::sync::promote_pending_x25519_keypair().map_err(LpmError::Script)?; + if json_output { + println!( + "{}", + serde_json::json!({ + "success": true, + "status": "resumed", + }) + ); + } else { + output::success( + "resumed pending sharing-key rotation: server already had the new key, \ + promoted the local slot.", + ); + } + return Ok(()); + } + // Pending exists but doesn't match server — last attempt failed + // before the server committed. Discard the orphan so the next + // step can re-generate. + output::warn( + "found a stale pending sharing-key from a prior failed rotation. Discarding it \ + and starting fresh.", + ); + lpm_vault::sync::discard_pending_x25519_keypair().map_err(LpmError::Script)?; + } + + // Show blast radius. We can't precompute it locally (we don't know + // which orgs the user belongs to without a server roundtrip we'd + // duplicate post-rotation), so the message names the EFFECT + // honestly: every org vault wrapped to this user gets invalidated; + // owners/admins of each affected org must re-share before pulls + // resume. + output::warn( + "Rotating your sharing key invalidates EVERY org-vault wrapped-key entry stored \ + for you. Until an owner or admin of each affected org runs \ + `lpm env share --org ` to re-wrap, you will not be able to pull those \ + vaults. The dashboard's Member Access view will show those rows as \"Needs share\".", + ); + output::info( + "An out-of-band security email will be sent to your account, and a separate \ + impact email to every affected org's owners + admins.", + ); + + let typed: String = + cliclack::input("Type ROTATE (uppercase) to confirm. Any other input cancels.") + .interact() + .map_err(|e| LpmError::Script(format!("confirmation prompt failed: {e}")))?; + if typed != "ROTATE" { + return Err(LpmError::Script( + "rotation cancelled — no server-side or local state changed.".into(), + )); + } + + // Acquire the WS2 step-up proof BEFORE generating the pending + // key — a credential refusal must not leave a stale pending file + // on disk. + let proof = crate::step_up::request_cli_step_up_proof( + ®istry_url, + &auth_token, + "vault:public-key:rotate", + ) + .await + .map_err(LpmError::Script)?; + + let pending = lpm_vault::sync::create_pending_x25519_keypair().map_err(LpmError::Script)?; + + output::info("uploading new sharing key..."); + let response = lpm_vault::sync::upload_public_key( + ®istry_url, + &auth_token, + &pending.public_key_b64, + Some(&proof), + ) + .await; + + let response = match response { + Ok(r) => r, + Err(e) => { + // Server-side write failed — discard the pending so the + // next attempt starts from a clean slate. + let _ = lpm_vault::sync::discard_pending_x25519_keypair(); + return Err(LpmError::Script(e)); + } + }; + + // Promote pending → live. A crash here leaves the pending slot + // and the server-side new key, which the crash-recovery branch + // at the top of this function will finish on the next run. + lpm_vault::sync::promote_pending_x25519_keypair().map_err(LpmError::Script)?; + + if json_output { + println!( + "{}", + serde_json::json!({ + "success": true, + "status": response.status, + "fingerprintPrefix": response.fingerprint_prefix, + "previousFingerprintPrefix": response.previous_fingerprint_prefix, + "invalidatedWrappedKeys": response.invalidated_wrapped_keys, + "affectedOrgs": response.affected_orgs, + }) + ); + } else { + let fp = response + .fingerprint_prefix + .as_deref() + .unwrap_or("(unknown)"); + let invalidated = response.invalidated_wrapped_keys.unwrap_or(0); + let orgs = response.affected_orgs.unwrap_or(0); + output::success(&format!( + "sharing key rotated. New fingerprint: {}.", + fp.bold() + )); + if invalidated > 0 || orgs > 0 { + output::info(&format!( + "Invalidated {invalidated} wrapped-key entries across {orgs} org(s). \ + Ask an owner/admin of each affected org to re-share before pulling again." + )); + } else { + output::info( + "No org-vault wrapped keys were affected (you weren't sharing into any orgs \ + yet).", + ); + } + } + + Ok(()) +} + /// Validate vault secrets against .env.example. fn vars_validate( project_dir: &std::path::Path, diff --git a/crates/lpm-cli/src/main.rs b/crates/lpm-cli/src/main.rs index 5f213c4b..8493d77e 100644 --- a/crates/lpm-cli/src/main.rs +++ b/crates/lpm-cli/src/main.rs @@ -41,6 +41,7 @@ mod script_policy_config; pub mod security_check; mod sigstore; mod sigstore_verify; +mod step_up; mod swift_manifest; #[cfg(test)] mod test_env; diff --git a/crates/lpm-cli/src/step_up.rs b/crates/lpm-cli/src/step_up.rs new file mode 100644 index 00000000..c18ebdc0 --- /dev/null +++ b/crates/lpm-cli/src/step_up.rs @@ -0,0 +1,116 @@ +//! CLI wrapper around the dashboard's `/api/auth/cli-step-up` endpoint +//! (Workstream 2 reauth primitive). +//! +//! Wraps the lpm-vault network clients with cliclack prompts and a +//! strict non-interactive refusal. Every sensitive CLI write that the +//! WS3 + WS4 server gates require a step-up proof for routes through +//! this helper: +//! +//! - `lpm env rotate-sharing-key` → `vault:public-key:rotate` +//! - `lpm env share` / `lpm env pull --org` first-time registration +//! → `vault:public-key:set` +//! - (future) force-push gates → `vault:force-push:*` +//! +//! Always refuses non-interactive callers: a CI environment piping +//! stdin should never silently advance through a password prompt and +//! then succeed; the failure must be loud so the operator wires a +//! different credential flow (long-lived token, OIDC, etc.) instead. + +use lpm_vault::sync::{CliStepUpCredential, discover_cli_step_up_policy, mint_cli_step_up_proof}; +use std::io::IsTerminal; + +use crate::output; + +/// Acquire a step-up proof for `scope`, prompting the user +/// interactively for the credential the server's policy requires. +/// +/// Returns the proof JWT on success. The caller carries it in the +/// `X-LPM-Step-Up-Proof` header on the subsequent sensitive write +/// (e.g. `lpm_vault::sync::upload_public_key(..., Some(&proof))`). +pub async fn request_cli_step_up_proof( + registry_url: &str, + auth_token: &str, + scope: &str, +) -> Result { + // Refuse non-interactive callers up-front. Prompting for a password + // without a TTY would either block on stdin forever or silently + // accept whatever's piped in — both are worse than a clear refusal. + if !std::io::stdin().is_terminal() { + return Err(format!( + "step-up reauth is required for `{scope}` but stdin is not a TTY. \ + Run this command from an interactive terminal." + )); + } + + let policy = discover_cli_step_up_policy(registry_url, auth_token).await?; + + match policy.method.as_str() { + "password" => { + output::info( + "Confirm your password to authorize this action. This proof is bound to your \ + current CLI session and expires in 5 minutes.", + ); + let password: String = cliclack::password("Password") + .interact() + .map_err(|e| format!("password prompt failed: {e}"))?; + + mint_cli_step_up_proof( + registry_url, + auth_token, + scope, + &CliStepUpCredential::Password { + password: &password, + }, + ) + .await + } + "totp" => { + // Two-factor proof — the server requires password AND a + // fresh TOTP code because the CLI bearer is not a Supabase + // session and `mfa.verify` can only be reached by signing + // in transiently with the password first. See WS2 server + // route docstring for the full rationale. + output::info( + "Confirm your password and a fresh authenticator code to authorize this \ + action. This proof is bound to your current CLI session and expires in 5 \ + minutes.", + ); + let password: String = cliclack::password("Password") + .interact() + .map_err(|e| format!("password prompt failed: {e}"))?; + let code: String = cliclack::input("Authenticator code (6 digits)") + .validate(|input: &String| { + if input.chars().all(|c| c.is_ascii_digit()) && input.len() == 6 { + Ok(()) + } else { + Err("must be exactly 6 digits") + } + }) + .interact() + .map_err(|e| format!("totp prompt failed: {e}"))?; + + mint_cli_step_up_proof( + registry_url, + auth_token, + scope, + &CliStepUpCredential::Totp { + password: &password, + code: &code, + }, + ) + .await + } + "unavailable" => { + let reason = policy.reason.as_deref().unwrap_or("unknown"); + Err(format!( + "step-up reauth is unavailable for this account: {reason}. \ + Set an account password or enroll an authenticator in the dashboard \ + (Settings → Security) and retry." + )) + } + other => Err(format!( + "server returned unknown step-up method `{other}` — your CLI may be too old; \ + try upgrading with `lpm upgrade`" + )), + } +} diff --git a/crates/lpm-vault/src/keychain.rs b/crates/lpm-vault/src/keychain.rs index 0edede5c..90b42cec 100644 --- a/crates/lpm-vault/src/keychain.rs +++ b/crates/lpm-vault/src/keychain.rs @@ -229,6 +229,20 @@ pub fn get_or_create_x25519_keypair() -> Result<([u8; 32], [u8; 32]), String> { Ok((private, public)) } +/// Delete the stored X25519 private key from the Keychain. Used by the +/// rotation promote path so subsequent `load_local_public_key_state` +/// calls fall through to the file-backed slot the rotation just wrote. +/// +/// Best-effort: a `not found` result is the steady state for users who +/// were already on the file fallback. Returns `Ok(())` either way so +/// callers don't need to special-case the absence path. +pub fn delete_x25519_keypair() -> Result<(), String> { + // Reuse the existing keychain delete primitive; the underlying API + // returns `not found` errors which are not actionable here. + let _ = delete_keychain_password(SERVICE, X25519_ACCOUNT); + Ok(()) +} + // ─── Index Management ────────────────────────────────────────────── fn read_index() -> Vec { diff --git a/crates/lpm-vault/src/sync.rs b/crates/lpm-vault/src/sync.rs index d9d9261f..91fca918 100644 --- a/crates/lpm-vault/src/sync.rs +++ b/crates/lpm-vault/src/sync.rs @@ -565,7 +565,7 @@ pub async fn pull_env( Ok((secrets, version)) } -// ── Public Key Management ───────────────────────────────────────── +// ── CLI Step-Up (Workstream 2 reauth) ───────────────────────────── /// HTTP header the server expects the CLI step-up proof JWT in. Mirrors /// `CLI_STEP_UP_HEADER_NAME` exported from the dashboard's @@ -573,6 +573,140 @@ pub async fn pull_env( /// site and any future caller route the proof through the same name. pub const CLI_STEP_UP_HEADER_NAME: &str = "X-LPM-Step-Up-Proof"; +/// Step-up policy resolved by the server for the calling user. The CLI +/// prompts for the credential named in `method` (or refuses outright +/// when `unavailable`). +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CliStepUpPolicy { + /// `"password"`, `"totp"`, or `"unavailable"`. The CLI MUST refuse + /// any flow when `unavailable` because the prompt would ask for a + /// credential the user cannot satisfy. + pub method: String, + /// Present only when `method == "unavailable"`. Currently the only + /// observed value is `"set_password_required"`. + pub reason: Option, + /// TTL the server applies to a freshly-minted proof. Surfaced so the + /// CLI can render an honest "expires in N seconds" hint. + pub ttl_seconds: Option, + /// Header name the server expects the minted proof in. Echoed so a + /// future server-side rename gets picked up without coordinating + /// constants. + pub header: Option, +} + +/// Credential the CLI supplies on mint. Two shapes — password-only for +/// users with no MFA, and password+TOTP for MFA-enrolled users (CLI +/// step-up cannot drive TOTP alone — see WS2's route docstring). +pub enum CliStepUpCredential<'a> { + Password { password: &'a str }, + Totp { password: &'a str, code: &'a str }, +} + +/// Discover the step-up method the server expects for the calling +/// user — does not consume any credential, no rate-limit cost beyond +/// the per-IP shield. Used by the CLI to decide what to prompt for +/// before asking the user to type a password / TOTP. +pub async fn discover_cli_step_up_policy( + registry_url: &str, + auth_token: &str, +) -> Result { + let client = sync_http_client_builder() + .build() + .map_err(|e| format!("failed to build http client: {e}"))?; + let url = format!("{registry_url}/api/auth/cli-step-up"); + + let response = client + .get(&url) + .bearer_auth(auth_token) + .timeout(std::time::Duration::from_secs(15)) + .send() + .await + .map_err(|e| format!("network error: {e}"))?; + + let status = response.status(); + if !status.is_success() { + let body = read_capped_error_text(response).await; + return Err(format!("step-up policy: {status}: {body}")); + } + + response + .json::() + .await + .map_err(|e| format!("parse error: {e}")) +} + +/// Successful mint response from `POST /api/auth/cli-step-up`. +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct MintCliStepUpResponse { + ok: Option, + proof: Option, +} + +/// Mint a CLI step-up proof against the server. +/// +/// On success, returns the JWT the caller carries in the +/// `X-LPM-Step-Up-Proof` header for the subsequent sensitive write +/// (public-key set/rotate, force-push, etc). +/// +/// On failure, the returned error includes the server's response body +/// so the CLI can surface the structured envelope (`code: +/// wrong_credential`, `code: rate_limited`, etc) to the user. +pub async fn mint_cli_step_up_proof( + registry_url: &str, + auth_token: &str, + scope: &str, + credential: &CliStepUpCredential<'_>, +) -> Result { + let client = sync_http_client_builder() + .build() + .map_err(|e| format!("failed to build http client: {e}"))?; + let url = format!("{registry_url}/api/auth/cli-step-up"); + + let body = match credential { + CliStepUpCredential::Password { password } => serde_json::json!({ + "scope": scope, + "method": "password", + "password": password, + }), + CliStepUpCredential::Totp { password, code } => serde_json::json!({ + "scope": scope, + "method": "totp", + "password": password, + "totpCode": code, + }), + }; + + let response = client + .post(&url) + .bearer_auth(auth_token) + .json(&body) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + .map_err(|e| format!("network error: {e}"))?; + + let status = response.status(); + if !status.is_success() { + let body = read_capped_error_text(response).await; + return Err(format!("step-up mint: {status}: {body}")); + } + + let parsed = response + .json::() + .await + .map_err(|e| format!("parse error: {e}"))?; + if parsed.ok != Some(true) { + return Err("step-up mint: server returned ok=false on 2xx response (unexpected)".into()); + } + parsed + .proof + .ok_or_else(|| "step-up mint: server response missing proof".into()) +} + +// ── Public Key Management ───────────────────────────────────────── + /// Server response from `POST /api/users/me/public-key`. /// /// The dashboard hardened that route to return a structured envelope @@ -786,7 +920,10 @@ pub async fn classify_public_key_state( /// canonical Base64 encoding. fn load_local_public_key_state() -> Result { #[cfg(target_os = "macos")] - let (private_key, public) = if force_file_x25519_keypair() { + let (private_key, public) = if should_use_file_backed_x25519_keypair( + force_file_x25519_keypair(), + live_x25519_key_path()?.exists(), + ) { get_or_create_file_backed_x25519_keypair()? } else { crate::keychain::get_or_create_x25519_keypair()? @@ -801,6 +938,166 @@ fn load_local_public_key_state() -> Result { }) } +/// Pending sharing-keypair lifecycle helpers used by the +/// `rotate-sharing-key` flow. +/// +/// Rotation is split across two writes: server-side upload of the new +/// public key, then local promotion of the matching private key into +/// the live slot. A crash, network drop, or process kill between those +/// two steps must NOT leave the live slot pointing at a key the server +/// no longer knows about (the user would silently lose access to every +/// org vault). The pending slot is the recovery primitive: +/// +/// 1. Generate a fresh keypair and persist it to the pending slot +/// WITHOUT touching the live slot. +/// 2. Upload the pending public key with a `vault:public-key:rotate` +/// step-up proof. +/// 3. On HTTP success, atomically promote the pending slot into the +/// live slot. +/// 4. On restart (or any future invocation), if the pending slot +/// contains a key that matches the server's current public key, +/// the previous run was interrupted between steps 2 and 3 — finish +/// promotion. If the pending slot exists but doesn't match the +/// server, the upload failed before commit; discard the pending. +/// +/// Storage backend: ALWAYS the file-backed slots (`.x25519_key.pending` +/// in the user's `~/.lpm` directory). The macOS keychain backend only +/// supports a single named entry per service/account pair; juggling a +/// transient pending entry there adds complexity (and a multi-keychain- +/// item ACL surface) that the file slot avoids cleanly. Promotion +/// writes the new private key to the live slot via the same code path +/// `get_or_create_file_backed_x25519_keypair` uses for first-set, so +/// permissions stay consistent (`0o600` on Unix). + +#[derive(Debug, Clone)] +pub struct PendingPublicKey { + pub private_key: [u8; 32], + pub public_key_b64: String, +} + +fn pending_x25519_key_path() -> Result { + Ok(dirs::home_dir() + .ok_or("no home directory")? + .join(".lpm") + .join(".x25519_key.pending")) +} + +fn live_x25519_key_path() -> Result { + Ok(dirs::home_dir() + .ok_or("no home directory")? + .join(".lpm") + .join(".x25519_key")) +} + +/// Generate a fresh X25519 keypair and persist it to the pending slot. +/// Does NOT touch the live slot. Overwrites any existing pending slot — +/// the caller's `rotate-sharing-key` flow has already verified the +/// pending state via [`read_pending_x25519_keypair`] and confirmed it's +/// safe to overwrite (stale orphan from a failed prior attempt). +pub fn create_pending_x25519_keypair() -> Result { + let (private_key, public_key) = crate::crypto::generate_x25519_keypair(); + let path = pending_x25519_key_path()?; + let parent = path.parent().ok_or("invalid pending key path")?; + std::fs::create_dir_all(parent) + .map_err(|e| format!("failed to create pending key dir: {e}"))?; + std::fs::write(&path, private_key).map_err(|e| format!("failed to write pending key: {e}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("failed to set pending key permissions: {e}"))?; + } + Ok(PendingPublicKey { + private_key, + public_key_b64: base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + public_key, + ), + }) +} + +/// Read the pending slot without promoting. Returns `Ok(None)` when no +/// pending slot exists (the steady state). Used on every +/// `rotate-sharing-key` invocation to detect crash-interrupted prior +/// rotations. +pub fn read_pending_x25519_keypair() -> Result, String> { + let path = pending_x25519_key_path()?; + if !path.exists() { + return Ok(None); + } + let data = std::fs::read(&path).map_err(|e| format!("failed to read pending key: {e}"))?; + if data.len() != 32 { + return Err(format!( + "pending key file has invalid length {} (expected 32)", + data.len() + )); + } + let mut private_key = [0u8; 32]; + private_key.copy_from_slice(&data); + let secret = x25519_dalek::StaticSecret::from(private_key); + let public_key = x25519_dalek::PublicKey::from(&secret); + let public_key_b64 = base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + public_key.as_bytes(), + ); + Ok(Some(PendingPublicKey { + private_key, + public_key_b64, + })) +} + +/// Atomically promote the pending slot into the live slot. +/// +/// Writes the pending private key to the live file with `0o600` perms, +/// then deletes the pending file. The write-then-delete order means a +/// crash between the two steps leaves BOTH files with the same key +/// (next promotion is a no-op); a crash before the write leaves only +/// pending (next rotation discovers it). Both orderings are safe; the +/// only unsafe state would be "live updated, pending still present +/// with a stale key" — which cannot happen because pending always +/// holds the key we just wrote to live. +/// +/// Also clears any prior keychain entry on macOS so subsequent +/// `load_local_public_key_state` calls observe the new file-backed key +/// instead of the stale keychain entry. (The keychain entry survives +/// across rotations otherwise.) +pub fn promote_pending_x25519_keypair() -> Result<(), String> { + let pending = read_pending_x25519_keypair()?.ok_or("no pending key to promote")?; + let live_path = live_x25519_key_path()?; + let parent = live_path.parent().ok_or("invalid live key path")?; + std::fs::create_dir_all(parent).map_err(|e| format!("failed to create live key dir: {e}"))?; + std::fs::write(&live_path, pending.private_key) + .map_err(|e| format!("failed to write live key: {e}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&live_path, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("failed to set live key permissions: {e}"))?; + } + + // Clear macOS keychain entry if present so subsequent reads see + // the file-backed key. Best-effort: a "not found" error here is + // expected when the user was already on the file fallback, so we + // intentionally swallow keychain errors. + #[cfg(target_os = "macos")] + { + let _ = crate::keychain::delete_x25519_keypair(); + } + + discard_pending_x25519_keypair() +} + +/// Delete the pending slot without promoting. Called when the prior +/// upload failed before commit (pending key doesn't match server's +/// current key), or after a successful promotion. +pub fn discard_pending_x25519_keypair() -> Result<(), String> { + let path = pending_x25519_key_path()?; + if path.exists() { + std::fs::remove_file(&path).map_err(|e| format!("failed to delete pending key: {e}"))?; + } + Ok(()) +} + /// Org member public key info. #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] @@ -858,6 +1155,11 @@ fn force_file_x25519_keypair() -> bool { ) } +#[cfg(target_os = "macos")] +fn should_use_file_backed_x25519_keypair(force_file: bool, live_key_exists: bool) -> bool { + force_file || live_key_exists +} + fn get_or_create_file_backed_x25519_keypair() -> Result<([u8; 32], [u8; 32]), String> { let key_path = dirs::home_dir() .ok_or("no home directory")? @@ -3179,4 +3481,287 @@ mod tests { .expect_err("server 500 must propagate, not be misclassified"); assert!(err.contains("500"), "got: {err}"); } + + #[tokio::test] + async fn discover_cli_step_up_policy_parses_password_response() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/auth/cli-step-up")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "method": "password", + "ttlSeconds": 300, + "header": "X-LPM-Step-Up-Proof", + }))) + .expect(1) + .mount(&server) + .await; + + let policy = discover_cli_step_up_policy(&server.uri(), "token") + .await + .expect("happy path"); + assert_eq!(policy.method, "password"); + assert!(policy.reason.is_none()); + assert_eq!(policy.ttl_seconds, Some(300)); + assert_eq!(policy.header.as_deref(), Some("X-LPM-Step-Up-Proof")); + } + + #[tokio::test] + async fn discover_cli_step_up_policy_parses_unavailable_with_reason() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/auth/cli-step-up")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "method": "unavailable", + "reason": "set_password_required", + }))) + .expect(1) + .mount(&server) + .await; + + let policy = discover_cli_step_up_policy(&server.uri(), "token") + .await + .expect("happy path"); + assert_eq!(policy.method, "unavailable"); + assert_eq!(policy.reason.as_deref(), Some("set_password_required")); + } + + #[tokio::test] + async fn discover_cli_step_up_policy_errors_on_non_2xx() { + // A 401 here can't be silently collapsed — the CLI would + // proceed to prompt for a credential the server can't accept. + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/auth/cli-step-up")) + .respond_with(ResponseTemplate::new(401).set_body_string("expired token")) + .expect(1) + .mount(&server) + .await; + + let err = discover_cli_step_up_policy(&server.uri(), "token") + .await + .expect_err("401 must propagate"); + assert!(err.contains("401"), "got: {err}"); + } + + #[tokio::test] + async fn mint_cli_step_up_proof_password_sends_expected_body() { + use std::sync::Arc; + use std::sync::Mutex as StdMutex; + + let captured = Arc::new(StdMutex::new(Vec::::new())); + let server = MockServer::start().await; + + #[derive(Clone)] + struct CaptureBody(Arc>>); + impl Respond for CaptureBody { + fn respond(&self, request: &Request) -> ResponseTemplate { + self.0 + .lock() + .unwrap() + .push(String::from_utf8_lossy(&request.body).to_string()); + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "ok": true, + "proof": "test-jwt", + })) + } + } + + Mock::given(method("POST")) + .and(path("/api/auth/cli-step-up")) + .respond_with(CaptureBody(Arc::clone(&captured))) + .expect(1) + .mount(&server) + .await; + + let proof = mint_cli_step_up_proof( + &server.uri(), + "token", + "vault:public-key:set", + &CliStepUpCredential::Password { + password: "hunter2", + }, + ) + .await + .expect("happy path"); + assert_eq!(proof, "test-jwt"); + + let body = captured.lock().unwrap()[0].clone(); + assert!(body.contains("\"method\":\"password\"")); + assert!(body.contains("\"scope\":\"vault:public-key:set\"")); + assert!(body.contains("\"password\":\"hunter2\"")); + assert!( + !body.contains("totpCode"), + "password-only request must not include totpCode field: {body}" + ); + } + + #[tokio::test] + async fn mint_cli_step_up_proof_totp_sends_both_password_and_code() { + use std::sync::Arc; + use std::sync::Mutex as StdMutex; + + let captured = Arc::new(StdMutex::new(Vec::::new())); + let server = MockServer::start().await; + + #[derive(Clone)] + struct CaptureBody(Arc>>); + impl Respond for CaptureBody { + fn respond(&self, request: &Request) -> ResponseTemplate { + self.0 + .lock() + .unwrap() + .push(String::from_utf8_lossy(&request.body).to_string()); + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "ok": true, + "proof": "totp-jwt", + })) + } + } + + Mock::given(method("POST")) + .and(path("/api/auth/cli-step-up")) + .respond_with(CaptureBody(Arc::clone(&captured))) + .expect(1) + .mount(&server) + .await; + + let proof = mint_cli_step_up_proof( + &server.uri(), + "token", + "vault:public-key:rotate", + &CliStepUpCredential::Totp { + password: "hunter2", + code: "123456", + }, + ) + .await + .expect("happy path"); + assert_eq!(proof, "totp-jwt"); + + let body = captured.lock().unwrap()[0].clone(); + assert!(body.contains("\"method\":\"totp\"")); + assert!(body.contains("\"scope\":\"vault:public-key:rotate\"")); + assert!(body.contains("\"password\":\"hunter2\"")); + assert!(body.contains("\"totpCode\":\"123456\"")); + } + + #[tokio::test] + async fn mint_cli_step_up_proof_propagates_server_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/auth/cli-step-up")) + .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({ + "ok": false, + "code": "wrong_credential", + "error": "Incorrect password.", + }))) + .expect(1) + .mount(&server) + .await; + + let err = mint_cli_step_up_proof( + &server.uri(), + "token", + "vault:public-key:set", + &CliStepUpCredential::Password { password: "wrong" }, + ) + .await + .expect_err("401 must propagate"); + assert!(err.contains("401"), "got: {err}"); + assert!( + err.contains("wrong_credential") || err.contains("Incorrect password"), + "error envelope should be preserved so CLI can render it: {err}" + ); + } + + #[test] + fn pending_key_lifecycle_round_trips_through_create_read_promote() { + // Create → read → promote, verifying that promote leaves the + // live slot with the pending bytes and clears the pending file. + let _guard = env_lock_guard(); + let _isolated = IsolatedVaultKeyEnv::new(); + + // No pending at first. + assert!( + read_pending_x25519_keypair() + .expect("read pending") + .is_none(), + "fresh HOME should have no pending key" + ); + + // Create — pending file now exists, live slot still untouched. + let pending = create_pending_x25519_keypair().expect("create pending"); + let pending_path = pending_x25519_key_path().expect("path"); + let live_path = live_x25519_key_path().expect("path"); + assert!(pending_path.exists(), "create must persist the pending key"); + assert!( + !live_path.exists(), + "create must NOT touch the live slot until promotion" + ); + + // Read returns the same key bytes. + let read_back = read_pending_x25519_keypair() + .expect("read pending") + .expect("pending should be present"); + assert_eq!(read_back.public_key_b64, pending.public_key_b64); + + // Promote — live slot now holds the pending bytes; pending is gone. + promote_pending_x25519_keypair().expect("promote"); + assert!(live_path.exists(), "live slot must be present post-promote"); + assert!( + !pending_path.exists(), + "pending must be deleted after promote" + ); + let live_bytes = std::fs::read(&live_path).expect("read live"); + assert_eq!(live_bytes, &pending.private_key); + + // Subsequent classify-equivalent: load_local_public_key_state + // should see the new live key. + let live = load_local_public_key_state().expect("load live"); + assert_eq!(live.public_key_b64, pending.public_key_b64); + } + + #[test] + fn pending_key_discard_removes_orphan_without_touching_live() { + let _guard = env_lock_guard(); + let _isolated = IsolatedVaultKeyEnv::new(); + + // Pre-seed a live key. + let live = load_local_public_key_state().expect("seed live"); + let live_path = live_x25519_key_path().expect("path"); + assert!(live_path.exists()); + + // Create + discard pending. + let _ = create_pending_x25519_keypair().expect("create pending"); + let pending_path = pending_x25519_key_path().expect("path"); + assert!(pending_path.exists()); + + discard_pending_x25519_keypair().expect("discard"); + assert!(!pending_path.exists()); + assert!(live_path.exists(), "discard must NOT touch live"); + // Live key bytes unchanged. + let live_after = load_local_public_key_state().expect("load live"); + assert_eq!(live_after.public_key_b64, live.public_key_b64); + } + + #[test] + fn pending_key_promote_when_no_pending_is_explicit_error() { + let _guard = env_lock_guard(); + let _isolated = IsolatedVaultKeyEnv::new(); + + let err = promote_pending_x25519_keypair() + .expect_err("promote with no pending must Err, not silently succeed"); + assert!(err.contains("no pending"), "got: {err}"); + } + + #[cfg(target_os = "macos")] + #[test] + fn x25519_backend_selection_uses_live_file_after_rotation_without_force_env() { + assert!( + should_use_file_backed_x25519_keypair(false, true), + "a promoted live file must win over the default keychain path so post-rotation reads keep using the rotated key" + ); + assert!(!should_use_file_backed_x25519_keypair(false, false)); + assert!(should_use_file_backed_x25519_keypair(true, false)); + } } diff --git a/tests/workflows/tests/env_vault.rs b/tests/workflows/tests/env_vault.rs index 7210b8e2..cbc07602 100644 --- a/tests/workflows/tests/env_vault.rs +++ b/tests/workflows/tests/env_vault.rs @@ -2648,3 +2648,87 @@ async fn env_share_refuses_force_flag_with_actionable_remediation() { "expected the pull-then-retry remediation hint; got: {combined}" ); } + +#[tokio::test] +async fn env_rotate_sharing_key_refuses_without_a_tty() { + // `lpm env rotate-sharing-key` is an interactive recovery surface + // — it prompts for typed `ROTATE` confirmation AND for password / + // TOTP via cliclack. Running it from a non-TTY context (CI, + // unattended runner, `curl | sh`) would either block on stdin + // forever or silently accept hostile input. The command MUST + // refuse outright at flag-parse / TTY-detect time so the operator + // never gets a half-rotated state. + // + // This test runs without a controlling TTY (cargo test inherits + // the worker's pipe-backed stdin), so the refusal must fire + // before any network, vault-state, or pending-key side effect. + let project = TempProject::empty(r#"{"name":"rotate-sharing-key-non-tty","version":"1.0.0"}"#); + + let output = lpm(&project) + .args(["env", "rotate-sharing-key"]) + .output() + .expect("failed to run lpm env rotate-sharing-key"); + + assert!( + !output.status.success(), + "rotate-sharing-key must fail with non-zero exit when stdin is not a TTY:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + + // The cliclack error box wraps long lines and inserts `│` + // continuation bars between segments, so we match on stable + // substrings that don't span those wraps. The two pinned phrases + // are load-bearing for the refusal semantics: the command name + // (so a future error-message rewrite that drops it gets caught) + // and "TTY" (so a regression that allows non-interactive flow + // gets caught). + let combined = format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + assert!( + combined.contains("rotate-sharing-key"), + "refusal must name the command so users know what was rejected; got: {combined}" + ); + assert!( + combined.contains("TTY"), + "refusal must mention the TTY requirement so users know the cause; got: {combined}" + ); +} + +#[tokio::test] +async fn env_rotate_sharing_key_refuses_yes_flag_explicitly() { + // `--yes` is the conventional "skip prompt" flag elsewhere in the + // CLI, but for the sharing-key rotation flow there is no safe way + // to bypass the typed ROTATE confirmation + step-up reauth: the + // command's whole purpose is to be the one rotation surface that + // CANNOT be driven from an automated context. Pin the refusal so + // a future change cannot accidentally turn `--yes` into a working + // CI bypass. + let project = + TempProject::empty(r#"{"name":"rotate-sharing-key-yes-refused","version":"1.0.0"}"#); + + let output = lpm(&project) + .args(["env", "rotate-sharing-key", "--yes"]) + .output() + .expect("failed to run lpm env rotate-sharing-key --yes"); + + assert!( + !output.status.success(), + "rotate-sharing-key --yes must still fail:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + + let combined = format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + assert!( + combined.contains("rotate-sharing-key") && combined.contains("TTY"), + "expected the explicit refusal regardless of --yes; got: {combined}" + ); +}