1use 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(PkgSignArgs),
37 Verify(PkgVerifyArgs),
39 Keygen(PkgKeygenArgs),
41}
42
43#[derive(Args, Debug)]
44pub struct PkgSignArgs {
45 #[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 #[arg(long, short = 'k', value_name = "FILE")]
56 pub key: Option<PathBuf>,
57
58 #[arg(long, short = 'b', value_name = "FILE")]
60 pub binary: Option<PathBuf>,
61
62 #[arg(long, short = 't', default_value = "wasm32-wasip1")]
64 pub target: String,
65
66 #[arg(long, short = 'o', value_name = "FILE")]
68 pub output: Option<PathBuf>,
69}
70
71#[derive(Args, Debug)]
72pub struct PkgVerifyArgs {
73 #[arg(long, short = 'p', value_name = "FILE")]
75 pub package: PathBuf,
76
77 #[arg(long, value_name = "FILE")]
79 pub pubkey: Option<PathBuf>,
80}
81
82#[derive(Args, Debug)]
83pub struct PkgKeygenArgs {
84 #[arg(long, short = 'o', value_name = "FILE")]
86 pub output: Option<PathBuf>,
87 #[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
119fn 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
204async 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 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
366async 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}