Skip to main content

actr_cli/commands/
pkg.rs

1//! `actr pkg` — local package operations (sign, verify, keygen).
2//!
3//! ## Subcommands
4//!
5//! ```text
6//! actr pkg sign     [--manifest-path FILE] [--key FILE] [--binary FILE]
7//! actr pkg verify   --package FILE [--pubkey FILE]
8//! actr pkg keygen   [--output FILE] [--force]
9//! ```
10//!
11//! Remote registry operations (`publish`) live under `actr registry`.
12//! End-to-end build + package is a single top-level command: `actr build`.
13
14use std::path::PathBuf;
15
16use anyhow::{Context, Result};
17use async_trait::async_trait;
18use base64::Engine;
19use clap::{Args, Subcommand};
20use ed25519_dalek::SigningKey;
21
22use crate::commands::package_build::{
23    load_signing_key, load_verifying_key, load_verifying_key_from_dev_key, resolve_key_path,
24};
25use crate::core::{Command, CommandContext, CommandResult, ComponentType};
26
27#[derive(Args, Debug)]
28pub struct PkgArgs {
29    #[command(subcommand)]
30    pub command: PkgCommand,
31}
32
33#[derive(Subcommand, Debug)]
34pub enum PkgCommand {
35    /// Sign a manifest.toml manifest with an MFR private key (offline signing).
36    Sign(PkgSignArgs),
37    /// Verify a signed .actr package.
38    Verify(PkgVerifyArgs),
39    /// Generate an Ed25519 MFR signing key pair.
40    Keygen(PkgKeygenArgs),
41}
42
43#[derive(Args, Debug)]
44pub struct PkgSignArgs {
45    /// Path to manifest.toml
46    #[arg(
47        long = "manifest-path",
48        short = 'm',
49        default_value = "manifest.toml",
50        value_name = "FILE"
51    )]
52    pub manifest_path: PathBuf,
53
54    /// Path to MFR signing key file (overrides config mfr.keychain)
55    #[arg(long, short = 'k', value_name = "FILE")]
56    pub key: Option<PathBuf>,
57
58    /// Path to actor binary (for hash computation)
59    #[arg(long, short = 'b', value_name = "FILE")]
60    pub binary: Option<PathBuf>,
61
62    /// Target platform (e.g. wasm32-wasip1, x86_64-unknown-linux-gnu)
63    #[arg(long, short = 't', default_value = "wasm32-wasip1")]
64    pub target: String,
65
66    /// Output signature file (default: manifest.sig)
67    #[arg(long, short = 'o', value_name = "FILE")]
68    pub output: Option<PathBuf>,
69}
70
71#[derive(Args, Debug)]
72pub struct PkgVerifyArgs {
73    /// .actr package file to verify
74    #[arg(long, short = 'p', value_name = "FILE")]
75    pub package: PathBuf,
76
77    /// Public key file (default: derive from config mfr.keychain)
78    #[arg(long, value_name = "FILE")]
79    pub pubkey: Option<PathBuf>,
80}
81
82#[derive(Args, Debug)]
83pub struct PkgKeygenArgs {
84    /// Key output path (default: ~/.actr/dev-key.json)
85    #[arg(long, short = 'o', value_name = "FILE")]
86    pub output: Option<PathBuf>,
87    /// Force overwrite existing key
88    #[arg(long)]
89    pub force: bool,
90}
91
92#[async_trait]
93impl Command for PkgArgs {
94    async fn execute(&self, _ctx: &CommandContext) -> Result<CommandResult> {
95        let cli_config = crate::config::resolver::resolve_effective_cli_config()?;
96        let keychain_ref = cli_config.mfr.keychain.as_deref();
97
98        match &self.command {
99            PkgCommand::Sign(a) => execute_sign(a, keychain_ref).await?,
100            PkgCommand::Verify(a) => execute_verify(a, keychain_ref).await?,
101            PkgCommand::Keygen(a) => execute_keygen(a)?,
102        }
103        Ok(CommandResult::Success(String::new()))
104    }
105
106    fn required_components(&self) -> Vec<ComponentType> {
107        vec![]
108    }
109
110    fn name(&self) -> &str {
111        "pkg"
112    }
113
114    fn description(&self) -> &str {
115        "Local package operations (sign, verify, keygen)"
116    }
117}
118
119// ── keygen ───────────────────────────────────────────────────────────────────
120
121fn execute_keygen(args: &PkgKeygenArgs) -> Result<()> {
122    let key_path = match args.output {
123        Some(ref path) => path.clone(),
124        None => {
125            let home = dirs::home_dir()
126                .ok_or_else(|| anyhow::anyhow!("Unable to determine home directory"))?;
127            home.join(".actr").join("dev-key.json")
128        }
129    };
130
131    if key_path.exists() && !args.force {
132        anyhow::bail!(
133            "Key file already exists: {}\nUse --force to overwrite, or --output to specify a different path.",
134            key_path.display()
135        );
136    }
137
138    if let Some(parent) = key_path.parent() {
139        std::fs::create_dir_all(parent)
140            .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
141    }
142
143    let signing_key = SigningKey::generate(&mut rand::rngs::OsRng);
144    let verifying_key = signing_key.verifying_key();
145
146    let private_b64 = base64::engine::general_purpose::STANDARD.encode(signing_key.to_bytes());
147    let public_b64 = base64::engine::general_purpose::STANDARD.encode(verifying_key.to_bytes());
148
149    let now = chrono::Utc::now().to_rfc3339();
150    let key_json = serde_json::json!({
151        "private_key": private_b64,
152        "public_key": public_b64,
153        "created_at": now,
154        "note": "Development signing key, for StaticTrust use only, not for production use"
155    });
156
157    let json_str = serde_json::to_string_pretty(&key_json)?;
158    std::fs::write(&key_path, &json_str)
159        .with_context(|| format!("Failed to write key file: {}", key_path.display()))?;
160
161    #[cfg(unix)]
162    {
163        use std::os::unix::fs::PermissionsExt;
164        let perms = std::fs::Permissions::from_mode(0o600);
165        std::fs::set_permissions(&key_path, perms).ok();
166    }
167
168    println!("Key pair generated: {}", key_path.display());
169    println!();
170    println!("Public key (base64, Ed25519 verifying key — 32 bytes):");
171    println!("  {}", public_b64);
172    println!();
173    println!("Use it as a StaticTrust anchor in your actr.toml:");
174    println!("  [[trust]]");
175    println!("  kind = \"static\"");
176    println!("  pubkey_b64 = \"{}\"", public_b64);
177    println!();
178    println!("Or reference a public-key.json next to the .actr package:");
179    println!("  [[trust]]");
180    println!("  kind = \"static\"");
181    println!("  pubkey_file = \"public-key.json\"");
182
183    let global_path = crate::config::loader::global_config_path()?;
184    let mut global_config =
185        crate::config::loader::load_cli_config(&global_path)?.unwrap_or_default();
186    global_config.mfr.keychain = Some(key_path.display().to_string());
187    if let Some(parent) = global_path.parent() {
188        std::fs::create_dir_all(parent)
189            .with_context(|| format!("Failed to create {}", parent.display()))?;
190    }
191    let content =
192        toml::to_string_pretty(&global_config).with_context(|| "Failed to serialize config")?;
193    std::fs::write(&global_path, content)
194        .with_context(|| format!("Failed to write {}", global_path.display()))?;
195    println!();
196    println!(
197        "✅ Global config updated: mfr.keychain = {}",
198        key_path.display()
199    );
200
201    Ok(())
202}
203
204// ── sign ─────────────────────────────────────────────────────────────────────
205//
206// Parses manifest.toml, reads binary + proto files, builds a PackageManifest,
207// serializes via to_toml(), and signs with Ed25519.
208//
209// Output:
210//   1. Canonical manifest.toml — the exact bytes that were signed
211//   2. manifest.sig — 64 bytes raw Ed25519 signature
212//
213// The signed content is byte-level identical to what `actr build` produces.
214
215async fn execute_sign(args: &PkgSignArgs, config_keychain: Option<&str>) -> Result<()> {
216    use ed25519_dalek::Signer;
217    use sha2::{Digest, Sha256};
218    use std::io::Write;
219
220    let key_path = resolve_key_path(args.key.as_deref(), config_keychain)?;
221    let signing_key = load_signing_key(&key_path)?;
222    let verifying_key = signing_key.verifying_key();
223    let key_id = actr_pack::compute_key_id(&verifying_key.to_bytes());
224
225    let config_path = &args.manifest_path;
226    if !config_path.exists() {
227        return Err(anyhow::anyhow!(
228            "manifest.toml not found: {}",
229            config_path.display()
230        ));
231    }
232    let config_bytes = std::fs::read(config_path)?;
233    let config_value: toml::Value =
234        toml::from_slice(&config_bytes).with_context(|| "Invalid manifest.toml")?;
235    let pkg = config_value
236        .get("package")
237        .ok_or_else(|| anyhow::anyhow!("manifest.toml missing [package] section"))?;
238
239    let get_str = |key: &str| -> Result<String> {
240        pkg.get(key)
241            .and_then(|v| v.as_str())
242            .map(|s| s.to_string())
243            .ok_or_else(|| anyhow::anyhow!("manifest.toml [package].{key} missing"))
244    };
245
246    let manufacturer = get_str("manufacturer")?;
247    let name = get_str("name")?;
248    let version = get_str("version")?;
249
250    let (binary_hash, binary_size) = if let Some(binary_path) = &args.binary {
251        let binary_data = std::fs::read(binary_path)
252            .with_context(|| format!("Failed to read binary: {}", binary_path.display()))?;
253        let hash = Sha256::digest(&binary_data);
254        println!(
255            "  binary:    {} ({} bytes)",
256            binary_path.display(),
257            binary_data.len()
258        );
259        (hex::encode(hash), Some(binary_data.len() as u64))
260    } else {
261        (String::new(), None)
262    };
263
264    let config_dir = args
265        .manifest_path
266        .parent()
267        .unwrap_or_else(|| std::path::Path::new("."));
268    let mut proto_entries = vec![];
269    let exports = pkg
270        .get("exports")
271        .and_then(|e| e.as_array())
272        .or_else(|| config_value.get("exports").and_then(|e| e.as_array()));
273    if let Some(exports) = exports {
274        for export_entry in exports {
275            if let Some(proto_path_str) = export_entry.as_str() {
276                let proto_path = config_dir.join(proto_path_str);
277                match std::fs::read(&proto_path) {
278                    Ok(content) => {
279                        let filename = proto_path
280                            .file_name()
281                            .and_then(|n| n.to_str())
282                            .unwrap_or("unknown.proto")
283                            .to_string();
284                        let hash = hex::encode(Sha256::digest(&content));
285                        println!("  proto:     {} (hash: {}...)", filename, &hash[..16]);
286                        proto_entries.push(actr_pack::ProtoFileEntry {
287                            name: filename.clone(),
288                            path: format!("proto/{}", filename),
289                            hash,
290                        });
291                    }
292                    Err(e) => {
293                        tracing::warn!("Failed to read proto file {:?}: {}", proto_path, e);
294                    }
295                }
296            }
297        }
298    }
299
300    let manifest = actr_pack::PackageManifest {
301        manufacturer: manufacturer.clone(),
302        name: name.clone(),
303        version: version.clone(),
304        binary: actr_pack::BinaryEntry {
305            path: "bin/actor.wasm".to_string(),
306            target: args.target.clone(),
307            hash: binary_hash,
308            size: binary_size,
309            // Default to Component for wasm32-wasip2, leave unset otherwise
310            // so the legacy-default resolver can apply.
311            kind: (args.target == "wasm32-wasip2").then_some(actr_pack::BinaryKind::Component),
312        },
313        signature_algorithm: "ed25519".to_string(),
314        signing_key_id: Some(key_id.clone()),
315        resources: vec![],
316        proto_files: proto_entries,
317        lock_file: None,
318        metadata: actr_pack::ManifestMetadata {
319            description: pkg
320                .get("description")
321                .and_then(|v| v.as_str())
322                .map(|s| s.to_string()),
323            license: pkg
324                .get("license")
325                .and_then(|v| v.as_str())
326                .map(|s| s.to_string()),
327        },
328    };
329
330    let manifest_toml = manifest
331        .to_toml()
332        .map_err(|e| anyhow::anyhow!("Failed to serialize manifest: {e}"))?;
333    let manifest_bytes = manifest_toml.as_bytes();
334
335    let signature = signing_key.sign(manifest_bytes);
336    let sig_bytes = signature.to_bytes();
337
338    let manifest_path = {
339        let mut p = args.manifest_path.clone();
340        p.set_file_name("manifest.toml");
341        p
342    };
343    std::fs::write(&manifest_path, manifest_bytes)
344        .with_context(|| format!("Failed to write manifest: {}", manifest_path.display()))?;
345
346    let sig_path = args.output.clone().unwrap_or_else(|| {
347        let mut p = args.manifest_path.clone();
348        p.set_file_name("manifest.sig");
349        p
350    });
351    {
352        let mut f = std::fs::File::create(&sig_path)?;
353        f.write_all(&sig_bytes)?;
354    }
355
356    println!("✅ Manifest signed successfully");
357    println!("  manifest:  {} (signed content)", manifest_path.display());
358    println!("  sig file:  {} (64 bytes raw Ed25519)", sig_path.display());
359    println!("  key_id:    {}", key_id);
360    println!("  actr_type: {}:{}:{}", manufacturer, name, version);
361    println!("  target:    {}", args.target);
362
363    Ok(())
364}
365
366// ── verify ───────────────────────────────────────────────────────────────────
367
368async fn execute_verify(args: &PkgVerifyArgs, config_keychain: Option<&str>) -> Result<()> {
369    let package_bytes = std::fs::read(&args.package)
370        .with_context(|| format!("Failed to read package: {}", args.package.display()))?;
371
372    let pubkey = if let Some(pubkey_path) = &args.pubkey {
373        load_verifying_key(pubkey_path)?
374    } else {
375        let key_path = resolve_key_path(None, config_keychain)?;
376        load_verifying_key_from_dev_key(&key_path)?
377    };
378
379    let verified = actr_pack::verify(&package_bytes, &pubkey)?;
380
381    if let Some(ref manifest_key_id) = verified.manifest.signing_key_id {
382        let expected_key_id = actr_pack::compute_key_id(&pubkey.to_bytes());
383        if manifest_key_id != &expected_key_id {
384            anyhow::bail!(
385                "signing_key_id mismatch: manifest says '{}' but the provided public key fingerprint is '{}'. \
386                 This package will fail verification in Production mode. \
387                 Rebuild with 'actr build' using the correct signing key.",
388                manifest_key_id,
389                expected_key_id,
390            );
391        }
392    } else {
393        anyhow::bail!(
394            "Package manifest has no 'signing_key_id'. \
395             This package will be rejected in Production mode. \
396             Rebuild with the latest 'actr build' to embed a signing_key_id."
397        );
398    }
399
400    println!("Package verification passed");
401    println!();
402    println!("  manufacturer: {}", verified.manifest.manufacturer);
403    println!("  type:         {}", verified.manifest.actr_type_str());
404    println!("  binary:       {}", verified.manifest.binary.path);
405    println!(
406        "  binary_hash:  {}...",
407        &verified.manifest.binary.hash[..16]
408    );
409    println!("  target:       {}", verified.manifest.binary.target);
410    if let Some(ref key_id) = verified.manifest.signing_key_id {
411        println!("  signing_key:  {}", key_id);
412    }
413    if !verified.manifest.resources.is_empty() {
414        println!("  resources:    {}", verified.manifest.resources.len());
415    }
416
417    Ok(())
418}