Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 9 additions & 28 deletions crates/lpm-cli/src/commands/env/platform/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
24 changes: 21 additions & 3 deletions crates/lpm-vault/src/signature.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]"))
}
Expand Down
37 changes: 9 additions & 28 deletions crates/lpm-vault/src/sync/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
143 changes: 143 additions & 0 deletions crates/lpm-vault/tests/acceptance_response_signing.rs
Original file line number Diff line number Diff line change
@@ -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,
);
}