diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3eb8f21..f203ba0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -317,6 +317,14 @@ jobs: - name: CLI binary-surface tests run: cargo nextest run --locked -p lpm-cli -E 'not binary_id(/bin\/lpm-rs/)' --no-fail-fast --status-level slow --final-status-level fail + - name: Release signature verification boundaries + env: + CARGO_PROFILE_RELEASE_LTO: "false" + CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16" + run: | + cargo test --release --locked -p lpm-vault --test acceptance_response_signing + cargo test --release --locked -p lpm-vault --features acceptance-test-hooks --test acceptance_response_signing + macos-workspace: name: macOS workspace checks runs-on: macos-latest diff --git a/crates/lpm-cli/src/commands/env/platform/mod.rs b/crates/lpm-cli/src/commands/env/platform/mod.rs index 9a7fb5e2..8b787c08 100644 --- a/crates/lpm-cli/src/commands/env/platform/mod.rs +++ b/crates/lpm-cli/src/commands/env/platform/mod.rs @@ -1218,37 +1218,18 @@ async fn read_signed_lpm_response( .get(lpm_vault::signature::SIGNATURE_HEADER) .and_then(|value| value.to_str().ok()) .map(str::to_owned); - #[cfg(all(debug_assertions, not(test)))] - let local_development = - lpm_vault::signature::is_local_development_key(response.url(), key_id.as_deref()); + #[cfg(not(test))] + let origin = response.url().clone(); let body = super::response::read_capped_platform_body(response).await?; if status.is_success() { #[cfg(not(test))] - let verification = { - #[cfg(debug_assertions)] - if local_development { - lpm_vault::signature::verify_response_with_test_key( - status.as_u16(), - &body, - key_id.as_deref(), - signature.as_deref(), - ) - } else { - lpm_vault::signature::verify_response( - status.as_u16(), - &body, - key_id.as_deref(), - signature.as_deref(), - ) - } - #[cfg(not(debug_assertions))] - lpm_vault::signature::verify_response( - status.as_u16(), - &body, - key_id.as_deref(), - signature.as_deref(), - ) - }; + let verification = lpm_vault::signature::verify_response_for_origin( + &origin, + status.as_u16(), + &body, + key_id.as_deref(), + signature.as_deref(), + ); #[cfg(test)] let verification = lpm_vault::signature::verify_response_with_test_key( status.as_u16(), diff --git a/crates/lpm-vault/src/signature.rs b/crates/lpm-vault/src/signature.rs index a8b2c151..e74c283b 100644 --- a/crates/lpm-vault/src/signature.rs +++ b/crates/lpm-vault/src/signature.rs @@ -110,11 +110,29 @@ pub fn verify_response( }) } -/// Whether a debug response can use the built-in localhost signing key. -#[cfg(debug_assertions)] +/// Verify a response with the keys allowed for its origin and build configuration. +pub fn verify_response_for_origin( + origin: &reqwest::Url, + status: u16, + body: &[u8], + key_id_header: Option<&str>, + signature_header: Option<&str>, +) -> Result<(), SignatureError> { + #[cfg(any(debug_assertions, feature = "acceptance-test-hooks"))] + if is_local_development_key(origin, key_id_header) { + return verify_response_with_test_key(status, body, key_id_header, signature_header); + } + #[cfg(not(any(debug_assertions, feature = "acceptance-test-hooks")))] + let _ = origin; + verify_response(status, body, key_id_header, signature_header) +} + +/// Whether a debug or isolated acceptance response can use the localhost signing key. +#[cfg(any(debug_assertions, feature = "acceptance-test-hooks"))] #[inline] pub fn is_local_development_key(url: &reqwest::Url, key_id: Option<&str>) -> bool { - key_id == Some(TEST_KEY_ID) + (cfg!(debug_assertions) || crate::acceptance_file_storage_enabled()) + && key_id == Some(TEST_KEY_ID) && url.scheme() == "http" && matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")) } diff --git a/crates/lpm-vault/src/sync/http.rs b/crates/lpm-vault/src/sync/http.rs index 5121a66b..542277f4 100644 --- a/crates/lpm-vault/src/sync/http.rs +++ b/crates/lpm-vault/src/sync/http.rs @@ -153,39 +153,20 @@ pub(super) async fn read_verified_response( } else { MAX_VAULT_ERROR_RESPONSE_BYTES }; - #[cfg(all(debug_assertions, not(test)))] - let local_development = - signature::is_local_development_key(response.url(), key_id_header.as_deref()); + #[cfg(not(test))] + let origin = response.url().clone(); let mut body = read_capped_body_with_limit(response, max_bytes).await?; let has_signature_headers = key_id_header.is_some() || signature_header.is_some(); if status != reqwest::StatusCode::UNAUTHORIZED || has_signature_headers { #[cfg(not(test))] - let verification = { - #[cfg(debug_assertions)] - if local_development { - signature::verify_response_with_test_key( - status.as_u16(), - &body, - key_id_header.as_deref(), - signature_header.as_deref(), - ) - } else { - signature::verify_response( - status.as_u16(), - &body, - key_id_header.as_deref(), - signature_header.as_deref(), - ) - } - #[cfg(not(debug_assertions))] - signature::verify_response( - status.as_u16(), - &body, - key_id_header.as_deref(), - signature_header.as_deref(), - ) - }; + let verification = signature::verify_response_for_origin( + &origin, + status.as_u16(), + &body, + key_id_header.as_deref(), + signature_header.as_deref(), + ); #[cfg(test)] let verification = signature::verify_response_with_test_key( status.as_u16(), diff --git a/crates/lpm-vault/tests/acceptance_response_signing.rs b/crates/lpm-vault/tests/acceptance_response_signing.rs new file mode 100644 index 00000000..de871dd9 --- /dev/null +++ b/crates/lpm-vault/tests/acceptance_response_signing.rs @@ -0,0 +1,143 @@ +//! Runs the non-test library path; unit builds use a separate signing-key verifier. + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use ed25519_dalek::{Signer, SigningKey}; +use lpm_vault::{signature, sync}; +use sha2::{Digest, Sha256}; +use std::process::Command; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn signed_response(request: &wiremock::Request, tampered: bool) -> ResponseTemplate { + let key_id = "vault-test-rfc8032"; + let body = serde_json::json!({ + "envelopeVersion": 3, + "operation": "sharingKey.read", + "outcome": "absent", + "requestNonce": request.headers["x-lpm-vault-request-nonce"].to_str().unwrap(), + "binding": { "scope": "account", "principalId": "acceptance-principal" }, + "data": {} + }) + .to_string(); + let seed = URL_SAFE_NO_PAD + .decode("nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A") + .unwrap(); + let key = SigningKey::from_bytes(seed.as_slice().try_into().unwrap()); + let mut frame = b"lpm-authenticated-response\0".to_vec(); + frame.push(4); + frame.extend_from_slice(&200u16.to_be_bytes()); + frame.push(key_id.len() as u8); + frame.extend_from_slice(key_id.as_bytes()); + frame.extend_from_slice(&(body.len() as u64).to_be_bytes()); + frame.extend_from_slice(&Sha256::digest(body.as_bytes())); + let signature = URL_SAFE_NO_PAD.encode(key.sign(&frame).to_bytes()); + ResponseTemplate::new(200) + .insert_header(signature::KEY_ID_HEADER, key_id) + .insert_header(signature::SIGNATURE_HEADER, signature) + .set_body_string(if tampered { + body.replace("acceptance-principal", "changed-principal") + } else { + body + }) +} + +fn run_case(name: &str, isolated: bool, tampered: bool, accepted: bool) { + if std::env::var("LPM_SIGNATURE_TEST_CHILD").as_deref() == Ok(name) { + tokio::runtime::Runtime::new().unwrap().block_on(async { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/api/users/me/public-key")) + .respond_with(move |request: &wiremock::Request| signed_response(request, tampered)) + .expect(1) + .mount(&server) + .await; + let result = sync::get_my_public_key_state(&server.uri(), "test-token").await; + if accepted { + let state = result.expect("isolated signed response must verify"); + assert_eq!(state.principal_id, "acceptance-principal"); + assert_eq!(state.public_key_b64, None); + #[cfg(any(debug_assertions, feature = "acceptance-test-hooks"))] + for address in [ + "https://lpm.dev", + "http://example.com", + "http://localhost.example.com", + "https://127.0.0.1", + "https://localhost", + ] { + assert!(!signature::is_local_development_key( + &reqwest::Url::parse(address).unwrap(), + Some("vault-test-rfc8032"), + )); + } + } else { + let error = result.expect_err("test signing key must not bypass verification"); + assert!( + error.to_string().contains( + if tampered + && cfg!(any(debug_assertions, feature = "acceptance-test-hooks")) + { + "signature does not match" + } else { + "signing key ID is unknown" + } + ), + "{error}" + ); + } + }); + return; + } + + let directory = tempfile::tempdir().unwrap(); + let home = directory.path().join("home"); + std::fs::create_dir_all(home.join(".lpm")).unwrap(); + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", name, "--nocapture"]) + .env("LPM_SIGNATURE_TEST_CHILD", name) + .env("HOME", &home) + .env("LPM_HOME", home.join(".lpm")) + .env("LPM_ACCEPTANCE_FILE_STORAGE", "1") + .env("ACCEPTANCE_RUN_ID", "response-signature-test") + .env( + "ACCEPTANCE_RUN_DIR", + if isolated { directory.path() } else { &home }, + ); + let output = command.output().unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn isolated_acceptance_uses_only_compiled_signing_keys() { + run_case( + "isolated_acceptance_uses_only_compiled_signing_keys", + true, + false, + cfg!(any(debug_assertions, feature = "acceptance-test-hooks")), + ); +} + +#[test] +fn release_verification_rejects_test_keys_without_storage_isolation() { + run_case( + "release_verification_rejects_test_keys_without_storage_isolation", + false, + false, + cfg!(debug_assertions), + ); +} + +#[test] +fn isolated_acceptance_rejects_tampered_signed_responses() { + run_case( + "isolated_acceptance_rejects_tampered_signed_responses", + true, + true, + false, + ); +}