pharos_runtime_ops/
provenance.rs1use std::fs;
4use std::io::Read;
5use std::path::Path;
6
7use ring::rand::SystemRandom;
8use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
9use serde_json::Value;
10use sha2::{Digest, Sha256};
11
12pub const PROVENANCE_SCHEMA: &str = "pharos.release-provenance.v1";
24
25pub struct SignAttestationRequest<'a> {
48 pub artifact_path: &'a Path,
49 pub sbom_path: &'a Path,
50 pub source_revision: &'a str,
51 pub cargo_lock_sha256: &'a str,
52 pub toolchain: &'a str,
53 pub build_configuration: &'a str,
54 pub app_contract_sha256: &'a str,
55 pub private_key_pkcs8: &'a [u8],
56}
57
58fn hex_encode(bytes: &[u8]) -> String {
60 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
61}
62
63fn hex_decode(text: &str) -> Option<Vec<u8>> {
65 if text.is_empty() || !text.len().is_multiple_of(2) {
66 return None;
67 }
68 text.as_bytes()
69 .chunks_exact(2)
70 .map(|pair| {
71 let high = (pair[0] as char).to_digit(16)?;
72 let low = (pair[1] as char).to_digit(16)?;
73 Some(((high << 4) | low) as u8)
74 })
75 .collect()
76}
77
78fn file_sha256(path: &Path) -> Option<String> {
80 let metadata = fs::metadata(path).ok()?;
81 if !metadata.is_file() || metadata.len() > 2 * 1024 * 1024 * 1024 {
82 return None;
83 }
84 let mut file = fs::File::open(path).ok()?;
85 let mut digest = Sha256::new();
86 let mut buffer = [0u8; 64 * 1024];
87 loop {
88 let read = file.read(&mut buffer).ok()?;
89 if read == 0 {
90 break;
91 }
92 digest.update(&buffer[..read]);
93 }
94 Some(hex_encode(&digest.finalize()))
95}
96
97pub fn app_contract_set_sha256(apps_root: &Path) -> Option<String> {
121 let canonical_root = fs::canonicalize(apps_root).ok()?;
122 let mut inputs = Vec::new();
123 for app_entry in fs::read_dir(&canonical_root).ok()?.flatten() {
124 let app_path = app_entry.path();
125 if app_entry.file_type().ok()?.is_symlink() || !app_path.is_dir() {
126 continue;
127 }
128 for manifest_name in ["pharos.app.yml", "pharos.app.json"] {
129 let manifest = app_path.join(manifest_name);
130 if manifest.is_file() {
131 inputs.push(manifest);
132 }
133 }
134 let contracts = app_path.join("data/contracts");
135 if !contracts.is_dir() {
136 continue;
137 }
138 for entry in fs::read_dir(contracts).ok()?.flatten() {
139 let path = entry.path();
140 let file_type = entry.file_type().ok()?;
141 if file_type.is_symlink() {
142 return None;
143 }
144 if file_type.is_file()
145 && matches!(
146 path.extension().and_then(|value| value.to_str()),
147 Some("yml" | "json")
148 )
149 {
150 inputs.push(path);
151 }
152 }
153 }
154 inputs.sort();
155 if inputs.is_empty() || inputs.len() > 10_000 {
156 return None;
157 }
158 let mut digest = Sha256::new();
159 let mut total_bytes = 0u64;
160 for path in inputs {
161 let relative = path.strip_prefix(&canonical_root).ok()?;
162 let metadata = fs::metadata(&path).ok()?;
163 total_bytes = total_bytes.checked_add(metadata.len())?;
164 if !metadata.is_file() || total_bytes > 512 * 1024 * 1024 {
165 return None;
166 }
167 digest.update(relative.to_string_lossy().as_bytes());
168 digest.update([0]);
169 let mut file = fs::File::open(path).ok()?;
170 let mut buffer = [0u8; 64 * 1024];
171 loop {
172 let read = file.read(&mut buffer).ok()?;
173 if read == 0 {
174 break;
175 }
176 digest.update(&buffer[..read]);
177 }
178 digest.update([0]);
179 }
180 Some(hex_encode(&digest.finalize()))
181}
182
183pub fn generate_private_key() -> Result<Vec<u8>, &'static str> {
203 Ed25519KeyPair::generate_pkcs8(&SystemRandom::new())
204 .map(|document| document.as_ref().to_vec())
205 .map_err(|_| "provenance key generation failed")
206}
207
208pub fn public_key_from_private(private_key_pkcs8: &[u8]) -> Result<Vec<u8>, &'static str> {
232 Ed25519KeyPair::from_pkcs8(private_key_pkcs8)
233 .map(|key_pair| key_pair.public_key().as_ref().to_vec())
234 .map_err(|_| "invalid provenance private key")
235}
236
237pub fn sign_attestation(request: &SignAttestationRequest<'_>) -> Result<Value, &'static str> {
261 let key_pair = Ed25519KeyPair::from_pkcs8(request.private_key_pkcs8)
262 .map_err(|_| "invalid provenance private key")?;
263 let statement = serde_json::json!({
264 "schema": PROVENANCE_SCHEMA,
265 "artifact": {
266 "name": request.artifact_path.file_name().and_then(|name| name.to_str()).ok_or("invalid artifact path")?,
267 "sha256": file_sha256(request.artifact_path).ok_or("artifact digest failed")?,
268 },
269 "sbom": {
270 "name": request.sbom_path.file_name().and_then(|name| name.to_str()).ok_or("invalid SBOM path")?,
271 "sha256": file_sha256(request.sbom_path).ok_or("SBOM digest failed")?,
272 },
273 "source_revision": request.source_revision,
274 "cargo_lock_sha256": request.cargo_lock_sha256,
275 "toolchain": request.toolchain,
276 "build_configuration": request.build_configuration,
277 "app_contract_sha256": request.app_contract_sha256,
278 });
279 let payload = serde_json::to_vec(&statement).map_err(|_| "provenance encode failed")?;
280 let signature = key_pair.sign(&payload);
281 Ok(serde_json::json!({
282 "statement": statement,
283 "signature": {
284 "algorithm": "Ed25519",
285 "public_key": hex_encode(key_pair.public_key().as_ref()),
286 "value": hex_encode(signature.as_ref()),
287 }
288 }))
289}
290
291pub fn verify_attestation(
319 attestation: &Value,
320 artifact_path: &Path,
321 sbom_path: &Path,
322 trusted_public_key: &[u8],
323) -> Result<(), &'static str> {
324 if trusted_public_key.len() != 32 {
325 return Err("invalid trusted provenance public key");
326 }
327 let statement = attestation
328 .get("statement")
329 .ok_or("provenance statement missing")?;
330 if statement.get("schema").and_then(Value::as_str) != Some(PROVENANCE_SCHEMA) {
331 return Err("unsupported provenance schema");
332 }
333 let signature = attestation
334 .get("signature")
335 .ok_or("provenance signature missing")?;
336 if signature.get("algorithm").and_then(Value::as_str) != Some("Ed25519") {
337 return Err("unsupported provenance signature");
338 }
339 let public_key = hex_decode(
340 signature
341 .get("public_key")
342 .and_then(Value::as_str)
343 .ok_or("provenance public key missing")?,
344 )
345 .ok_or("invalid provenance public key")?;
346 if public_key.as_slice() != trusted_public_key {
347 return Err("untrusted provenance signing key");
348 }
349 let signature_bytes = hex_decode(
350 signature
351 .get("value")
352 .and_then(Value::as_str)
353 .ok_or("provenance signature value missing")?,
354 )
355 .ok_or("invalid provenance signature value")?;
356 let payload = serde_json::to_vec(statement).map_err(|_| "provenance encode failed")?;
357 UnparsedPublicKey::new(&ED25519, public_key)
358 .verify(&payload, &signature_bytes)
359 .map_err(|_| "provenance signature verification failed")?;
360 let artifact_sha = statement
361 .get("artifact")
362 .and_then(|value| value.get("sha256"))
363 .and_then(Value::as_str)
364 .ok_or("artifact digest missing")?;
365 let sbom_sha = statement
366 .get("sbom")
367 .and_then(|value| value.get("sha256"))
368 .and_then(Value::as_str)
369 .ok_or("SBOM digest missing")?;
370 if file_sha256(artifact_path).as_deref() != Some(artifact_sha)
371 || file_sha256(sbom_path).as_deref() != Some(sbom_sha)
372 {
373 return Err("provenance subject digest mismatch");
374 }
375 Ok(())
376}
377
378#[cfg(test)]
379mod tests {
380 use super::{
381 SignAttestationRequest, app_contract_set_sha256, generate_private_key,
382 public_key_from_private, sign_attestation, verify_attestation,
383 };
384 use std::fs;
385 use std::time::{SystemTime, UNIX_EPOCH};
386
387 #[test]
388 fn signed_attestation_rejects_artifact_drift() {
390 let unique = SystemTime::now()
391 .duration_since(UNIX_EPOCH)
392 .expect("clock")
393 .as_nanos();
394 let root = std::env::temp_dir().join(format!("pharos-provenance-{unique}"));
395 fs::create_dir_all(&root).expect("fixture root");
396 let artifact = root.join("bundle.tar.gz");
397 let sbom = root.join("pharos.cdx.json");
398 fs::write(&artifact, b"artifact").expect("artifact");
399 fs::write(&sbom, b"{}").expect("SBOM");
400 let key = generate_private_key().expect("key");
401 let public_key = public_key_from_private(&key).expect("public key");
402 let attestation = sign_attestation(&SignAttestationRequest {
403 artifact_path: &artifact,
404 sbom_path: &sbom,
405 source_revision: "source",
406 cargo_lock_sha256: "lock",
407 toolchain: "rust",
408 build_configuration: "release",
409 app_contract_sha256: "contracts",
410 private_key_pkcs8: &key,
411 })
412 .expect("sign");
413 verify_attestation(&attestation, &artifact, &sbom, &public_key).expect("verify");
414 fs::write(&artifact, b"changed").expect("mutate");
415 assert!(verify_attestation(&attestation, &artifact, &sbom, &public_key).is_err());
416 fs::remove_dir_all(root).expect("cleanup");
417 }
418
419 #[test]
420 fn signed_attestation_rejects_an_untrusted_signing_key() {
422 let unique = SystemTime::now()
423 .duration_since(UNIX_EPOCH)
424 .expect("clock")
425 .as_nanos();
426 let root = std::env::temp_dir().join(format!("pharos-provenance-trust-{unique}"));
427 fs::create_dir_all(&root).expect("fixture root");
428 let artifact = root.join("bundle.tar.gz");
429 let sbom = root.join("pharos.cdx.json");
430 fs::write(&artifact, b"artifact").expect("artifact");
431 fs::write(&sbom, b"{}").expect("SBOM");
432 let attacker_key = generate_private_key().expect("attacker key");
433 let trusted_key = generate_private_key().expect("trusted key");
434 let trusted_public_key = public_key_from_private(&trusted_key).expect("public key");
435 let attestation = sign_attestation(&SignAttestationRequest {
436 artifact_path: &artifact,
437 sbom_path: &sbom,
438 source_revision: "source",
439 cargo_lock_sha256: "lock",
440 toolchain: "rust",
441 build_configuration: "release",
442 app_contract_sha256: "contracts",
443 private_key_pkcs8: &attacker_key,
444 })
445 .expect("sign");
446 assert!(verify_attestation(&attestation, &artifact, &sbom, &trusted_public_key).is_err());
447 fs::remove_dir_all(root).expect("cleanup");
448 }
449
450 #[test]
451 fn app_contract_digest_is_stable_and_detects_source_drift() {
453 let unique = SystemTime::now()
454 .duration_since(UNIX_EPOCH)
455 .expect("clock")
456 .as_nanos();
457 let root = std::env::temp_dir().join(format!("pharos-contract-digest-{unique}"));
458 let app = root.join("example");
459 fs::create_dir_all(app.join("data/contracts")).expect("contracts");
460 fs::write(app.join("pharos.app.yml"), b"app_id: example\n").expect("manifest");
461 let contract = app.join("data/contracts/routes.yml");
462 fs::write(&contract, b"contract_version: 1.0\n").expect("contract");
463 let first = app_contract_set_sha256(&root).expect("first digest");
464 assert_eq!(
465 app_contract_set_sha256(&root).as_deref(),
466 Some(first.as_str())
467 );
468 fs::write(&contract, b"contract_version: 1.1\n").expect("mutate contract");
469 assert_ne!(
470 app_contract_set_sha256(&root).as_deref(),
471 Some(first.as_str())
472 );
473 fs::remove_dir_all(root).expect("cleanup");
474 }
475}