gate(executor): constant-time hash compare in the privileged helper
Compare artifact and closure hashes in constant time via subtle::ConstantTimeEq at the three verify sites, closing a timing oracle on the privileged executor. subtle is added as an explicit dep (was only transitive).
This commit is contained in:
parent
0b1b8124b0
commit
c35ad89b92
2 changed files with 19 additions and 3 deletions
|
|
@ -10,6 +10,7 @@ serde = { version = "1.0", features = ["derive"] }
|
|||
serde_json = "1.0"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
subtle = "2"
|
||||
|
||||
[[bin]]
|
||||
name = "redflag-helper"
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
const SUPPORTED_TOKEN_VERSION: u32 = 1;
|
||||
|
||||
|
|
@ -357,6 +358,20 @@ fn compute_file_sha256(path: &Path) -> std::io::Result<String> {
|
|||
|
||||
// Verify every artifact the executor can actually reach on disk.
|
||||
// mirror source: artifact_path is required and the file MUST exist and match.
|
||||
// Constant-time equality for hex hash strings. A short-circuiting == or
|
||||
// eq_ignore_ascii_case on the artifact-integrity gate would leak the pinned hash
|
||||
// byte-by-byte through timing; this process runs as root via systemd-run and its
|
||||
// wall-clock is observable, so the load-bearing compare must not be a timing
|
||||
// oracle. Length is public (sha256 hex is always 64 chars), so the early length
|
||||
// check leaks only that a malformed input was offered, never hash bytes. `subtle`
|
||||
// is already in the dep tree via curve25519-dalek; depending on it explicitly keeps
|
||||
// that a guarantee rather than a transitive accident.
|
||||
fn ct_eq_hex(a: &str, b: &str) -> bool {
|
||||
let a = a.trim().to_lowercase();
|
||||
let b = b.trim().to_lowercase();
|
||||
a.len() == b.len() && bool::from(a.as_bytes().ct_eq(b.as_bytes()))
|
||||
}
|
||||
|
||||
// registry source: if a local artifact_path is present, verify it; otherwise the
|
||||
// server already attested the hash (covered by the signature) and enforcement is
|
||||
// the mirror's job at fetch time. Returns count of hashes verified on disk.
|
||||
|
|
@ -386,7 +401,7 @@ fn verify_artifacts(token: &CapabilityToken) -> Result<usize, Denial> {
|
|||
let actual = compute_file_sha256(path).map_err(|err| {
|
||||
Denial::new(EXIT_ARTIFACT, "artifact_hash_read_failed", format!("{}: {}", p, err))
|
||||
})?;
|
||||
if !actual.eq_ignore_ascii_case(&e.sha256) {
|
||||
if !ct_eq_hex(&actual, &e.sha256) {
|
||||
return Err(Denial::new(
|
||||
EXIT_ARTIFACT,
|
||||
"artifact_hash_mismatch",
|
||||
|
|
@ -620,7 +635,7 @@ fn stage_and_verify_binary(source: &str, staging: &str, expected_hash: &str) ->
|
|||
return Err(Denial::new(EXIT_ARTIFACT, "stage_hash_failed", format!("{}: {}", staging, e)));
|
||||
}
|
||||
};
|
||||
if actual != expected {
|
||||
if !ct_eq_hex(&actual, &expected) {
|
||||
let _ = fs::remove_file(staging);
|
||||
return Err(Denial::new(
|
||||
EXIT_ARTIFACT,
|
||||
|
|
@ -1902,7 +1917,7 @@ fn run_verify_binary(args: &[String]) -> i32 {
|
|||
return EXIT_INTEGRITY;
|
||||
}
|
||||
};
|
||||
let matched = actual == expected;
|
||||
let matched = ct_eq_hex(&actual, &expected);
|
||||
|
||||
let result = IntegrityResult {
|
||||
target: target.clone(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue