Skip to main content

ggen_core/agent/
receipt.rs

1//! Authoritative pack-install provenance receipt emission.
2//!
3//! This is the single, root-parameterized implementation of pack-install
4//! receipt signing. It supersedes the cwd-implicit copy that previously lived in
5//! `ggen-cli` (`crates/ggen-cli/src/cmds/packs_receipt.rs`), which now delegates
6//! here. Consolidating the path means there is exactly one place that decides
7//! what a lawful pack receipt looks like — no drift between the CLI surface and
8//! the agent surface.
9//!
10//! Fail-closed by contract: a receipt is emitted only for an install that pinned
11//! a non-empty digest (lockfile invariant 4.1). An empty digest means nothing
12//! durable was installed, so there is nothing lawful to witness and emission is
13//! refused.
14
15use ed25519_dalek::{SecretKey, SigningKey, VerifyingKey};
16use std::fs;
17use std::path::{Path, PathBuf};
18
19/// Error type for pack receipt operations.
20#[derive(Debug)]
21pub enum PackReceiptError {
22    Runtime(String),
23}
24
25impl std::fmt::Display for PackReceiptError {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        match self {
28            PackReceiptError::Runtime(msg) => write!(f, "{}", msg),
29        }
30    }
31}
32
33impl std::error::Error for PackReceiptError {}
34
35/// Result type for pack receipt operations.
36pub type Result<T> = std::result::Result<T, PackReceiptError>;
37
38/// The witnessed closure of a pack installation.
39///
40/// This is the `O*` of `A = μ(O*)` for a pack install: every input capable of
41/// determining what was installed, plus the durable artifacts that resulted.
42/// [`emit_install_receipt`] binds ALL of these into the signed receipt so the
43/// receipt is a faithful proof object, not a decorative `hash(pack_id)`.
44pub struct PackInstallClosure<'a> {
45    /// Pack identifier (e.g. `acme/base`).
46    pub pack_id: &'a str,
47    /// Resolved pack version recorded in the lockfile.
48    pub pack_version: &'a str,
49    /// Non-empty SHA-256 (hex) digest pinned in `.ggen/packs.lock`
50    /// (`integrity` without the `sha256-` prefix). Binds the pack closure.
51    pub pack_digest: &'a str,
52    /// Packages declared by the pack and recorded as installed.
53    pub packages_installed: &'a [String],
54    /// Absolute paths of durable artifacts produced by the install (e.g. the
55    /// install dir and the lockfile). Their contents are hashed into
56    /// `output_hashes`.
57    pub artifact_paths: &'a [PathBuf],
58}
59
60/// Generates a cryptographic receipt for a SUCCESSFUL pack installation, rooted
61/// at `root` (the project directory whose `.ggen/` holds receipts and keys).
62///
63/// Fail-closed by contract: only invoked after an install succeeded and a
64/// non-empty lockfile digest exists. A FAILED install must NOT call this —
65/// emitting a receipt for work that did not happen is fail-open contract drift.
66///
67/// Closure binding:
68/// - `input_hashes` binds the actuator identity, the pack identity+version, the
69///   pack digest, and each declared package — the full pack closure.
70/// - `output_hashes` binds the real installed artifacts by hashing their on-disk
71///   contents (install dir manifest, lockfile), not the status string.
72///
73/// Returns an error (and writes nothing) if the digest is empty, mirroring the
74/// lockfile invariant: no digest means nothing was durably pinned, so there is
75/// nothing lawful to witness.
76pub fn emit_install_receipt(root: &Path, closure: &PackInstallClosure<'_>) -> Result<PathBuf> {
77    use crate::receipt::{hash_data, Receipt};
78
79    // Refuse to witness an install that pinned no digest — that is not a lawful,
80    // completed install (lockfile invariant 4.1).
81    if closure.pack_digest.trim().is_empty() {
82        return Err(PackReceiptError::Runtime(format!(
83            "Refusing to emit receipt for '{}': empty pack digest (no durable install to witness)",
84            closure.pack_id
85        )));
86    }
87
88    let receipts_dir = root.join(".ggen").join("receipts");
89    let keys_dir = root.join(".ggen").join("keys");
90
91    fs::create_dir_all(&receipts_dir).map_err(|e| {
92        PackReceiptError::Runtime(format!("Failed to create receipts directory: {}", e))
93    })?;
94    fs::create_dir_all(&keys_dir).map_err(|e| {
95        PackReceiptError::Runtime(format!("Failed to create keys directory: {}", e))
96    })?;
97
98    // Load or generate the Ed25519 keypair under the project root.
99    let private_key_path = keys_dir.join("private.pem");
100    let public_key_path = keys_dir.join("public.pem");
101
102    let (signing_key, _verifying_key) = if private_key_path.exists() {
103        load_keypair(&private_key_path)?
104    } else {
105        let (signing_key, verifying_key) = crate::receipt::generate_keypair();
106        save_keypair(
107            &signing_key,
108            &verifying_key,
109            &private_key_path,
110            &public_key_path,
111        )?;
112        (signing_key, verifying_key)
113    };
114
115    let timestamp = chrono::Utc::now();
116    let operation_id = format!(
117        "pack-install-{}-{}",
118        closure.pack_id,
119        timestamp.format("%Y%m%d-%H%M%S")
120    );
121
122    // input_hashes: bind the FULL pack closure (O*).
123    let mut input_hashes: Vec<String> = Vec::new();
124    input_hashes.push(format!(
125        "actuator:ggen-pack-install@{}",
126        env!("CARGO_PKG_VERSION")
127    ));
128    input_hashes.push(format!(
129        "pack:{}@{}:{}",
130        closure.pack_id, closure.pack_version, closure.pack_digest
131    ));
132    for package in closure.packages_installed {
133        input_hashes.push(format!("package:{}", package));
134    }
135
136    // output_hashes: bind the REAL installed artifacts by hashing on-disk
137    // contents. A path that cannot be read is recorded as MISSING (honest gap),
138    // never silently dropped.
139    let mut output_hashes: Vec<String> = Vec::new();
140    for path in closure.artifact_paths {
141        let display = path.display();
142        match read_artifact_bytes(path) {
143            Some(bytes) => output_hashes.push(format!("{}:{}", display, hash_data(&bytes))),
144            None => output_hashes.push(format!("{}:MISSING", display)),
145        }
146    }
147    // Guarantee a non-empty witnessed output even if no artifact paths were
148    // supplied: bind the pinned digest as the output of record.
149    if output_hashes.is_empty() {
150        output_hashes.push(format!("pack-digest:sha256-{}", closure.pack_digest));
151    }
152
153    let receipt = Receipt::new(operation_id, input_hashes, output_hashes, None)
154        .sign(&signing_key)
155        .map_err(|e| PackReceiptError::Runtime(format!("Failed to sign receipt: {}", e)))?;
156
157    let receipt_filename = format!(
158        "pack-{}-{}.json",
159        closure.pack_id,
160        timestamp.format("%Y%m%d-%H%M%S")
161    );
162    let receipt_path = receipts_dir.join(&receipt_filename);
163
164    let receipt_json = serde_json::to_string_pretty(&receipt)
165        .map_err(|e| PackReceiptError::Runtime(format!("Failed to serialize receipt: {}", e)))?;
166
167    fs::write(&receipt_path, receipt_json)
168        .map_err(|e| PackReceiptError::Runtime(format!("Failed to write receipt: {}", e)))?;
169
170    Ok(receipt_path)
171}
172
173/// Read an artifact's bytes for hashing.
174///
175/// For a directory, hashes a deterministic manifest of its entries' names and
176/// sizes (sorted) so the install directory contributes real, stable evidence.
177/// For a file, returns its raw bytes. Returns `None` if the path cannot be read.
178fn read_artifact_bytes(path: &Path) -> Option<Vec<u8>> {
179    let meta = fs::metadata(path).ok()?;
180    if meta.is_dir() {
181        let mut entries: Vec<String> = fs::read_dir(path)
182            .ok()?
183            .filter_map(|e| e.ok())
184            .map(|e| {
185                let name = e.file_name().to_string_lossy().into_owned();
186                let len = e.metadata().map(|m| m.len()).unwrap_or(0);
187                format!("{}:{}", name, len)
188            })
189            .collect();
190        entries.sort();
191        Some(entries.join("\n").into_bytes())
192    } else {
193        fs::read(path).ok()
194    }
195}
196
197/// Loads an existing Ed25519 keypair from the hex-encoded private key file.
198fn load_keypair(private_key_path: &Path) -> Result<(SigningKey, VerifyingKey)> {
199    use ed25519_dalek::SECRET_KEY_LENGTH;
200
201    let private_key_raw = fs::read(private_key_path)
202        .map_err(|e| PackReceiptError::Runtime(format!("Failed to read private key: {}", e)))?;
203    let private_key_hex = std::str::from_utf8(&private_key_raw)
204        .map_err(|_| PackReceiptError::Runtime("Private key file is not valid UTF-8".to_string()))?
205        .trim();
206    let private_key_bytes = hex::decode(private_key_hex).map_err(|e| {
207        PackReceiptError::Runtime(format!("Failed to hex-decode private key: {}", e))
208    })?;
209
210    if private_key_bytes.len() != SECRET_KEY_LENGTH {
211        return Err(PackReceiptError::Runtime(
212            "Invalid private key length".to_string(),
213        ));
214    }
215
216    let secret_key = SecretKey::try_from(private_key_bytes.as_slice())
217        .map_err(|_| PackReceiptError::Runtime("Invalid private key".to_string()))?;
218    let signing_key: SigningKey = secret_key.into();
219    let verifying_key = signing_key.verifying_key();
220
221    Ok((signing_key, verifying_key))
222}
223
224/// Saves an Ed25519 keypair as hex-encoded files.
225fn save_keypair(
226    signing_key: &SigningKey, verifying_key: &VerifyingKey, private_key_path: &Path,
227    public_key_path: &Path,
228) -> Result<()> {
229    let private_key_hex = hex::encode(signing_key.to_bytes());
230    fs::write(private_key_path, private_key_hex)
231        .map_err(|e| PackReceiptError::Runtime(format!("Failed to write private key: {}", e)))?;
232
233    let public_key_hex = hex::encode(verifying_key.to_bytes());
234    fs::write(public_key_path, public_key_hex)
235        .map_err(|e| PackReceiptError::Runtime(format!("Failed to write public key: {}", e)))?;
236
237    Ok(())
238}
239
240/// Verifies a provenance receipt at `receipt_path` against the public key stored
241/// under `root/.ggen/keys/public.pem`.
242///
243/// Returns `Ok((is_valid, operation_id, reason))`. This is fail-closed: a
244/// missing key, an unreadable/garbled receipt, or an empty signature all yield
245/// `is_valid == false` with a reason — never a spurious `true`.
246pub fn verify_install_receipt(
247    root: &Path, receipt_path: &Path,
248) -> (bool, Option<String>, Option<String>) {
249    use crate::receipt::Receipt;
250
251    let receipt_bytes = match fs::read(receipt_path) {
252        Ok(b) => b,
253        Err(e) => return (false, None, Some(format!("cannot read receipt: {}", e))),
254    };
255
256    let receipt: Receipt = match serde_json::from_slice(&receipt_bytes) {
257        Ok(r) => r,
258        Err(e) => return (false, None, Some(format!("malformed receipt: {}", e))),
259    };
260    let operation_id = Some(receipt.operation_id.clone());
261
262    if receipt.signature.trim().is_empty() {
263        return (false, operation_id, Some("empty signature".to_string()));
264    }
265
266    let public_key_path = root.join(".ggen").join("keys").join("public.pem");
267    let verifying_key = match load_verifying_key(&public_key_path) {
268        Ok(k) => k,
269        Err(e) => return (false, operation_id, Some(e)),
270    };
271
272    match receipt.verify(&verifying_key) {
273        Ok(()) => (true, operation_id, None),
274        Err(e) => (
275            false,
276            operation_id,
277            Some(format!("signature invalid: {}", e)),
278        ),
279    }
280}
281
282/// Loads the hex-encoded verifying key written by [`save_keypair`].
283fn load_verifying_key(public_key_path: &Path) -> std::result::Result<VerifyingKey, String> {
284    let raw = fs::read(public_key_path).map_err(|e| {
285        format!(
286            "cannot read public key {}: {}",
287            public_key_path.display(),
288            e
289        )
290    })?;
291    let hex_str = std::str::from_utf8(&raw)
292        .map_err(|_| "public key file is not valid UTF-8".to_string())?
293        .trim();
294    let bytes = hex::decode(hex_str).map_err(|e| format!("cannot hex-decode public key: {}", e))?;
295    let arr: [u8; 32] = bytes
296        .as_slice()
297        .try_into()
298        .map_err(|_| "public key is not 32 bytes".to_string())?;
299    VerifyingKey::from_bytes(&arr).map_err(|e| format!("invalid public key: {}", e))
300}