Skip to main content

pharos_runtime_ops/
provenance.rs

1//! Ed25519 release provenance with artifact and SBOM digest verification.
2
3use 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
12/// Stable release provenance schema.
13///
14/// # Examples
15///
16/// ```rust,ignore
17/// let _ = pharos_runtime_ops::provenance::PROVENANCE_SCHEMA;
18/// ```
19///
20/// # Related
21///
22/// - [`crate::provenance`] for the surrounding module surface.
23pub const PROVENANCE_SCHEMA: &str = "pharos.release-provenance.v1";
24
25/// Inputs required to create one signed release provenance statement.
26///
27/// # Fields
28///
29/// - `artifact_path`: Stored field on `SignAttestationRequest`.
30/// - `sbom_path`: Stored field on `SignAttestationRequest`.
31/// - `source_revision`: Stored field on `SignAttestationRequest`.
32/// - `cargo_lock_sha256`: Stored field on `SignAttestationRequest`.
33/// - `toolchain`: Stored field on `SignAttestationRequest`.
34/// - `build_configuration`: Stored field on `SignAttestationRequest`.
35/// - `app_contract_sha256`: Stored field on `SignAttestationRequest`.
36/// - `private_key_pkcs8`: Stored field on `SignAttestationRequest`.
37///
38/// # Examples
39///
40/// ```rust,ignore
41/// let _ = std::mem::size_of::<pharos_runtime_ops::provenance::SignAttestationRequest>();
42/// ```
43///
44/// # Related
45///
46/// - [`crate::provenance`] for constructors, helpers, and neighboring runtime types.
47pub 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
58/// Encodes bytes as lowercase hexadecimal without invoking an external tool.
59fn hex_encode(bytes: &[u8]) -> String {
60    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
61}
62
63/// Decodes strict even-length hexadecimal.
64fn 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
78/// Returns a SHA-256 digest for one bounded regular file.
79fn 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
97/// Hashes canonical app manifests and declarative contract sources in stable path order.
98///
99/// # Parameters
100///
101/// - `apps_root`: Input consumed by [`crate::provenance::app_contract_set_sha256`].
102///
103/// # Returns
104///
105/// Returns [`Some`] when [`crate::provenance::app_contract_set_sha256`] can produce a value and [`None`] otherwise.
106///
107/// # Performance
108///
109/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::provenance::app_contract_set_sha256`].
110///
111/// # Examples
112///
113/// ```rust,ignore
114/// let _result = pharos_runtime_ops::provenance::app_contract_set_sha256(todo!());
115/// ```
116///
117/// # Related
118///
119/// - [`crate::provenance`] for adjacent helpers that share the same runtime surface.
120pub 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
183/// Generates a new PKCS#8 Ed25519 private key for operator-controlled storage.
184///
185/// # Returns
186///
187/// Returns the successful value produced by [`crate::provenance::generate_private_key`] or an error when the operation fails.
188///
189/// # Errors
190///
191/// Returns an error value when [`crate::provenance::generate_private_key`] cannot complete successfully.
192///
193/// # Examples
194///
195/// ```rust,ignore
196/// let _result = pharos_runtime_ops::provenance::generate_private_key();
197/// ```
198///
199/// # Related
200///
201/// - [`crate::provenance`] for adjacent helpers that share the same runtime surface.
202pub 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
208/// Derives the raw Ed25519 public key that operators distribute as the verification trust anchor.
209///
210/// # Parameters
211///
212/// - `private_key_pkcs8`: Input consumed by [`crate::provenance::public_key_from_private`].
213///
214/// # Returns
215///
216/// Returns the successful value produced by [`crate::provenance::public_key_from_private`] or an error when the operation fails.
217///
218/// # Errors
219///
220/// Returns an error value when [`crate::provenance::public_key_from_private`] cannot complete successfully.
221///
222/// # Examples
223///
224/// ```rust,ignore
225/// let _result = pharos_runtime_ops::provenance::public_key_from_private(todo!());
226/// ```
227///
228/// # Related
229///
230/// - [`crate::provenance`] for adjacent helpers that share the same runtime surface.
231pub 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
237/// Signs artifact, source, dependency, toolchain, configuration, contract, and SBOM identity.
238///
239/// # Parameters
240///
241/// - `request`: Input consumed by [`crate::provenance::sign_attestation`].
242///
243/// # Returns
244///
245/// Returns the successful value produced by [`crate::provenance::sign_attestation`] or an error when the operation fails.
246///
247/// # Errors
248///
249/// Returns an error value when [`crate::provenance::sign_attestation`] cannot complete successfully.
250///
251/// # Examples
252///
253/// ```rust,ignore
254/// let _result = pharos_runtime_ops::provenance::sign_attestation(todo!());
255/// ```
256///
257/// # Related
258///
259/// - [`crate::provenance`] for adjacent helpers that share the same runtime surface.
260pub 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
291/// Verifies an Ed25519 attestation and the current artifact and SBOM digests.
292///
293/// # Parameters
294///
295/// - `attestation`: Input consumed by [`crate::provenance::verify_attestation`].
296/// - `artifact_path`: Input consumed by [`crate::provenance::verify_attestation`].
297/// - `sbom_path`: Input consumed by [`crate::provenance::verify_attestation`].
298/// - `trusted_public_key`: Input consumed by [`crate::provenance::verify_attestation`].
299/// - `) -> Result<(`: Input consumed by [`crate::provenance::verify_attestation`].
300///
301/// # Returns
302///
303/// Returns the successful value produced by [`crate::provenance::verify_attestation`] or an error when the operation fails.
304///
305/// # Errors
306///
307/// Returns an error value when [`crate::provenance::verify_attestation`] cannot complete successfully.
308///
309/// # Examples
310///
311/// ```rust,ignore
312/// let _result = pharos_runtime_ops::provenance::verify_attestation(todo!(), todo!(), todo!(), todo!(), todo!());
313/// ```
314///
315/// # Related
316///
317/// - [`crate::provenance`] for adjacent helpers that share the same runtime surface.
318pub 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    /// Performs signed attestation rejects artifact drift.
389    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    /// Performs signed attestation rejects an untrusted signing key.
421    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    /// Performs app contract digest is stable and detects source drift.
452    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}