From d2220a136c6e9a884a2f93211ad51f3c25bc1440 Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Wed, 17 Dec 2025 10:13:46 -0500 Subject: [PATCH 01/12] Add commands for managing signing keys --- Cargo.lock | 359 +++++++++++++++++++++++++++- Cargo.toml | 5 + src/commands/mod.rs | 1 + src/commands/signing_keys/create.rs | 124 ++++++++++ src/commands/signing_keys/list.rs | 60 +++++ src/commands/signing_keys/mod.rs | 12 + src/commands/signing_keys/remove.rs | 57 +++++ src/main.rs | 45 ++++ src/utils/mod.rs | 1 + src/utils/signing_keys.rs | 280 ++++++++++++++++++++++ 10 files changed, 940 insertions(+), 4 deletions(-) create mode 100644 src/commands/signing_keys/create.rs create mode 100644 src/commands/signing_keys/list.rs create mode 100644 src/commands/signing_keys/mod.rs create mode 100644 src/commands/signing_keys/remove.rs create mode 100644 src/utils/signing_keys.rs diff --git a/Cargo.lock b/Cargo.lock index 962a977..3e4f6fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -101,23 +110,34 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "avocado-cli" version = "0.17.2" dependencies = [ "anyhow", + "base64", + "chrono", "clap", "directories", + "ed25519-dalek", "flate2", "futures-util", "indicatif", "libc", + "rand 0.8.5", "regex", "reqwest", "serde", "serde_json", "serde_yaml", "serial_test", + "sha2", "tar", "tempfile", "thiserror 1.0.69", @@ -134,12 +154,27 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" + [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -174,6 +209,20 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.5.53" @@ -233,6 +282,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -242,6 +312,43 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -255,6 +362,26 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "directories" version = "5.0.1" @@ -287,6 +414,31 @@ dependencies = [ "syn", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -315,6 +467,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filetime" version = "0.2.26" @@ -441,6 +599,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -587,6 +755,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -833,6 +1025,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -892,6 +1093,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "portable-atomic" version = "1.11.1" @@ -954,7 +1165,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.2", "ring", "rustc-hash", "rustls", @@ -995,14 +1206,35 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -1012,7 +1244,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", ] [[package]] @@ -1134,6 +1375,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.2" @@ -1209,6 +1459,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -1311,6 +1567,17 @@ dependencies = [ "syn", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1326,6 +1593,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.7" @@ -1354,6 +1630,16 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1677,6 +1963,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -1742,6 +2034,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -1885,12 +2183,65 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index 1698c04..967d3fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,11 @@ libc = "0.2" walkdir = "2.4" tempfile = "3.0" regex = "1.0" +ed25519-dalek = { version = "2.1", features = ["rand_core"] } +rand = "0.8" +base64 = "0.22" +sha2 = "0.10" +chrono = { version = "0.4", features = ["serde"] } [dev-dependencies] tokio-test = "0.4" diff --git a/src/commands/mod.rs b/src/commands/mod.rs index cfac0c3..3198f0a 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -8,4 +8,5 @@ pub mod install; pub mod provision; pub mod runtime; pub mod sdk; +pub mod signing_keys; pub mod upgrade; diff --git a/src/commands/signing_keys/create.rs b/src/commands/signing_keys/create.rs new file mode 100644 index 0000000..6735187 --- /dev/null +++ b/src/commands/signing_keys/create.rs @@ -0,0 +1,124 @@ +//! Create signing key command. + +use anyhow::Result; +use chrono::Utc; + +use crate::utils::signing_keys::{ + generate_keyid, generate_keypair, get_signing_keys_dir, is_pkcs11_uri, path_to_file_uri, + save_keypair, KeyEntry, KeysRegistry, +}; + +/// Command to create a new signing key or register an external key +pub struct SigningKeysCreateCommand { + /// Optional name for the key (defaults to keyid if not provided) + pub name: Option, + /// Optional PKCS#11 URI for hardware-backed keys + pub uri: Option, +} + +impl SigningKeysCreateCommand { + pub fn new(name: Option, uri: Option) -> Self { + Self { name, uri } + } + + pub fn execute(&self) -> Result<()> { + let mut registry = KeysRegistry::load()?; + + let (keyid, uri, key_type) = if let Some(pkcs11_uri) = &self.uri { + // Register an external PKCS#11 key + if !is_pkcs11_uri(pkcs11_uri) { + anyhow::bail!( + "Invalid URI: '{}'. Expected a pkcs11: URI (e.g., 'pkcs11:token=YubiKey;object=signing-key')", + pkcs11_uri + ); + } + + // For PKCS#11 keys, we generate a keyid from the URI itself + // since we don't have direct access to the public key + let keyid = generate_keyid_from_uri(pkcs11_uri); + (keyid, pkcs11_uri.clone(), "PKCS#11") + } else { + // Generate a new ed25519 keypair + let (signing_key, verifying_key) = generate_keypair(); + let keyid = generate_keyid(&verifying_key); + + // Save the keypair to disk + let key_path = save_keypair(&keyid, &signing_key, &verifying_key)?; + let uri = path_to_file_uri(&key_path); + + (keyid, uri, "file") + }; + + // Determine the name (use provided name or fall back to keyid) + let name = self.name.clone().unwrap_or_else(|| keyid.clone()); + + // Check if name already exists + if registry.get_key(&name).is_some() { + anyhow::bail!("A key with name '{}' already exists", name); + } + + // Create the key entry + let entry = KeyEntry { + keyid: keyid.clone(), + algorithm: "ed25519".to_string(), + created_at: Utc::now(), + uri: uri.clone(), + }; + + // Add to registry and save + registry.add_key(name.clone(), entry)?; + registry.save()?; + + // Print success message + println!("Created signing key:"); + println!(" Name: {}", name); + println!(" Key ID: {}", keyid); + println!(" Algorithm: ed25519"); + println!(" Type: {}", key_type); + + if key_type == "file" { + let keys_dir = get_signing_keys_dir()?; + println!(" Location: {}", keys_dir.display()); + } else { + println!(" URI: {}", uri); + } + + Ok(()) + } +} + +/// Generate a keyid from a PKCS#11 URI +/// Since we can't access the actual public key from PKCS#11 without additional libraries, +/// we generate a hash from the URI itself as an identifier +fn generate_keyid_from_uri(uri: &str) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(uri.as_bytes()); + let hash = hasher.finalize(); + format!("sha256-{}", hex_encode(&hash[..8])) +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_keyid_from_uri() { + let uri = "pkcs11:token=YubiKey;object=signing-key"; + let keyid = generate_keyid_from_uri(uri); + assert!(keyid.starts_with("sha256-")); + assert_eq!(keyid.len(), 7 + 16); // "sha256-" + 16 hex chars + } + + #[test] + fn test_generate_keyid_from_uri_deterministic() { + let uri = "pkcs11:token=YubiKey;object=signing-key"; + let keyid1 = generate_keyid_from_uri(uri); + let keyid2 = generate_keyid_from_uri(uri); + assert_eq!(keyid1, keyid2); + } +} diff --git a/src/commands/signing_keys/list.rs b/src/commands/signing_keys/list.rs new file mode 100644 index 0000000..d0e0ae5 --- /dev/null +++ b/src/commands/signing_keys/list.rs @@ -0,0 +1,60 @@ +//! List signing keys command. + +use anyhow::Result; + +use crate::utils::signing_keys::{is_file_uri, is_pkcs11_uri, KeysRegistry}; + +/// Command to list all registered signing keys +pub struct SigningKeysListCommand; + +impl SigningKeysListCommand { + pub fn new() -> Self { + Self + } + + pub fn execute(&self) -> Result<()> { + let registry = KeysRegistry::load()?; + + if registry.keys.is_empty() { + println!("No signing keys registered."); + println!(); + println!("Create a new key with: avocado signing-keys create [NAME]"); + return Ok(()); + } + + println!("Registered signing keys:"); + println!(); + + // Sort keys by name for consistent output + let mut keys: Vec<_> = registry.keys.iter().collect(); + keys.sort_by_key(|(name, _)| name.as_str()); + + for (name, entry) in keys { + let key_type = if is_file_uri(&entry.uri) { + "file" + } else if is_pkcs11_uri(&entry.uri) { + "pkcs11" + } else { + "unknown" + }; + + println!(" {}", name); + println!(" Key ID: {}", entry.keyid); + println!(" Algorithm: {}", entry.algorithm); + println!(" Type: {}", key_type); + println!( + " Created: {}", + entry.created_at.format("%Y-%m-%d %H:%M:%S UTC") + ); + println!(); + } + + Ok(()) + } +} + +impl Default for SigningKeysListCommand { + fn default() -> Self { + Self::new() + } +} diff --git a/src/commands/signing_keys/mod.rs b/src/commands/signing_keys/mod.rs new file mode 100644 index 0000000..333fec3 --- /dev/null +++ b/src/commands/signing_keys/mod.rs @@ -0,0 +1,12 @@ +//! Signing keys management commands. +//! +//! Provides commands for creating, listing, and removing signing keys +//! stored in the global avocado configuration. + +mod create; +mod list; +mod remove; + +pub use create::SigningKeysCreateCommand; +pub use list::SigningKeysListCommand; +pub use remove::SigningKeysRemoveCommand; diff --git a/src/commands/signing_keys/remove.rs b/src/commands/signing_keys/remove.rs new file mode 100644 index 0000000..14c633d --- /dev/null +++ b/src/commands/signing_keys/remove.rs @@ -0,0 +1,57 @@ +//! Remove signing key command. + +use anyhow::Result; + +use crate::utils::signing_keys::{delete_key_files, is_file_uri, KeysRegistry}; + +/// Command to remove a signing key from the registry and filesystem +pub struct SigningKeysRemoveCommand { + /// Name of the key to remove + pub name: String, +} + +impl SigningKeysRemoveCommand { + pub fn new(name: String) -> Self { + Self { name } + } + + pub fn execute(&self) -> Result<()> { + let mut registry = KeysRegistry::load()?; + + // Get the key entry before removing + let entry = registry.get_key(&self.name).cloned(); + + if entry.is_none() { + anyhow::bail!("No signing key found with name '{}'", self.name); + } + + let entry = entry.unwrap(); + + // Remove from registry + registry.remove_key(&self.name)?; + registry.save()?; + + // If it's a file-based key, delete the key files + if is_file_uri(&entry.uri) { + match delete_key_files(&entry.keyid) { + Ok(()) => { + println!("Removed signing key '{}'", self.name); + println!(" Key ID: {}", entry.keyid); + println!(" Deleted key files from disk"); + } + Err(e) => { + // Key was removed from registry, but file deletion failed + println!("Removed signing key '{}' from registry", self.name); + println!(" Warning: Failed to delete key files: {}", e); + } + } + } else { + // PKCS#11 key - just remove from registry + println!("Removed signing key '{}'", self.name); + println!(" Key ID: {}", entry.keyid); + println!(" Note: PKCS#11 key reference removed (hardware key unchanged)"); + } + + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index aa34e26..725b458 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,9 @@ use commands::sdk::{ SdkCleanCommand, SdkCompileCommand, SdkDepsCommand, SdkDnfCommand, SdkInstallCommand, SdkRunCommand, }; +use commands::signing_keys::{ + SigningKeysCreateCommand, SigningKeysListCommand, SigningKeysRemoveCommand, +}; use commands::upgrade::UpgradeCommand; #[derive(Parser)] @@ -231,6 +234,31 @@ enum Commands { #[arg(long = "dnf-arg", num_args = 1, allow_hyphen_values = true, action = clap::ArgAction::Append)] dnf_args: Option>, }, + /// Manage signing keys for extension and image signing + #[command(name = "signing-keys")] + SigningKeys { + #[command(subcommand)] + command: SigningKeysCommands, + }, +} + +#[derive(Subcommand)] +enum SigningKeysCommands { + /// Create a new signing key or register an external PKCS#11 key + Create { + /// Name for the key (defaults to key ID if not provided) + name: Option, + /// PKCS#11 URI for hardware-backed keys (e.g., 'pkcs11:token=YubiKey;object=signing-key') + #[arg(long)] + uri: Option, + }, + /// List all registered signing keys + List, + /// Remove a signing key + Remove { + /// Name of the key to remove + name: String, + }, } #[derive(Subcommand)] @@ -739,6 +767,23 @@ async fn main() -> Result<()> { deploy_cmd.execute().await?; Ok(()) } + Commands::SigningKeys { command } => match command { + SigningKeysCommands::Create { name, uri } => { + let cmd = SigningKeysCreateCommand::new(name, uri); + cmd.execute()?; + Ok(()) + } + SigningKeysCommands::List => { + let cmd = SigningKeysListCommand::new(); + cmd.execute()?; + Ok(()) + } + SigningKeysCommands::Remove { name } => { + let cmd = SigningKeysRemoveCommand::new(name); + cmd.execute()?; + Ok(()) + } + }, Commands::Runtime { command } => match command { RuntimeCommands::Install { runtime, diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 6c5c2da..8ef5938 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -2,5 +2,6 @@ pub mod config; pub mod container; pub mod interpolation; pub mod output; +pub mod signing_keys; pub mod target; pub mod volume; diff --git a/src/utils/signing_keys.rs b/src/utils/signing_keys.rs new file mode 100644 index 0000000..395dad1 --- /dev/null +++ b/src/utils/signing_keys.rs @@ -0,0 +1,280 @@ +//! Signing keys management utilities. +//! +//! Provides functionality for managing ed25519 signing keys in a global config location. +//! Supports both file-based keys and PKCS#11 URIs for hardware security modules. + +use anyhow::{Context, Result}; +use base64::prelude::*; +use chrono::{DateTime, Utc}; +use directories::ProjectDirs; +use ed25519_dalek::{SigningKey, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +/// Registry file name for storing key metadata +const KEYS_REGISTRY_FILE: &str = "keys.json"; + +/// Subdirectory name for signing keys within the avocado config +const SIGNING_KEYS_DIR: &str = "signing-keys"; + +/// Represents a single signing key entry in the registry +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeyEntry { + /// Unique key identifier (SHA-256 hash of public key) + pub keyid: String, + /// Cryptographic algorithm used (always "ed25519" for now) + pub algorithm: String, + /// Timestamp when the key was created/registered + pub created_at: DateTime, + /// URI pointing to the key (file:// or pkcs11:) + pub uri: String, +} + +/// Global signing keys registry +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct KeysRegistry { + /// Map of key names to their metadata + pub keys: HashMap, +} + +impl KeysRegistry { + /// Load the registry from disk, creating an empty one if it doesn't exist + pub fn load() -> Result { + let registry_path = get_registry_path()?; + + if !registry_path.exists() { + return Ok(Self::default()); + } + + let contents = fs::read_to_string(®istry_path).with_context(|| { + format!("Failed to read registry file: {}", registry_path.display()) + })?; + + serde_json::from_str(&contents) + .with_context(|| format!("Failed to parse registry file: {}", registry_path.display())) + } + + /// Save the registry to disk + pub fn save(&self) -> Result<()> { + let registry_path = get_registry_path()?; + + // Ensure parent directory exists + if let Some(parent) = registry_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory: {}", parent.display()))?; + } + + let contents = + serde_json::to_string_pretty(self).context("Failed to serialize registry")?; + + fs::write(®istry_path, contents) + .with_context(|| format!("Failed to write registry file: {}", registry_path.display())) + } + + /// Add a new key entry to the registry + pub fn add_key(&mut self, name: String, entry: KeyEntry) -> Result<()> { + if self.keys.contains_key(&name) { + anyhow::bail!("A key with name '{}' already exists", name); + } + self.keys.insert(name, entry); + Ok(()) + } + + /// Remove a key entry from the registry + pub fn remove_key(&mut self, name: &str) -> Result { + self.keys + .remove(name) + .ok_or_else(|| anyhow::anyhow!("No key found with name '{}'", name)) + } + + /// Get a key entry by name + pub fn get_key(&self, name: &str) -> Option<&KeyEntry> { + self.keys.get(name) + } +} + +/// Get the base directory for avocado global config +pub fn get_avocado_config_dir() -> Result { + ProjectDirs::from("", "", "avocado") + .map(|dirs| dirs.config_dir().to_path_buf()) + .ok_or_else(|| anyhow::anyhow!("Could not determine config directory for your platform")) +} + +/// Get the directory for storing signing keys +pub fn get_signing_keys_dir() -> Result { + let config_dir = get_avocado_config_dir()?; + Ok(config_dir.join(SIGNING_KEYS_DIR)) +} + +/// Get the path to the keys registry file +pub fn get_registry_path() -> Result { + let keys_dir = get_signing_keys_dir()?; + Ok(keys_dir.join(KEYS_REGISTRY_FILE)) +} + +/// Get the path for a key file (without extension) +pub fn get_key_file_path(keyid: &str) -> Result { + let keys_dir = get_signing_keys_dir()?; + Ok(keys_dir.join(keyid)) +} + +/// Generate a key ID from a public key (SHA-256 hash, first 16 hex chars) +pub fn generate_keyid(public_key: &VerifyingKey) -> String { + let mut hasher = Sha256::new(); + hasher.update(public_key.as_bytes()); + let hash = hasher.finalize(); + format!("sha256-{}", hex::encode(&hash[..8])) +} + +/// Generate a new ed25519 keypair +pub fn generate_keypair() -> (SigningKey, VerifyingKey) { + let mut rng = rand::thread_rng(); + let signing_key = SigningKey::generate(&mut rng); + let verifying_key = signing_key.verifying_key(); + (signing_key, verifying_key) +} + +/// Save a keypair to disk +pub fn save_keypair( + keyid: &str, + signing_key: &SigningKey, + verifying_key: &VerifyingKey, +) -> Result { + let keys_dir = get_signing_keys_dir()?; + fs::create_dir_all(&keys_dir).with_context(|| { + format!( + "Failed to create signing keys directory: {}", + keys_dir.display() + ) + })?; + + let base_path = get_key_file_path(keyid)?; + let private_key_path = base_path.with_extension("key"); + let public_key_path = base_path.with_extension("pub"); + + // Save private key (base64 encoded) + let private_key_b64 = BASE64_STANDARD.encode(signing_key.to_bytes()); + fs::write(&private_key_path, &private_key_b64).with_context(|| { + format!( + "Failed to write private key: {}", + private_key_path.display() + ) + })?; + + // Set restrictive permissions on private key (Unix only) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let permissions = std::fs::Permissions::from_mode(0o600); + fs::set_permissions(&private_key_path, permissions).with_context(|| { + format!( + "Failed to set permissions on private key: {}", + private_key_path.display() + ) + })?; + } + + // Save public key (base64 encoded) + let public_key_b64 = BASE64_STANDARD.encode(verifying_key.as_bytes()); + fs::write(&public_key_path, &public_key_b64) + .with_context(|| format!("Failed to write public key: {}", public_key_path.display()))?; + + Ok(base_path) +} + +/// Delete key files from disk +pub fn delete_key_files(keyid: &str) -> Result<()> { + let base_path = get_key_file_path(keyid)?; + let private_key_path = base_path.with_extension("key"); + let public_key_path = base_path.with_extension("pub"); + + // Remove private key if it exists + if private_key_path.exists() { + fs::remove_file(&private_key_path).with_context(|| { + format!( + "Failed to delete private key: {}", + private_key_path.display() + ) + })?; + } + + // Remove public key if it exists + if public_key_path.exists() { + fs::remove_file(&public_key_path).with_context(|| { + format!("Failed to delete public key: {}", public_key_path.display()) + })?; + } + + Ok(()) +} + +/// Check if a URI is a file:// URI +pub fn is_file_uri(uri: &str) -> bool { + uri.starts_with("file://") +} + +/// Check if a URI is a pkcs11: URI +pub fn is_pkcs11_uri(uri: &str) -> bool { + uri.starts_with("pkcs11:") +} + +/// Create a file:// URI from a path +pub fn path_to_file_uri(path: &PathBuf) -> String { + format!("file://{}", path.display()) +} + +// Add hex encoding since we need it for keyid generation +mod hex { + pub fn encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_keyid() { + let (_, verifying_key) = generate_keypair(); + let keyid = generate_keyid(&verifying_key); + assert!(keyid.starts_with("sha256-")); + assert_eq!(keyid.len(), 7 + 16); // "sha256-" + 16 hex chars + } + + #[test] + fn test_is_file_uri() { + assert!(is_file_uri("file:///path/to/key")); + assert!(!is_file_uri("pkcs11:token=YubiKey")); + assert!(!is_file_uri("/path/to/key")); + } + + #[test] + fn test_is_pkcs11_uri() { + assert!(is_pkcs11_uri("pkcs11:token=YubiKey")); + assert!(!is_pkcs11_uri("file:///path/to/key")); + assert!(!is_pkcs11_uri("/path/to/key")); + } + + #[test] + fn test_registry_serialization() { + let mut registry = KeysRegistry::default(); + registry.keys.insert( + "test-key".to_string(), + KeyEntry { + keyid: "sha256-abcd1234abcd1234".to_string(), + algorithm: "ed25519".to_string(), + created_at: Utc::now(), + uri: "file:///path/to/key".to_string(), + }, + ); + + let json = serde_json::to_string(®istry).unwrap(); + let parsed: KeysRegistry = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.keys.len(), 1); + assert!(parsed.keys.contains_key("test-key")); + } +} From 951cd8bf4d5be795f73e6ce9ebc65e0ec5230ffd Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Wed, 17 Dec 2025 10:24:47 -0500 Subject: [PATCH 02/12] update config to reference signing keys --- src/commands/signing_keys/mod.rs | 5 ++ src/utils/config.rs | 73 +++++++++++++++++++ src/utils/signing_keys.rs | 64 +++++++++++++++- src/utils/target.rs | 3 + tests/fixtures/configs/with-signing-keys.yaml | 12 +++ 5 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/configs/with-signing-keys.yaml diff --git a/src/commands/signing_keys/mod.rs b/src/commands/signing_keys/mod.rs index 333fec3..bafe7c0 100644 --- a/src/commands/signing_keys/mod.rs +++ b/src/commands/signing_keys/mod.rs @@ -7,6 +7,11 @@ mod create; mod list; mod remove; +// These exports are used by the binary target (main.rs) but not the library target, +// which causes clippy warnings in the lib build. We allow unused_imports here. +#[allow(unused_imports)] pub use create::SigningKeysCreateCommand; +#[allow(unused_imports)] pub use list::SigningKeysListCommand; +#[allow(unused_imports)] pub use remove::SigningKeysRemoveCommand; diff --git a/src/utils/config.rs b/src/utils/config.rs index 6b7a435..aefb947 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -167,6 +167,13 @@ pub struct DistroConfig { pub version: Option, } +/// Signing key reference in configuration +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SigningKeyRef { + /// Name of the signing key (as registered in global signing keys) + pub key: String, +} + /// Supported targets configuration - can be either "*" (all targets) or a list of specific targets #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] @@ -185,6 +192,8 @@ pub struct Config { pub runtime: Option>, pub sdk: Option, pub provision: Option>, + /// Signing keys referenced by this configuration + pub signing_keys: Option>, } impl Config { @@ -698,6 +707,21 @@ impl Config { .unwrap_or(false) // Default to false (enable weak dependencies) } + /// Get signing keys referenced in this configuration + #[allow(dead_code)] // Public API for future use + pub fn get_signing_keys(&self) -> Option<&Vec> { + self.signing_keys.as_ref() + } + + /// Get signing key names as a list of strings + #[allow(dead_code)] // Public API for future use + pub fn get_signing_key_names(&self) -> Vec { + self.signing_keys + .as_ref() + .map(|keys| keys.iter().map(|k| k.key.clone()).collect()) + .unwrap_or_default() + } + /// Get provision profile configuration pub fn get_provision_profile(&self, profile_name: &str) -> Option<&ProvisionProfileConfig> { self.provision.as_ref()?.get(profile_name) @@ -4517,4 +4541,53 @@ sdk: assert_eq!(args[8], "--name"); assert_eq!(args[9], "my-container"); } + + #[test] + fn test_signing_keys_parsing() { + let config_content = r#" +default_target: qemux86-64 + +sdk: + image: ghcr.io/avocado-framework/avocado-sdk:latest + +signing_keys: + - key: my-production-key + - key: backup-key +"#; + + let config = Config::load_from_yaml_str(config_content).unwrap(); + + // Test that signing_keys is parsed correctly + let signing_keys = config.get_signing_keys(); + assert!(signing_keys.is_some()); + let signing_keys = signing_keys.unwrap(); + assert_eq!(signing_keys.len(), 2); + assert_eq!(signing_keys[0].key, "my-production-key"); + assert_eq!(signing_keys[1].key, "backup-key"); + + // Test get_signing_key_names helper + let key_names = config.get_signing_key_names(); + assert_eq!(key_names.len(), 2); + assert_eq!(key_names[0], "my-production-key"); + assert_eq!(key_names[1], "backup-key"); + } + + #[test] + fn test_signing_keys_empty() { + let config_content = r#" +default_target: qemux86-64 + +sdk: + image: ghcr.io/avocado-framework/avocado-sdk:latest +"#; + + let config = Config::load_from_yaml_str(config_content).unwrap(); + + // Test that signing_keys is None when not specified + assert!(config.get_signing_keys().is_none()); + + // Test get_signing_key_names returns empty vec when no keys + let key_names = config.get_signing_key_names(); + assert!(key_names.is_empty()); + } } diff --git a/src/utils/signing_keys.rs b/src/utils/signing_keys.rs index 395dad1..3037433 100644 --- a/src/utils/signing_keys.rs +++ b/src/utils/signing_keys.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// Registry file name for storing key metadata const KEYS_REGISTRY_FILE: &str = "keys.json"; @@ -222,10 +222,70 @@ pub fn is_pkcs11_uri(uri: &str) -> bool { } /// Create a file:// URI from a path -pub fn path_to_file_uri(path: &PathBuf) -> String { +pub fn path_to_file_uri(path: &Path) -> String { format!("file://{}", path.display()) } +/// Validate that all signing key names exist in the global registry +/// +/// Returns Ok(()) if all keys exist, or an error listing the missing keys +#[allow(dead_code)] // Public API for future use +pub fn validate_signing_keys(key_names: &[String]) -> Result<()> { + if key_names.is_empty() { + return Ok(()); + } + + let registry = KeysRegistry::load()?; + let missing: Vec<_> = key_names + .iter() + .filter(|name| !registry.keys.contains_key(*name)) + .collect(); + + if missing.is_empty() { + Ok(()) + } else { + anyhow::bail!( + "The following signing keys are referenced in the config but not found in the global registry: {}", + missing.iter().map(|s| format!("'{}'", s)).collect::>().join(", ") + ) + } +} + +/// Get key entries for a list of key names from the global registry +/// +/// Returns the key entries for the specified keys, or an error if any are missing +#[allow(dead_code)] // Public API for future use +pub fn get_key_entries(key_names: &[String]) -> Result> { + if key_names.is_empty() { + return Ok(Vec::new()); + } + + let registry = KeysRegistry::load()?; + let mut entries = Vec::new(); + let mut missing = Vec::new(); + + for name in key_names { + if let Some(entry) = registry.keys.get(name) { + entries.push((name.clone(), entry.clone())); + } else { + missing.push(name.clone()); + } + } + + if !missing.is_empty() { + anyhow::bail!( + "The following signing keys are not found in the global registry: {}", + missing + .iter() + .map(|s| format!("'{}'", s)) + .collect::>() + .join(", ") + ) + } + + Ok(entries) +} + // Add hex encoding since we need it for keyid generation mod hex { pub fn encode(bytes: &[u8]) -> String { diff --git a/src/utils/target.rs b/src/utils/target.rs index 38e9216..13109d0 100644 --- a/src/utils/target.rs +++ b/src/utils/target.rs @@ -238,6 +238,7 @@ mod tests { runtime: None, sdk: None, provision: None, + signing_keys: None, } } @@ -251,6 +252,7 @@ mod tests { runtime: None, sdk: None, provision: None, + signing_keys: None, } } @@ -264,6 +266,7 @@ mod tests { runtime: None, sdk: None, provision: None, + signing_keys: None, } } diff --git a/tests/fixtures/configs/with-signing-keys.yaml b/tests/fixtures/configs/with-signing-keys.yaml new file mode 100644 index 0000000..e4b7aad --- /dev/null +++ b/tests/fixtures/configs/with-signing-keys.yaml @@ -0,0 +1,12 @@ +default_target: qemux86-64 + +sdk: + image: ghcr.io/avocado-framework/avocado-sdk:latest + +signing_keys: + - key: my-production-key + - key: backup-key + +runtime: + default: + target: x86_64-unknown-linux-gnu From 9bb8ac6b1e18d508def2ee26b09b99582947dacd Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Wed, 17 Dec 2025 10:37:24 -0500 Subject: [PATCH 03/12] update runtime configs to associate signing keys --- examples/signing-keys-example.yaml | 45 +++++++ src/utils/config.rs | 114 +++++++++++++++--- tests/fixtures/configs/with-signing-keys.yaml | 7 +- 3 files changed, 147 insertions(+), 19 deletions(-) create mode 100644 examples/signing-keys-example.yaml diff --git a/examples/signing-keys-example.yaml b/examples/signing-keys-example.yaml new file mode 100644 index 0000000..f5f6db2 --- /dev/null +++ b/examples/signing-keys-example.yaml @@ -0,0 +1,45 @@ +# Example: Using signing keys with runtime configurations +# +# This example demonstrates how to: +# 1. Define a local mapping of signing keys (name -> key ID) +# 2. Reference those keys in runtime configurations +# +# The signing_keys section acts as a bridge between friendly names +# and the actual key IDs from the global signing keys registry. + +default_target: qemux86-64 + +sdk: + image: ghcr.io/avocado-framework/avocado-sdk:latest + +# Define signing keys with friendly names +# The key IDs (right side) should match keys in the global registry +# managed by `avocado signing-keys` commands +signing_keys: + - production-key: sha256-abc123def456 + - staging-key: sha256-789012fedcba + - backup-key: sha256-111222333444 + +runtime: + # Production runtime uses the production signing key + production: + dependencies: + avocado-img-bootfiles: "*" + avocado-img-rootfs: "*" + signing: + key: production-key + + # Staging runtime uses a different key + staging: + dependencies: + avocado-img-bootfiles: "*" + avocado-img-rootfs: "*" + signing: + key: staging-key + + # Development runtime with no signing + dev: + dependencies: + avocado-img-bootfiles: "*" + avocado-img-rootfs: "*" + # No signing configuration - unsigned builds diff --git a/src/utils/config.rs b/src/utils/config.rs index aefb947..3620645 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -124,6 +124,13 @@ pub enum ConfigError { IoError(#[from] std::io::Error), } +/// Signing configuration for runtime +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SigningConfig { + /// Name of the signing key to use (references a key from signing_keys section) + pub key: String, +} + /// Runtime configuration section #[derive(Debug, Clone, Deserialize, Serialize)] pub struct RuntimeConfig { @@ -131,6 +138,8 @@ pub struct RuntimeConfig { pub dependencies: Option>, pub stone_include_paths: Option>, pub stone_manifest: Option, + /// Signing configuration for this runtime + pub signing: Option, } /// SDK configuration section @@ -167,11 +176,34 @@ pub struct DistroConfig { pub version: Option, } -/// Signing key reference in configuration -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct SigningKeyRef { - /// Name of the signing key (as registered in global signing keys) - pub key: String, +/// Helper module for deserializing signing keys list +mod signing_keys_deserializer { + use serde::{Deserialize, Deserializer}; + use std::collections::HashMap; + + /// Deserialize signing_keys from a list of single-key maps + /// Example YAML: + /// ```yaml + /// signing_keys: + /// - my-key: sha256-abc123 + /// - other-key: sha256-def456 + /// ``` + pub fn deserialize<'de, D>(deserializer: D) -> Result>, D::Error> + where + D: Deserializer<'de>, + { + let list = Option::>>::deserialize(deserializer)?; + + Ok(list.map(|items| { + let mut result = HashMap::new(); + for item in items { + for (key, value) in item { + result.insert(key, value); + } + } + result + })) + } } /// Supported targets configuration - can be either "*" (all targets) or a list of specific targets @@ -192,8 +224,10 @@ pub struct Config { pub runtime: Option>, pub sdk: Option, pub provision: Option>, - /// Signing keys referenced by this configuration - pub signing_keys: Option>, + /// Signing keys mapping friendly names to key IDs + /// Acts as a local bridge between the config and the global signing keys registry + #[serde(default, deserialize_with = "signing_keys_deserializer::deserialize")] + pub signing_keys: Option>, } impl Config { @@ -707,21 +741,35 @@ impl Config { .unwrap_or(false) // Default to false (enable weak dependencies) } - /// Get signing keys referenced in this configuration + /// Get signing keys mapping (name -> keyid) #[allow(dead_code)] // Public API for future use - pub fn get_signing_keys(&self) -> Option<&Vec> { + pub fn get_signing_keys(&self) -> Option<&HashMap> { self.signing_keys.as_ref() } - /// Get signing key names as a list of strings + /// Get signing key ID by name + #[allow(dead_code)] // Public API for future use + pub fn get_signing_key_id(&self, name: &str) -> Option<&String> { + self.signing_keys.as_ref()?.get(name) + } + + /// Get all signing key names #[allow(dead_code)] // Public API for future use pub fn get_signing_key_names(&self) -> Vec { self.signing_keys .as_ref() - .map(|keys| keys.iter().map(|k| k.key.clone()).collect()) + .map(|keys| keys.keys().cloned().collect()) .unwrap_or_default() } + /// Get signing key for a specific runtime + #[allow(dead_code)] // Public API for future use + pub fn get_runtime_signing_key(&self, runtime_name: &str) -> Option { + let runtime_config = self.runtime.as_ref()?.get(runtime_name)?; + let signing_key_name = &runtime_config.signing.as_ref()?.key; + self.get_signing_key_id(signing_key_name).cloned() + } + /// Get provision profile configuration pub fn get_provision_profile(&self, profile_name: &str) -> Option<&ProvisionProfileConfig> { self.provision.as_ref()?.get(profile_name) @@ -4551,8 +4599,13 @@ sdk: image: ghcr.io/avocado-framework/avocado-sdk:latest signing_keys: - - key: my-production-key - - key: backup-key + - my-production-key: sha256-abc123def456 + - backup-key: sha256-789012fedcba + +runtime: + dev: + signing: + key: my-production-key "#; let config = Config::load_from_yaml_str(config_content).unwrap(); @@ -4562,14 +4615,41 @@ signing_keys: assert!(signing_keys.is_some()); let signing_keys = signing_keys.unwrap(); assert_eq!(signing_keys.len(), 2); - assert_eq!(signing_keys[0].key, "my-production-key"); - assert_eq!(signing_keys[1].key, "backup-key"); + assert_eq!( + signing_keys.get("my-production-key"), + Some(&"sha256-abc123def456".to_string()) + ); + assert_eq!( + signing_keys.get("backup-key"), + Some(&"sha256-789012fedcba".to_string()) + ); // Test get_signing_key_names helper let key_names = config.get_signing_key_names(); assert_eq!(key_names.len(), 2); - assert_eq!(key_names[0], "my-production-key"); - assert_eq!(key_names[1], "backup-key"); + assert!(key_names.contains(&"my-production-key".to_string())); + assert!(key_names.contains(&"backup-key".to_string())); + + // Test get_signing_key_id helper + assert_eq!( + config.get_signing_key_id("my-production-key"), + Some(&"sha256-abc123def456".to_string()) + ); + assert_eq!( + config.get_signing_key_id("backup-key"), + Some(&"sha256-789012fedcba".to_string()) + ); + assert_eq!(config.get_signing_key_id("nonexistent"), None); + + // Test runtime signing key reference + let runtime_key = config.get_runtime_signing_key("dev"); + assert_eq!(runtime_key, Some("sha256-abc123def456".to_string())); + + // Test runtime signing config + let runtime = config.runtime.as_ref().unwrap().get("dev").unwrap(); + assert!(runtime.signing.is_some()); + let signing = runtime.signing.as_ref().unwrap(); + assert_eq!(signing.key, "my-production-key"); } #[test] diff --git a/tests/fixtures/configs/with-signing-keys.yaml b/tests/fixtures/configs/with-signing-keys.yaml index e4b7aad..1f1c450 100644 --- a/tests/fixtures/configs/with-signing-keys.yaml +++ b/tests/fixtures/configs/with-signing-keys.yaml @@ -4,9 +4,12 @@ sdk: image: ghcr.io/avocado-framework/avocado-sdk:latest signing_keys: - - key: my-production-key - - key: backup-key + - my-production-key: sha256-abc123def456 + - backup-key: sha256-789012fedcba runtime: default: target: x86_64-unknown-linux-gnu + dev: + signing: + key: my-production-key From 0220c88cdbc0decb3c8aac0d08f9f0cb864edfdd Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Wed, 17 Dec 2025 13:07:44 -0500 Subject: [PATCH 04/12] sign runtime images --- Cargo.lock | 117 ++++- Cargo.toml | 2 + docs/signing-keys.md | 321 ++++++++++++ examples/signing-keys-example.yaml | 6 +- src/commands/runtime/build.rs | 481 +++++++++++++++++- src/utils/config.rs | 31 ++ src/utils/container.rs | 189 +++++++ src/utils/image_signing.rs | 440 ++++++++++++++++ src/utils/mod.rs | 1 + src/utils/signing_keys.rs | 9 + tests/fixtures/configs/with-signing-keys.yaml | 5 + tests/fixtures/test-signing-container.sh | 39 ++ 12 files changed, 1632 insertions(+), 9 deletions(-) create mode 100644 docs/signing-keys.md create mode 100644 src/utils/image_signing.rs create mode 100755 tests/fixtures/test-signing-container.sh diff --git a/Cargo.lock b/Cargo.lock index 3e4f6fc..6a854dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,18 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "async-stream" version = "0.3.6" @@ -122,8 +134,10 @@ version = "0.17.2" dependencies = [ "anyhow", "base64", + "blake3", "chrono", "clap", + "cryptoki", "directories", "ed25519-dalek", "flate2", @@ -160,12 +174,31 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "blake3" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -288,6 +321,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -322,6 +361,29 @@ dependencies = [ "typenum", ] +[[package]] +name = "cryptoki" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9123ecc6a29329cd3f852e6e6814f302ed777820e1eb60b098b89aee0eb91b" +dependencies = [ + "bitflags 1.3.2", + "cryptoki-sys", + "libloading", + "log", + "paste", + "secrecy", +] + +[[package]] +name = "cryptoki-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "750380200f47d4ff677be725b6e0d78b590e1d0343573dcd4b62147f25dc6efa" +dependencies = [ + "libloading", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -954,13 +1016,23 @@ version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + [[package]] name = "libredox" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags", + "bitflags 2.10.0", "libc", "redox_syscall", ] @@ -1075,6 +1147,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1271,7 +1349,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.10.0", ] [[package]] @@ -1390,7 +1468,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys", @@ -1459,6 +1537,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secrecy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" +dependencies = [ + "zeroize", +] + [[package]] name = "semver" version = "1.0.27" @@ -1914,7 +2001,7 @@ version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456" dependencies = [ - "bitflags", + "bitflags 2.10.0", "bytes", "futures-util", "http", @@ -2174,6 +2261,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2183,6 +2286,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 967d3fb..20e9a2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,7 +50,9 @@ ed25519-dalek = { version = "2.1", features = ["rand_core"] } rand = "0.8" base64 = "0.22" sha2 = "0.10" +blake3 = "1.5" chrono = { version = "0.4", features = ["serde"] } +cryptoki = "0.6" [dev-dependencies] tokio-test = "0.4" diff --git a/docs/signing-keys.md b/docs/signing-keys.md new file mode 100644 index 0000000..2328463 --- /dev/null +++ b/docs/signing-keys.md @@ -0,0 +1,321 @@ +# Signing Keys Configuration + +## Overview + +The avocado CLI supports managing signing keys for runtime image signing through two mechanisms: + +1. **Global Registry**: Keys stored in platform-specific config directories via `avocado signing-keys` commands +2. **Local Config Bridge**: Map friendly names to key IDs in your `avocado.yaml` config + +## Global Key Management + +### Creating Keys + +```bash +# Create a new ed25519 key with a name +avocado signing-keys create my-production-key + +# Create a key (defaults to key ID as name) +avocado signing-keys create + +# Register a hardware-backed PKCS#11 key +avocado signing-keys create yubikey-signing --uri "pkcs11:token=YubiKey;object=signing-key" +``` + +### Listing Keys + +```bash +avocado signing-keys list +``` + +Output: +``` +Registered signing keys: + + my-production-key + Key ID: sha256-7ca821b2d4ac87b3 + Algorithm: ed25519 + Type: file + Created: 2025-12-17 15:10:22 UTC +``` + +### Removing Keys + +```bash +avocado signing-keys remove my-production-key +``` + +## Configuration Format + +### Mapping Keys in avocado.yaml + +The `signing_keys` section creates a local mapping between friendly names and key IDs: + +```yaml +signing_keys: + - production-key: sha256-abc123def456 + - staging-key: sha256-789012fedcba + - backup-key: sha256-111222333444 +``` + +### Referencing Keys in Runtimes + +Each runtime can reference a signing key by name with optional checksum algorithm: + +```yaml +runtime: + production: + dependencies: + avocado-img-bootfiles: "*" + avocado-img-rootfs: "*" + signing: + key: production-key + checksum_algorithm: blake3 # Optional, defaults to sha256 + + staging: + signing: + key: staging-key + # checksum_algorithm defaults to sha256 if not specified + + dev: + # No signing configuration - unsigned builds + dependencies: + avocado-img-bootfiles: "*" +``` + +### Supported Checksum Algorithms + +- **sha256** (default): SHA-256 checksums +- **blake3**: BLAKE3 checksums (faster than SHA-256) + +## Complete Example + +```yaml +default_target: qemux86-64 + +sdk: + image: ghcr.io/avocado-framework/avocado-sdk:latest + +# Map friendly names to key IDs from global registry +signing_keys: + - production-key: sha256-abc123def456 + - staging-key: sha256-789012fedcba + +runtime: + production: + signing: + key: production-key + checksum_algorithm: blake3 + + staging: + signing: + key: staging-key +``` + +## Key Storage Locations + +Keys are stored in platform-specific directories: + +- **Linux**: `~/.config/avocado/signing-keys/` +- **macOS**: `~/Library/Application Support/avocado/signing-keys/` +- **Windows**: `C:\Users\\AppData\Roaming\avocado\signing-keys\` + +## Key Registry Format + +The global registry is stored in `keys.json`: + +```json +{ + "keys": { + "my-production-key": { + "keyid": "sha256-abc123def456", + "algorithm": "ed25519", + "created_at": "2025-12-17T10:30:00Z", + "uri": "file:///home/user/.config/avocado/signing-keys/sha256-abc123" + } + } +} +``` + +## API Usage + +For programmatic access, the following methods are available: + +```rust +use avocado_cli::utils::config::Config; + +let config = Config::load("avocado.yaml")?; + +// Get all signing keys +let keys = config.get_signing_keys(); + +// Get specific key ID by name +let keyid = config.get_signing_key_id("production-key"); + +// Get signing key for a runtime +let runtime_key = config.get_runtime_signing_key("production"); +``` + +## Image Signing + +When you build a runtime with signing configured, the build process uses a **multi-pass architecture** to sign images securely: + +### Multi-Pass Signing Workflow + +The signing process is split into three distinct passes to support both file-based keys and hardware-backed keys (TPM/YubiKey via PKCS#11): + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Pass 1: Checksum Generation (Inside Container) │ +│ ─────────────────────────────────────────────── │ +│ 1. Container accesses images in Docker volume │ +│ 2. Computes checksums ONLY for required extension .raw files│ +│ using sha256sum or b3sum │ +│ 3. Saves checksums as .sha256 or .blake3 files next to │ +│ each image in output/extensions/ │ +└─────────────────────────────────────────────────────────────┘ + ↓ (checksum files) +┌─────────────────────────────────────────────────────────────┐ +│ Pass 2: Checksum Extraction (Docker cp) │ +│ ──────────────────────────────────── │ +│ 1. Extracts .sha256/.blake3 files from volume │ +│ 2. Parses checksums into manifest │ +└─────────────────────────────────────────────────────────────┘ + ↓ (manifest) +┌─────────────────────────────────────────────────────────────┐ +│ Pass 3: Signing (On Host) │ +│ ────────────────────── │ +│ 1. Host receives checksum manifest │ +│ 2. Signs each checksum using signing key: │ +│ • File-based keys: Load from ~/.config/avocado/ │ +│ • PKCS#11 keys: Access TPM/YubiKey directly │ +│ 3. Generates .sig files │ +└─────────────────────────────────────────────────────────────┘ + ↓ (signatures) +┌─────────────────────────────────────────────────────────────┐ +│ Pass 4: Signature Writing (Docker cp) │ +│ ──────────────────────────────────── │ +│ 1. Creates temporary container with volume │ +│ 2. Copies .sig files into volume via docker cp │ +│ 3. Removes temporary container │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Why Multi-Pass? + +This architecture solves a key challenge: **Docker volumes are not directly accessible from the host filesystem**, and **hardware security modules (HSMs) cannot be reliably accessed from inside containers**. + +**Traditional approach (broken):** +- Sign files directly in container ❌ Cannot access TPM/YubiKey +- Sign files on host ❌ Cannot access Docker volume files + +**Multi-pass solution:** +- ✅ Images stay in Docker volume (never copied out) +- ✅ Checksums generated using standard container tools (sha256sum/b3sum) +- ✅ Only checksum files are extracted (small, fast) +- ✅ Signing happens on host with full hardware access +- ✅ Signatures are written back via docker cp + +### Implementation Details + +1. **Checksum Generation**: + - Uses standard container utilities (`sha256sum` or `b3sum`) + - Only checksums `.raw` files in `output/extensions/` directory + - Only checksums extensions that are dependencies of the runtime being built + - Skips files that already have checksum files to avoid recursive checksumming + - Saves checksums as `.sha256` or `.blake3` files next to each image + +2. **Extension Filtering**: + - The build process determines which extensions are required for the runtime + - Only those extensions' `.raw` images are checksummed and signed + - This prevents signing unnecessary files or creating recursive `.sha256.sha256` files + +3. **Checksum Manifest Format**: +```json +{ + "runtime": "production", + "checksum_algorithm": "blake3", + "files": [ + { + "container_path": "/opt/_avocado/qemuarm64/output/extensions/bootfiles.raw", + "hash": "abc123...", + "size": 1048576 + } + ] +} +``` + +3. **Checksum Files**: Standard format checksum files are created: + - `.sha256` files for SHA-256 checksums + - `.blake3` files for BLAKE3 checksums + +4. **Signature Writing**: Uses `docker cp` to copy signatures into a temporary container with the volume mounted, then the signatures are in the correct location + +### Signature File Format + +Signature files are JSON format containing: + +```json +{ + "version": "1", + "checksum_algorithm": "sha256", + "checksum": "abc123...", + "signature": "def456...", + "key_name": "production-key", + "keyid": "sha256-abc123def456" +} +``` + +### Signed Files + +The following files are signed during runtime builds: +- **Extension images only**: `$AVOCADO_PREFIX//output/extensions/*.raw` + - Only extensions that are dependencies of the runtime being built are signed + - Each extension's `.raw` image file gets a corresponding `.sig` signature file + +Where `` is the target architecture (e.g., `qemuarm64`, `x86_64-unknown-linux-gnu`). + +**Note**: Currently, only extension `.raw` images are signed. Stone-generated runtime images and var images are not signed in this version. + +## Build Commands + +Signing is automatically applied when using: + +```bash +# Build a specific runtime +avocado runtime build -r production + +# Build all runtimes +avocado build + +# Build a specific runtime from the general build command +avocado build -r production +``` + +Example output: +``` +Building runtime images for 'production' +Signing runtime images with key 'production-key' using blake3 checksums +Signed 3 image file(s) +Successfully built runtime 'production' +``` + +## Security Considerations + +1. **Images Never Leave Volume**: Images are never copied to the host; only cryptographic hashes are extracted +2. **Hardware Key Support**: PKCS#11 keys (TPM/YubiKey) are accessed directly on the host where hardware is available +3. **File-Based Keys**: Stored in platform-specific secure locations with 0600 permissions (owner read/write only) +4. **Minimal Container Privileges**: Hash generation and signature writing containers require no special privileges +5. **Read-Only Volume Mount**: The hash generation container mounts the volume as read-only + +## PKCS#11 Support Status + +- **Key Registration**: ✅ PKCS#11 URIs can be registered via `avocado signing-keys create --uri` +- **Key Listing**: ✅ PKCS#11 keys are listed and displayed +- **Signing Operations**: ⚠️ PKCS#11 signing support is planned but not yet implemented +- **Workaround**: Currently, only file-based keys (ed25519) can be used for actual signing + +When PKCS#11 support is fully implemented: +- Signing will occur on the host (Pass 2 of multi-pass workflow) +- Hardware devices (TPM, YubiKey) will be accessed directly +- No container privileges or device passthrough required diff --git a/examples/signing-keys-example.yaml b/examples/signing-keys-example.yaml index f5f6db2..776262e 100644 --- a/examples/signing-keys-example.yaml +++ b/examples/signing-keys-example.yaml @@ -21,21 +21,23 @@ signing_keys: - backup-key: sha256-111222333444 runtime: - # Production runtime uses the production signing key + # Production runtime uses the production signing key with blake3 production: dependencies: avocado-img-bootfiles: "*" avocado-img-rootfs: "*" signing: key: production-key + checksum_algorithm: blake3 # Use blake3 for faster checksums - # Staging runtime uses a different key + # Staging runtime uses a different key with sha256 (default) staging: dependencies: avocado-img-bootfiles: "*" avocado-img-rootfs: "*" signing: key: staging-key + # checksum_algorithm defaults to sha256 if not specified # Development runtime with no signing dev: diff --git a/src/commands/runtime/build.rs b/src/commands/runtime/build.rs index 1484392..02b6231 100644 --- a/src/commands/runtime/build.rs +++ b/src/commands/runtime/build.rs @@ -1,7 +1,8 @@ use crate::utils::{ config::load_config, container::{RunConfig, SdkContainer}, - output::{print_info, print_success, OutputLevel}, + image_signing::{validate_signing_key_for_use, ChecksumAlgorithm}, + output::{print_info, print_success, print_warning, OutputLevel}, target::resolve_target_required, }; use anyhow::{Context, Result}; @@ -116,7 +117,7 @@ impl RuntimeBuildCommand { Some(env_vars) }; - let config = RunConfig { + let run_config = RunConfig { container_image: container_image.to_string(), target: target_arch.clone(), command: build_script, @@ -131,7 +132,7 @@ impl RuntimeBuildCommand { ..Default::default() }; let complete_result = container_helper - .run_in_container(config) + .run_in_container(run_config) .await .context("Failed to build complete image")?; @@ -139,6 +140,35 @@ impl RuntimeBuildCommand { return Err(anyhow::anyhow!("Failed to build complete image")); } + // Get the list of required extensions for filtering + let merged_runtime = config + .get_merged_runtime_config(&self.runtime_name, &target_arch, &self.config_path)? + .with_context(|| { + format!( + "Runtime '{}' not found or has no configuration for target '{}'", + self.runtime_name, target_arch + ) + })?; + + let binding = serde_yaml::Mapping::new(); + let runtime_deps = merged_runtime + .get("dependencies") + .and_then(|v| v.as_mapping()) + .unwrap_or(&binding); + + let mut required_extensions = HashSet::new(); + for (_dep_name, dep_spec) in runtime_deps { + if let Some(ext_name) = dep_spec.get("ext").and_then(|v| v.as_str()) { + required_extensions.insert(ext_name.to_string()); + } + } + + let all_required_extensions = + self.find_all_extension_dependencies(&config, &required_extensions, &target_arch)?; + + // Sign images if signing is configured + self.sign_runtime_images_if_configured(&config, &target_arch, &all_required_extensions).await?; + print_success( &format!("Successfully built runtime '{}'", self.runtime_name), OutputLevel::Normal, @@ -478,6 +508,451 @@ avocado-build-$TARGET_ARCH $RUNTIME_NAME Ok(()) } + + /// Sign runtime images if signing is configured for this runtime + async fn sign_runtime_images_if_configured( + &self, + config: &crate::utils::config::Config, + target_arch: &str, + required_extensions: &HashSet, + ) -> Result<()> { + // Check if runtime has signing configuration + let runtime_signing_key_name = match config.get_runtime_signing_key(&self.runtime_name) { + Some(keyid) => { + // Get the key name from signing_keys mapping + let signing_keys = config.get_signing_keys(); + signing_keys + .and_then(|keys| { + keys.iter() + .find(|(_, v)| *v == &keyid) + .map(|(k, _)| k.clone()) + }) + .context("Signing key ID not found in signing_keys mapping")? + } + None => { + // No signing configured for this runtime + if self.verbose { + print_info( + &format!( + "No signing key configured for runtime '{}'", + self.runtime_name + ), + OutputLevel::Verbose, + ); + } + return Ok(()); + } + }; + + // Get the keyid for signing + let keyid = config + .get_runtime_signing_key(&self.runtime_name) + .context("Failed to get signing key ID")?; + + // Get checksum algorithm (defaults to sha256) + let checksum_str = config + .runtime + .as_ref() + .and_then(|r| r.get(&self.runtime_name)) + .and_then(|rc| rc.signing.as_ref()) + .map(|s| s.checksum_algorithm.as_str()) + .unwrap_or("sha256"); + + let checksum_algorithm: ChecksumAlgorithm = checksum_str.parse()?; + + print_info( + &format!( + "Signing runtime images with key '{}' using {} checksums", + runtime_signing_key_name, + checksum_algorithm.name() + ), + OutputLevel::Normal, + ); + + // Validate the signing key is usable + validate_signing_key_for_use(&runtime_signing_key_name, &keyid)?; + + // Multi-pass signing workflow: + // 1. Run container to generate checksums and save as files + // 2. Extract checksums from volume + // 3. Sign checksums on host + // 4. Write signatures back to volume + + // Get SDK image for checksum generation + let sdk_image = config + .get_sdk_image() + .context("No SDK container image specified in configuration")?; + + // Step 1: Generate checksums in container (saved as .sha256 or .blake3 files) + self.generate_checksums_in_container(&checksum_algorithm, sdk_image, target_arch, required_extensions) + .await?; + + // Step 2: Extract checksums from volume + let manifest = self + .extract_checksums_from_volume(&checksum_algorithm, target_arch) + .await?; + + if manifest.files.is_empty() { + print_warning( + &format!( + "No image files found to sign. Searched in: {}/output/extensions", + target_arch + ), + OutputLevel::Normal, + ); + print_info( + "This may indicate: (1) checksum generation failed, (2) docker cp failed, or (3) no required extensions have .raw images", + OutputLevel::Normal, + ); + return Ok(()); + } + + // Step 3: Sign checksums on host + let signatures = crate::utils::image_signing::sign_hash_manifest( + &manifest, + &runtime_signing_key_name, + &keyid, + )?; + + // Step 4: Write signatures back to volume + self.write_signatures_to_volume(&signatures).await?; + + print_success( + &format!("Signed {} image file(s)", signatures.len()), + OutputLevel::Normal, + ); + + Ok(()) + } + + /// Generate checksums for images in container using standard tools + async fn generate_checksums_in_container( + &self, + checksum_algorithm: &ChecksumAlgorithm, + sdk_image: &str, + target_arch: &str, + required_extensions: &HashSet, + ) -> Result<()> { + if self.verbose { + print_info("Generating checksums in container...", OutputLevel::Verbose); + } + + // Get volume name + let volume_manager = + crate::utils::volume::VolumeManager::new("docker".to_string(), self.verbose); + let volume_state = volume_manager + .get_or_create_volume(&std::env::current_dir()?) + .await?; + + // Determine checksum command and file extension based on algorithm + let (checksum_cmd, file_ext) = match checksum_algorithm { + ChecksumAlgorithm::Sha256 => ("sha256sum", "sha256"), + ChecksumAlgorithm::Blake3 => ("b3sum", "blake3"), + }; + + // Build shell script to generate checksums ONLY for required extension .raw images + let checksum_ext = file_ext; + + // Build list of extension patterns to checksum + let mut extension_patterns = Vec::new(); + for ext_name in required_extensions { + // Match both with and without version: ext-name-*.raw or ext-name.raw + extension_patterns.push(format!("{}-*.raw", ext_name)); + extension_patterns.push(format!("{}.raw", ext_name)); + } + + let pattern_checks = extension_patterns.iter() + .map(|pattern| format!( + r#" + for file in {pattern}; do + if [ -f "$file" ] && [ ! -f "$file.{checksum_ext}" ]; then + echo " Generating checksum for: $file" + {checksum_cmd} "$file" | awk '{{print $1}}' > "$file.{checksum_ext}" + echo " Created: $file.{checksum_ext}" + fi + done"#, + pattern = pattern, + checksum_ext = checksum_ext, + checksum_cmd = checksum_cmd + )) + .collect::>() + .join("\n"); + + let script = format!( + r#"#!/bin/sh +set -e +cd /opt/_avocado/{target} + +echo "=== Generating checksums for extension images only ===" + +# Generate checksums ONLY for required extension .raw images +if [ -d output/extensions ]; then + echo "Checking output/extensions" + cd output/extensions + {pattern_checks} + cd /opt/_avocado/{target} +else + echo " output/extensions directory not found" +fi + +echo "=== Checksum generation complete ===" +"#, + target = target_arch, + pattern_checks = pattern_checks + ); + + // Run container with volume mounted read-write + let container_name = format!("avocado-checksum-gen-{}", uuid::Uuid::new_v4()); + let volume_mount = format!("{}:/opt/_avocado:rw", volume_state.volume_name); + + let run_cmd = [ + "docker", + "run", + "--rm", + "--name", + &container_name, + "-v", + &volume_mount, + sdk_image, + "sh", + "-c", + &script, + ]; + + let mut cmd = tokio::process::Command::new(run_cmd[0]); + cmd.args(&run_cmd[1..]); + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + let output = cmd + .output() + .await + .context("Failed to run checksum generation")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Checksum generation failed: {}", stderr); + } + + if self.verbose { + let stdout = String::from_utf8_lossy(&output.stdout); + print_info(&format!("Checksum generation output:\n{}", stdout), OutputLevel::Verbose); + } + + Ok(()) + } + + /// Extract checksums from volume by reading checksum files + async fn extract_checksums_from_volume( + &self, + checksum_algorithm: &ChecksumAlgorithm, + target_arch: &str, + ) -> Result { + if self.verbose { + print_info("Extracting checksums from volume...", OutputLevel::Verbose); + } + + // Get volume name + let volume_manager = + crate::utils::volume::VolumeManager::new("docker".to_string(), self.verbose); + let volume_state = volume_manager + .get_or_create_volume(&std::env::current_dir()?) + .await?; + + // Determine file extension based on algorithm + let file_ext = match checksum_algorithm { + ChecksumAlgorithm::Sha256 => "sha256", + ChecksumAlgorithm::Blake3 => "blake3", + }; + + // Create temp directory for extracted checksums + let temp_dir = tempfile::tempdir().context("Failed to create temp directory")?; + + // Create temporary container to extract checksum files + let container_name = format!("avocado-checksum-extract-{}", uuid::Uuid::new_v4()); + let volume_mount = format!("{}:/opt/_avocado:ro", volume_state.volume_name); + + let create_cmd = [ + "docker", + "create", + "--name", + &container_name, + "-v", + &volume_mount, + "busybox", + "true", + ]; + + let mut cmd = tokio::process::Command::new(create_cmd[0]); + cmd.args(&create_cmd[1..]); + let output = cmd + .output() + .await + .context("Failed to create extract container")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Failed to create extract container: {}", stderr); + } + + // Extract checksum files using docker cp + let mut entries = Vec::new(); + + // Copy checksum files ONLY from extensions directory + let extensions_dir = format!("{}/output/extensions", target_arch); + + let search_dirs = vec![ + (extensions_dir.as_str(), "output/extensions"), + ]; + + for (source_dir, _) in &search_dirs { + print_info( + &format!("Attempting to copy checksums from: /opt/_avocado/{}", source_dir), + OutputLevel::Normal, + ); + + // Copy the entire directory (without trailing slash to copy the dir itself) + let container_path = format!("{}:/opt/_avocado/{}", container_name, source_dir); + let dest_path = temp_dir.path().to_str().unwrap(); + + let cp_cmd = ["docker", + "cp", + &container_path, + dest_path]; + + let mut cmd = tokio::process::Command::new(cp_cmd[0]); + cmd.args(&cp_cmd[1..]); + let output = cmd.output().await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + print_info( + &format!(" ⚠ Docker cp failed: {}", stderr.trim()), + OutputLevel::Normal, + ); + } else { + print_info( + " ✓ Copied successfully", + OutputLevel::Normal, + ); + } + } + + // Clean up container + let _ = self.cleanup_container(&container_name).await; + + // Read extracted checksum files from all subdirectories + print_info( + &format!("Looking for .{} files in extracted directories...", file_ext), + OutputLevel::Normal, + ); + + // Docker cp copies just the final directory, not the full path + // So /opt/_avocado/qemuarm64/output/extensions becomes temp_dir/extensions + let dir_mapping = vec![ + (extensions_dir.as_str(), "extensions"), + ]; + + for (source_dir, dir_name) in &dir_mapping { + let search_path = temp_dir.path().join(dir_name); + + print_info( + &format!(" Scanning: {} -> {}", source_dir, search_path.display()), + OutputLevel::Normal, + ); + + if !search_path.exists() { + print_info( + " Directory not found", + OutputLevel::Normal, + ); + continue; + } + + if let Ok(dir_entries) = std::fs::read_dir(&search_path) { + let mut found_count = 0; + for entry in dir_entries.flatten() { + let path = entry.path(); + + if path.extension().and_then(|e| e.to_str()) == Some(file_ext) { + found_count += 1; + let checksum = std::fs::read_to_string(&path)?.trim().to_string(); + let image_name = path.file_stem().unwrap().to_str().unwrap(); + let size = 0; // Size not needed for signing + + // All checksums are from extensions directory + let container_path = format!("/opt/_avocado/{}/output/extensions/{}", target_arch, image_name); + + print_info( + &format!(" Found checksum: {}", image_name), + OutputLevel::Normal, + ); + + entries.push(crate::utils::image_signing::HashManifestEntry { + container_path, + hash: checksum, + size, + }); + } + } + + if found_count == 0 { + print_info( + &format!(" No .{} files found in this directory", file_ext), + OutputLevel::Normal, + ); + } + } + } + + Ok(crate::utils::image_signing::HashManifest { + runtime: self.runtime_name.clone(), + checksum_algorithm: checksum_algorithm.name().to_string(), + files: entries, + }) + } + + /// Write signatures to Docker volume + async fn write_signatures_to_volume( + &self, + signatures: &[crate::utils::image_signing::SignatureData], + ) -> Result<()> { + if self.verbose { + print_info( + &format!( + "Writing {} signature file(s) to volume...", + signatures.len() + ), + OutputLevel::Verbose, + ); + } + + // Get volume name + let volume_manager = + crate::utils::volume::VolumeManager::new("docker".to_string(), self.verbose); + let volume_state = volume_manager + .get_or_create_volume(&std::env::current_dir()?) + .await?; + + // Use SdkContainer's write_signatures_to_volume method + let container = SdkContainer::new().verbose(self.verbose); + container + .write_signatures_to_volume(&volume_state.volume_name, signatures) + .await?; + + Ok(()) + } + + /// Clean up a container + async fn cleanup_container(&self, container_name: &str) -> Result<()> { + let rm_cmd = ["docker", "rm", "-f", container_name]; + + let mut cmd = tokio::process::Command::new(rm_cmd[0]); + cmd.args(&rm_cmd[1..]); + let _ = cmd.output().await; + + Ok(()) + } } #[cfg(test)] diff --git a/src/utils/config.rs b/src/utils/config.rs index 3620645..3b91aaf 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -129,6 +129,13 @@ pub enum ConfigError { pub struct SigningConfig { /// Name of the signing key to use (references a key from signing_keys section) pub key: String, + /// Checksum algorithm to use (sha256 or blake3, defaults to sha256) + #[serde(default = "default_checksum_algorithm")] + pub checksum_algorithm: String, +} + +fn default_checksum_algorithm() -> String { + "sha256".to_string() } /// Runtime configuration section @@ -4606,6 +4613,15 @@ runtime: dev: signing: key: my-production-key + checksum_algorithm: sha256 + production: + signing: + key: backup-key + checksum_algorithm: blake3 + staging: + signing: + key: my-production-key + # checksum_algorithm defaults to sha256 "#; let config = Config::load_from_yaml_str(config_content).unwrap(); @@ -4650,6 +4666,21 @@ runtime: assert!(runtime.signing.is_some()); let signing = runtime.signing.as_ref().unwrap(); assert_eq!(signing.key, "my-production-key"); + assert_eq!(signing.checksum_algorithm, "sha256"); + + // Test production runtime with blake3 + let production = config.runtime.as_ref().unwrap().get("production").unwrap(); + assert!(production.signing.is_some()); + let prod_signing = production.signing.as_ref().unwrap(); + assert_eq!(prod_signing.key, "backup-key"); + assert_eq!(prod_signing.checksum_algorithm, "blake3"); + + // Test staging runtime with default checksum_algorithm + let staging = config.runtime.as_ref().unwrap().get("staging").unwrap(); + assert!(staging.signing.is_some()); + let staging_signing = staging.signing.as_ref().unwrap(); + assert_eq!(staging_signing.key, "my-production-key"); + assert_eq!(staging_signing.checksum_algorithm, "sha256"); // Default } #[test] diff --git a/src/utils/container.rs b/src/utils/container.rs index 04c253a..153a6f2 100644 --- a/src/utils/container.rs +++ b/src/utils/container.rs @@ -234,6 +234,24 @@ impl SdkContainer { container_cmd.push("-v".to_string()); container_cmd.push(format!("{}:/opt/_avocado:rw", volume_state.volume_name)); + // Mount signing keys directory if it exists (read-only for security) + let signing_keys_env = + if let Ok(signing_keys_dir) = crate::utils::signing_keys::get_signing_keys_dir() { + if signing_keys_dir.exists() { + container_cmd.push("-v".to_string()); + container_cmd.push(format!( + "{}:/opt/signing-keys:ro", + signing_keys_dir.display() + )); + // Return environment variable so container knows where keys are mounted + Some("/opt/signing-keys".to_string()) + } else { + None + } + } else { + None + }; + // Note: Working directory is handled in the entrypoint script based on sysroot parameters // Add environment variables @@ -242,6 +260,12 @@ impl SdkContainer { container_cmd.push("-e".to_string()); container_cmd.push(format!("AVOCADO_SDK_TARGET={target}")); + // Add signing keys directory env var if mounted + if let Some(keys_dir) = signing_keys_env { + container_cmd.push("-e".to_string()); + container_cmd.push(format!("AVOCADO_SIGNING_KEYS_DIR={}", keys_dir)); + } + for (key, value) in env_vars { container_cmd.push("-e".to_string()); container_cmd.push(format!("{key}={value}")); @@ -779,6 +803,171 @@ fi result } } + + /// Write signature files to a Docker volume using docker cp + /// + /// This creates a temporary container, copies signature files into it, + /// then removes the container. + pub async fn write_signatures_to_volume( + &self, + volume_name: &str, + signatures: &[crate::utils::image_signing::SignatureData], + ) -> Result<()> { + if signatures.is_empty() { + return Ok(()); + } + + // Create temporary directory for signature files + let temp_dir = tempfile::tempdir().context("Failed to create temp directory")?; + + // Write signature files to temp directory with flattened names + let mut file_mappings = Vec::new(); + for (idx, sig) in signatures.iter().enumerate() { + let temp_file_name = format!("sig_{}.json", idx); + let temp_file_path = temp_dir.path().join(&temp_file_name); + std::fs::write(&temp_file_path, &sig.content).with_context(|| { + format!( + "Failed to write signature file to temp: {}", + temp_file_path.display() + ) + })?; + + file_mappings.push((temp_file_path, sig.container_path.clone())); + } + + // Create a temporary container with the volume mounted + let container_name = format!("avocado-sig-writer-{}", uuid::Uuid::new_v4()); + let volume_mount = format!("{}:/opt/_avocado:rw", volume_name); + + let create_cmd = [ + &self.container_tool, + &"create".to_string(), + &"--name".to_string(), + &container_name, + &"-v".to_string(), + &volume_mount, + &"alpine:latest".to_string(), + &"true".to_string(), + ]; + + if self.verbose { + print_info( + &format!( + "Creating temporary container for signature writing: {}", + container_name + ), + OutputLevel::Verbose, + ); + } + + let mut cmd = AsyncCommand::new(create_cmd[0]); + cmd.args(&create_cmd[1..]); + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + + let output = cmd + .output() + .await + .context("Failed to create temporary container")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Failed to create temporary container: {}", stderr); + } + + // Copy each signature file into the container + for (temp_path, container_path) in &file_mappings { + let temp_path_str = temp_path.display().to_string(); + let container_dest = format!("{}:{}", container_name, container_path); + + let cp_cmd = [ + &self.container_tool, + &"cp".to_string(), + &temp_path_str, + &container_dest, + ]; + + if self.verbose { + print_info( + &format!("Copying signature to {}", container_path), + OutputLevel::Verbose, + ); + } + + let mut cmd = AsyncCommand::new(cp_cmd[0]); + cmd.args(&cp_cmd[1..]); + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + + let output = cmd.output().await.with_context(|| { + format!( + "Failed to copy signature file to container: {}", + container_path + ) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + + // Clean up container before returning error + let _ = self.remove_container(&container_name).await; + + anyhow::bail!( + "Failed to copy signature file {}: {}", + container_path, + stderr + ); + } + } + + // Remove the temporary container + self.remove_container(&container_name).await?; + + if self.verbose { + print_info( + &format!( + "Successfully wrote {} signature file(s) to volume", + signatures.len() + ), + OutputLevel::Normal, + ); + } + + Ok(()) + } + + /// Remove a container by name + async fn remove_container(&self, container_name: &str) -> Result<()> { + let container_name_str = container_name.to_string(); + let rm_cmd = [ + &self.container_tool, + &"rm".to_string(), + &"-f".to_string(), + &container_name_str, + ]; + + let mut cmd = AsyncCommand::new(rm_cmd[0]); + cmd.args(&rm_cmd[1..]); + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + + let output = cmd + .output() + .await + .context("Failed to remove temporary container")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if self.verbose { + print_error( + &format!( + "Warning: Failed to remove temporary container {}: {}", + container_name, stderr + ), + OutputLevel::Verbose, + ); + } + } + + Ok(()) + } } #[cfg(test)] diff --git a/src/utils/image_signing.rs b/src/utils/image_signing.rs new file mode 100644 index 0000000..efed2bf --- /dev/null +++ b/src/utils/image_signing.rs @@ -0,0 +1,440 @@ +//! Image signing utilities for runtime builds. +//! +//! Provides functionality for signing image files using ed25519 keys +//! with configurable hash algorithms (sha256 or blake3). +//! +//! Supports multi-pass signing workflow: +//! 1. Container computes hashes and outputs manifest +//! 2. Host signs hashes (supports file-based and PKCS#11 keys) +//! 3. Signatures written back to volume + +use anyhow::{Context, Result}; +use base64::prelude::*; +use blake3; +use ed25519_dalek::{Signature, Signer, SigningKey}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::Path; +use std::str::FromStr; + +use super::signing_keys::{get_key_entries, is_file_uri, is_pkcs11_uri}; + +/// Hash manifest entry for a single file +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HashManifestEntry { + /// Path to the file inside the container + pub container_path: String, + /// Hex-encoded hash of the file + pub hash: String, + /// File size in bytes + pub size: u64, +} + +/// Hash manifest containing all files to be signed +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HashManifest { + /// Runtime name + pub runtime: String, + /// Checksum algorithm used (sha256 or blake3) + pub checksum_algorithm: String, + /// List of files with their hashes + pub files: Vec, +} + +/// Signature data to be written back to volume +#[derive(Debug, Clone)] +pub struct SignatureData { + /// Path to the file inside the container (where .sig should be written) + pub container_path: String, + /// Signature file content (JSON) + pub content: String, +} + +/// Supported checksum algorithms for signing +#[derive(Debug, Clone, PartialEq)] +pub enum ChecksumAlgorithm { + Sha256, + Blake3, +} + +impl FromStr for ChecksumAlgorithm { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "sha256" | "sha-256" => Ok(ChecksumAlgorithm::Sha256), + "blake3" => Ok(ChecksumAlgorithm::Blake3), + _ => anyhow::bail!( + "Unsupported checksum algorithm '{}'. Supported: sha256, blake3", + s + ), + } + } +} + +impl ChecksumAlgorithm { + /// Get the name of the checksum algorithm + pub fn name(&self) -> &str { + match self { + ChecksumAlgorithm::Sha256 => "sha256", + ChecksumAlgorithm::Blake3 => "blake3", + } + } +} + +/// Compute checksum of a file +#[allow(dead_code)] // Public API for future use +pub fn compute_file_hash(file_path: &Path, algorithm: &ChecksumAlgorithm) -> Result> { + let data = fs::read(file_path) + .with_context(|| format!("Failed to read file for hashing: {}", file_path.display()))?; + + Ok(match algorithm { + ChecksumAlgorithm::Sha256 => { + let mut hasher = Sha256::new(); + hasher.update(&data); + hasher.finalize().to_vec() + } + ChecksumAlgorithm::Blake3 => blake3::hash(&data).as_bytes().to_vec(), + }) +} + +/// Load a signing key from disk +fn load_signing_key(keyid: &str) -> Result { + let key_file_path = super::signing_keys::get_key_file_path(keyid)?.with_extension("key"); + + let private_key_b64 = fs::read_to_string(&key_file_path).with_context(|| { + format!( + "Failed to read private key file: {}", + key_file_path.display() + ) + })?; + + let private_key_bytes = BASE64_STANDARD + .decode(private_key_b64.trim()) + .context("Failed to decode private key from base64")?; + + if private_key_bytes.len() != 32 { + anyhow::bail!("Invalid private key length"); + } + + let mut key_bytes = [0u8; 32]; + key_bytes.copy_from_slice(&private_key_bytes); + + Ok(SigningKey::from_bytes(&key_bytes)) +} + +/// Sign a file and save the signature +#[allow(dead_code)] // Public API for future use +pub fn sign_file( + file_path: &Path, + key_name: &str, + keyid: &str, + checksum_algorithm: &ChecksumAlgorithm, +) -> Result<()> { + // Compute file checksum + let hash = compute_file_hash(file_path, checksum_algorithm)?; + + // Load signing key (only file:// URIs supported for now) + let signing_key = load_signing_key(keyid).with_context(|| { + format!( + "Failed to load signing key '{}' (keyid: {})", + key_name, keyid + ) + })?; + + // Sign the hash + let signature: Signature = signing_key.sign(&hash); + + // Create signature file content + let sig_content = create_signature_content( + &hash, + signature.to_bytes(), + checksum_algorithm, + key_name, + keyid, + )?; + + // Write signature to .sig file + let sig_path = file_path.with_extension( + format!( + "{}.sig", + file_path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("raw") + ) + .as_str(), + ); + + fs::write(&sig_path, sig_content) + .with_context(|| format!("Failed to write signature file: {}", sig_path.display()))?; + + Ok(()) +} + +/// Create signature file content in JSON format +fn create_signature_content( + hash: &[u8], + signature: [u8; 64], + checksum_algorithm: &ChecksumAlgorithm, + key_name: &str, + keyid: &str, +) -> Result { + let sig_data = serde_json::json!({ + "version": "1", + "checksum_algorithm": checksum_algorithm.name(), + "checksum": hex_encode(hash), + "signature": hex_encode(&signature), + "key_name": key_name, + "keyid": keyid, + }); + + serde_json::to_string_pretty(&sig_data).context("Failed to serialize signature data") +} + +/// Sign runtime images (extension images and var image) +#[allow(dead_code)] // Public API for future use +pub fn sign_runtime_images( + runtime_name: &str, + key_name: &str, + keyid: &str, + checksum_algorithm: &ChecksumAlgorithm, + avocado_prefix: &Path, +) -> Result> { + let mut signed_files = Vec::new(); + + // Sign extension images in output/extensions/ + let ext_dir = avocado_prefix.join("output/extensions"); + if ext_dir.exists() { + for entry in fs::read_dir(&ext_dir).with_context(|| { + format!("Failed to read extensions directory: {}", ext_dir.display()) + })? { + let entry = entry?; + let path = entry.path(); + + if path.extension().and_then(|e| e.to_str()) == Some("raw") { + sign_file(&path, key_name, keyid, checksum_algorithm)?; + signed_files.push(path.display().to_string()); + } + } + } + + // Sign var image in runtimes/{runtime_name}/ + let runtime_dir = avocado_prefix.join("runtimes").join(runtime_name); + if runtime_dir.exists() { + for entry in fs::read_dir(&runtime_dir).with_context(|| { + format!( + "Failed to read runtime directory: {}", + runtime_dir.display() + ) + })? { + let entry = entry?; + let path = entry.path(); + + // Sign .raw files (var image and others) + if path.extension().and_then(|e| e.to_str()) == Some("raw") { + sign_file(&path, key_name, keyid, checksum_algorithm)?; + signed_files.push(path.display().to_string()); + } + } + } + + Ok(signed_files) +} + +/// Validate that a signing key is usable +/// +/// Note: key_name is the local name from config, keyid is the actual key name in global registry +pub fn validate_signing_key_for_use(key_name: &str, keyid: &str) -> Result<()> { + // Get key entry from registry using the keyid (which is the actual registry key name) + let entries = get_key_entries(&[keyid.to_string()])?; + let (_registry_name, entry) = entries + .first() + .ok_or_else(|| anyhow::anyhow!("Key with ID '{}' not found in global registry", keyid))?; + + // Validate based on key type + if is_file_uri(&entry.uri) { + // Verify the key file exists and can be loaded + load_signing_key(keyid).with_context(|| { + format!( + "Failed to load signing key '{}' (keyid: {}). The key may be missing or corrupted.", + key_name, keyid + ) + })?; + } else if is_pkcs11_uri(&entry.uri) { + // PKCS#11 keys - basic validation + // Full validation would require opening a session, which we defer to signing time + println!("Note: PKCS#11 key validation deferred to signing operation"); + } else { + anyhow::bail!( + "Signing key '{}' (keyid: {}) uses unsupported URI type: {}", + key_name, + keyid, + entry.uri + ); + } + + Ok(()) +} + +/// Type alias for signing function to reduce complexity +type SignFn = Box Result>>; + +/// Sign a hash manifest and return signature data +/// +/// Note: key_name is the local name from config, keyid is the actual key name in global registry +pub fn sign_hash_manifest( + manifest: &HashManifest, + key_name: &str, + keyid: &str, +) -> Result> { + let mut signatures = Vec::new(); + + // Get key entry from registry using the keyid (which is the actual registry key name) + let entries = get_key_entries(&[keyid.to_string()])?; + let (_registry_name, entry) = entries + .first() + .ok_or_else(|| anyhow::anyhow!("Key with ID '{}' not found in global registry", keyid))?; + + // Determine signing method based on URI type + let sign_fn: SignFn = if is_file_uri(&entry.uri) { + // File-based signing + let signing_key = load_signing_key(keyid).with_context(|| { + format!( + "Failed to load signing key '{}' (keyid: {})", + key_name, keyid + ) + })?; + Box::new(move |hash: &[u8]| { + let signature: Signature = signing_key.sign(hash); + Ok(signature.to_bytes().to_vec()) + }) + } else if is_pkcs11_uri(&entry.uri) { + // PKCS#11 signing + let uri = entry.uri.clone(); + Box::new(move |hash: &[u8]| sign_with_pkcs11(&uri, hash)) + } else { + anyhow::bail!( + "Signing key '{}' uses unsupported URI type: {}", + key_name, + entry.uri + ); + }; + + // Sign each file's hash + for file_entry in &manifest.files { + // Decode hex hash + let hash_bytes = hex_decode(&file_entry.hash) + .with_context(|| format!("Failed to decode hash for {}", file_entry.container_path))?; + + // Sign the hash using appropriate method + let signature_bytes = sign_fn(&hash_bytes) + .with_context(|| format!("Failed to sign hash for {}", file_entry.container_path))?; + + // Create signature file content + let sig_content = create_signature_content( + &hash_bytes, + signature_bytes.try_into().unwrap_or_else(|v: Vec| { + // Pad or truncate to 64 bytes for compatibility + let mut arr = [0u8; 64]; + let len = v.len().min(64); + arr[..len].copy_from_slice(&v[..len]); + arr + }), + &manifest.checksum_algorithm.parse()?, + key_name, + keyid, + )?; + + // Determine signature file path + let sig_path = format!("{}.sig", file_entry.container_path); + + signatures.push(SignatureData { + container_path: sig_path, + content: sig_content, + }); + } + + Ok(signatures) +} + +/// Sign a hash using PKCS#11 hardware token +/// +/// This function provides basic PKCS#11 signing support. Full implementation +/// requires proper token/slot discovery and PIN management. +fn sign_with_pkcs11(uri: &str, _hash: &[u8]) -> Result> { + // Parse PKCS#11 URI (simplified - full URI parsing would be more complex) + // Format: pkcs11:token=TokenName;object=KeyLabel + + // For now, return an error with helpful message + // Full implementation would: + // 1. Initialize PKCS#11 library + // 2. Find slot/token + // 3. Open session + // 4. Find private key object + // 5. Perform signing operation + // 6. Return signature + + anyhow::bail!( + "PKCS#11 signing is not yet fully implemented. URI: {}\n\ + \n\ + To implement PKCS#11 signing:\n\ + 1. Install PKCS#11 library for your device (e.g., opensc for YubiKey)\n\ + 2. Set PKCS11_MODULE_PATH environment variable\n\ + 3. Ensure device is connected and accessible\n\ + \n\ + Currently, only file-based ed25519 keys are supported for signing.", + uri + ) +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +fn hex_decode(hex: &str) -> Result> { + (0..hex.len()) + .step_by(2) + .map(|i| { + u8::from_str_radix(&hex[i..i + 2], 16) + .with_context(|| format!("Invalid hex string at position {}", i)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_checksum_algorithm_from_str() { + assert_eq!( + "sha256".parse::().unwrap(), + ChecksumAlgorithm::Sha256 + ); + assert_eq!( + "SHA256".parse::().unwrap(), + ChecksumAlgorithm::Sha256 + ); + assert_eq!( + "sha-256".parse::().unwrap(), + ChecksumAlgorithm::Sha256 + ); + assert_eq!( + "blake3".parse::().unwrap(), + ChecksumAlgorithm::Blake3 + ); + assert_eq!( + "BLAKE3".parse::().unwrap(), + ChecksumAlgorithm::Blake3 + ); + assert!("md5".parse::().is_err()); + } + + #[test] + fn test_checksum_algorithm_name() { + assert_eq!(ChecksumAlgorithm::Sha256.name(), "sha256"); + assert_eq!(ChecksumAlgorithm::Blake3.name(), "blake3"); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 8ef5938..dc32966 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,5 +1,6 @@ pub mod config; pub mod container; +pub mod image_signing; pub mod interpolation; pub mod output; pub mod signing_keys; diff --git a/src/utils/signing_keys.rs b/src/utils/signing_keys.rs index 3037433..ff6d895 100644 --- a/src/utils/signing_keys.rs +++ b/src/utils/signing_keys.rs @@ -104,7 +104,16 @@ pub fn get_avocado_config_dir() -> Result { } /// Get the directory for storing signing keys +/// +/// When running in a container, this checks the AVOCADO_SIGNING_KEYS_DIR environment variable +/// which points to the mounted keys directory. Otherwise, it returns the host path. pub fn get_signing_keys_dir() -> Result { + // Check if we're running in a container with mounted keys + if let Ok(container_keys_dir) = std::env::var("AVOCADO_SIGNING_KEYS_DIR") { + return Ok(PathBuf::from(container_keys_dir)); + } + + // Otherwise use the host path let config_dir = get_avocado_config_dir()?; Ok(config_dir.join(SIGNING_KEYS_DIR)) } diff --git a/tests/fixtures/configs/with-signing-keys.yaml b/tests/fixtures/configs/with-signing-keys.yaml index 1f1c450..969dadb 100644 --- a/tests/fixtures/configs/with-signing-keys.yaml +++ b/tests/fixtures/configs/with-signing-keys.yaml @@ -13,3 +13,8 @@ runtime: dev: signing: key: my-production-key + checksum_algorithm: sha256 + production: + signing: + key: backup-key + checksum_algorithm: blake3 diff --git a/tests/fixtures/test-signing-container.sh b/tests/fixtures/test-signing-container.sh new file mode 100755 index 0000000..b38f3df --- /dev/null +++ b/tests/fixtures/test-signing-container.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Test script to verify signing keys are mounted in container + +set -e + +echo "=== Testing Signing Keys Container Mount ===" +echo + +# Check if AVOCADO_SIGNING_KEYS_DIR is set +if [ -n "$AVOCADO_SIGNING_KEYS_DIR" ]; then + echo "✓ AVOCADO_SIGNING_KEYS_DIR is set: $AVOCADO_SIGNING_KEYS_DIR" +else + echo "✗ AVOCADO_SIGNING_KEYS_DIR is not set" + exit 1 +fi + +# Check if the directory exists +if [ -d "$AVOCADO_SIGNING_KEYS_DIR" ]; then + echo "✓ Signing keys directory exists" +else + echo "✗ Signing keys directory does not exist" + exit 1 +fi + +# Check if we can read the directory +if [ -r "$AVOCADO_SIGNING_KEYS_DIR" ]; then + echo "✓ Signing keys directory is readable" +else + echo "✗ Signing keys directory is not readable" + exit 1 +fi + +# List contents (if any) +echo +echo "Directory contents:" +ls -la "$AVOCADO_SIGNING_KEYS_DIR" || echo "(empty or no access)" + +echo +echo "✓ All container mount tests passed!" From 963491da7cf63db8b8b36907bd2bfc36cc87ec3b Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Wed, 17 Dec 2025 14:03:37 -0500 Subject: [PATCH 05/12] update signing keys example --- examples/signing-keys-example.yaml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/examples/signing-keys-example.yaml b/examples/signing-keys-example.yaml index 776262e..e54c346 100644 --- a/examples/signing-keys-example.yaml +++ b/examples/signing-keys-example.yaml @@ -10,7 +10,7 @@ default_target: qemux86-64 sdk: - image: ghcr.io/avocado-framework/avocado-sdk:latest + image: docker.io/avocadolinux/avocado-sdk:apollo-edge # Define signing keys with friendly names # The key IDs (right side) should match keys in the global registry @@ -24,24 +24,24 @@ runtime: # Production runtime uses the production signing key with blake3 production: dependencies: - avocado-img-bootfiles: "*" - avocado-img-rootfs: "*" + avocado-runtime: "*" signing: key: production-key checksum_algorithm: blake3 # Use blake3 for faster checksums - + # Staging runtime uses a different key with sha256 (default) staging: dependencies: - avocado-img-bootfiles: "*" - avocado-img-rootfs: "*" + avocado-runtime: "*" signing: key: staging-key # checksum_algorithm defaults to sha256 if not specified - + # Development runtime with no signing dev: dependencies: - avocado-img-bootfiles: "*" - avocado-img-rootfs: "*" + avocado-runtime: "*" # No signing configuration - unsigned builds + +# avocado build +# avocado runtime build -r dev From 80ddfbe3f399ad5c027d7480c451de86c1b36125 Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Wed, 17 Dec 2025 14:25:22 -0500 Subject: [PATCH 06/12] add runtime sign and move signing out of build --- src/commands/mod.rs | 1 + src/commands/runtime/build.rs | 477 +--------------------- src/commands/runtime/mod.rs | 2 + src/commands/runtime/sign.rs | 747 ++++++++++++++++++++++++++++++++++ src/commands/sign.rs | 224 ++++++++++ src/main.rs | 82 ++++ 6 files changed, 1057 insertions(+), 476 deletions(-) create mode 100644 src/commands/runtime/sign.rs create mode 100644 src/commands/sign.rs diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 3198f0a..c9649bb 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -8,5 +8,6 @@ pub mod install; pub mod provision; pub mod runtime; pub mod sdk; +pub mod sign; pub mod signing_keys; pub mod upgrade; diff --git a/src/commands/runtime/build.rs b/src/commands/runtime/build.rs index 02b6231..b8ed1e9 100644 --- a/src/commands/runtime/build.rs +++ b/src/commands/runtime/build.rs @@ -1,8 +1,7 @@ use crate::utils::{ config::load_config, container::{RunConfig, SdkContainer}, - image_signing::{validate_signing_key_for_use, ChecksumAlgorithm}, - output::{print_info, print_success, print_warning, OutputLevel}, + output::{print_info, print_success, OutputLevel}, target::resolve_target_required, }; use anyhow::{Context, Result}; @@ -140,35 +139,6 @@ impl RuntimeBuildCommand { return Err(anyhow::anyhow!("Failed to build complete image")); } - // Get the list of required extensions for filtering - let merged_runtime = config - .get_merged_runtime_config(&self.runtime_name, &target_arch, &self.config_path)? - .with_context(|| { - format!( - "Runtime '{}' not found or has no configuration for target '{}'", - self.runtime_name, target_arch - ) - })?; - - let binding = serde_yaml::Mapping::new(); - let runtime_deps = merged_runtime - .get("dependencies") - .and_then(|v| v.as_mapping()) - .unwrap_or(&binding); - - let mut required_extensions = HashSet::new(); - for (_dep_name, dep_spec) in runtime_deps { - if let Some(ext_name) = dep_spec.get("ext").and_then(|v| v.as_str()) { - required_extensions.insert(ext_name.to_string()); - } - } - - let all_required_extensions = - self.find_all_extension_dependencies(&config, &required_extensions, &target_arch)?; - - // Sign images if signing is configured - self.sign_runtime_images_if_configured(&config, &target_arch, &all_required_extensions).await?; - print_success( &format!("Successfully built runtime '{}'", self.runtime_name), OutputLevel::Normal, @@ -508,451 +478,6 @@ avocado-build-$TARGET_ARCH $RUNTIME_NAME Ok(()) } - - /// Sign runtime images if signing is configured for this runtime - async fn sign_runtime_images_if_configured( - &self, - config: &crate::utils::config::Config, - target_arch: &str, - required_extensions: &HashSet, - ) -> Result<()> { - // Check if runtime has signing configuration - let runtime_signing_key_name = match config.get_runtime_signing_key(&self.runtime_name) { - Some(keyid) => { - // Get the key name from signing_keys mapping - let signing_keys = config.get_signing_keys(); - signing_keys - .and_then(|keys| { - keys.iter() - .find(|(_, v)| *v == &keyid) - .map(|(k, _)| k.clone()) - }) - .context("Signing key ID not found in signing_keys mapping")? - } - None => { - // No signing configured for this runtime - if self.verbose { - print_info( - &format!( - "No signing key configured for runtime '{}'", - self.runtime_name - ), - OutputLevel::Verbose, - ); - } - return Ok(()); - } - }; - - // Get the keyid for signing - let keyid = config - .get_runtime_signing_key(&self.runtime_name) - .context("Failed to get signing key ID")?; - - // Get checksum algorithm (defaults to sha256) - let checksum_str = config - .runtime - .as_ref() - .and_then(|r| r.get(&self.runtime_name)) - .and_then(|rc| rc.signing.as_ref()) - .map(|s| s.checksum_algorithm.as_str()) - .unwrap_or("sha256"); - - let checksum_algorithm: ChecksumAlgorithm = checksum_str.parse()?; - - print_info( - &format!( - "Signing runtime images with key '{}' using {} checksums", - runtime_signing_key_name, - checksum_algorithm.name() - ), - OutputLevel::Normal, - ); - - // Validate the signing key is usable - validate_signing_key_for_use(&runtime_signing_key_name, &keyid)?; - - // Multi-pass signing workflow: - // 1. Run container to generate checksums and save as files - // 2. Extract checksums from volume - // 3. Sign checksums on host - // 4. Write signatures back to volume - - // Get SDK image for checksum generation - let sdk_image = config - .get_sdk_image() - .context("No SDK container image specified in configuration")?; - - // Step 1: Generate checksums in container (saved as .sha256 or .blake3 files) - self.generate_checksums_in_container(&checksum_algorithm, sdk_image, target_arch, required_extensions) - .await?; - - // Step 2: Extract checksums from volume - let manifest = self - .extract_checksums_from_volume(&checksum_algorithm, target_arch) - .await?; - - if manifest.files.is_empty() { - print_warning( - &format!( - "No image files found to sign. Searched in: {}/output/extensions", - target_arch - ), - OutputLevel::Normal, - ); - print_info( - "This may indicate: (1) checksum generation failed, (2) docker cp failed, or (3) no required extensions have .raw images", - OutputLevel::Normal, - ); - return Ok(()); - } - - // Step 3: Sign checksums on host - let signatures = crate::utils::image_signing::sign_hash_manifest( - &manifest, - &runtime_signing_key_name, - &keyid, - )?; - - // Step 4: Write signatures back to volume - self.write_signatures_to_volume(&signatures).await?; - - print_success( - &format!("Signed {} image file(s)", signatures.len()), - OutputLevel::Normal, - ); - - Ok(()) - } - - /// Generate checksums for images in container using standard tools - async fn generate_checksums_in_container( - &self, - checksum_algorithm: &ChecksumAlgorithm, - sdk_image: &str, - target_arch: &str, - required_extensions: &HashSet, - ) -> Result<()> { - if self.verbose { - print_info("Generating checksums in container...", OutputLevel::Verbose); - } - - // Get volume name - let volume_manager = - crate::utils::volume::VolumeManager::new("docker".to_string(), self.verbose); - let volume_state = volume_manager - .get_or_create_volume(&std::env::current_dir()?) - .await?; - - // Determine checksum command and file extension based on algorithm - let (checksum_cmd, file_ext) = match checksum_algorithm { - ChecksumAlgorithm::Sha256 => ("sha256sum", "sha256"), - ChecksumAlgorithm::Blake3 => ("b3sum", "blake3"), - }; - - // Build shell script to generate checksums ONLY for required extension .raw images - let checksum_ext = file_ext; - - // Build list of extension patterns to checksum - let mut extension_patterns = Vec::new(); - for ext_name in required_extensions { - // Match both with and without version: ext-name-*.raw or ext-name.raw - extension_patterns.push(format!("{}-*.raw", ext_name)); - extension_patterns.push(format!("{}.raw", ext_name)); - } - - let pattern_checks = extension_patterns.iter() - .map(|pattern| format!( - r#" - for file in {pattern}; do - if [ -f "$file" ] && [ ! -f "$file.{checksum_ext}" ]; then - echo " Generating checksum for: $file" - {checksum_cmd} "$file" | awk '{{print $1}}' > "$file.{checksum_ext}" - echo " Created: $file.{checksum_ext}" - fi - done"#, - pattern = pattern, - checksum_ext = checksum_ext, - checksum_cmd = checksum_cmd - )) - .collect::>() - .join("\n"); - - let script = format!( - r#"#!/bin/sh -set -e -cd /opt/_avocado/{target} - -echo "=== Generating checksums for extension images only ===" - -# Generate checksums ONLY for required extension .raw images -if [ -d output/extensions ]; then - echo "Checking output/extensions" - cd output/extensions - {pattern_checks} - cd /opt/_avocado/{target} -else - echo " output/extensions directory not found" -fi - -echo "=== Checksum generation complete ===" -"#, - target = target_arch, - pattern_checks = pattern_checks - ); - - // Run container with volume mounted read-write - let container_name = format!("avocado-checksum-gen-{}", uuid::Uuid::new_v4()); - let volume_mount = format!("{}:/opt/_avocado:rw", volume_state.volume_name); - - let run_cmd = [ - "docker", - "run", - "--rm", - "--name", - &container_name, - "-v", - &volume_mount, - sdk_image, - "sh", - "-c", - &script, - ]; - - let mut cmd = tokio::process::Command::new(run_cmd[0]); - cmd.args(&run_cmd[1..]); - cmd.stdout(std::process::Stdio::piped()); - cmd.stderr(std::process::Stdio::piped()); - - let output = cmd - .output() - .await - .context("Failed to run checksum generation")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("Checksum generation failed: {}", stderr); - } - - if self.verbose { - let stdout = String::from_utf8_lossy(&output.stdout); - print_info(&format!("Checksum generation output:\n{}", stdout), OutputLevel::Verbose); - } - - Ok(()) - } - - /// Extract checksums from volume by reading checksum files - async fn extract_checksums_from_volume( - &self, - checksum_algorithm: &ChecksumAlgorithm, - target_arch: &str, - ) -> Result { - if self.verbose { - print_info("Extracting checksums from volume...", OutputLevel::Verbose); - } - - // Get volume name - let volume_manager = - crate::utils::volume::VolumeManager::new("docker".to_string(), self.verbose); - let volume_state = volume_manager - .get_or_create_volume(&std::env::current_dir()?) - .await?; - - // Determine file extension based on algorithm - let file_ext = match checksum_algorithm { - ChecksumAlgorithm::Sha256 => "sha256", - ChecksumAlgorithm::Blake3 => "blake3", - }; - - // Create temp directory for extracted checksums - let temp_dir = tempfile::tempdir().context("Failed to create temp directory")?; - - // Create temporary container to extract checksum files - let container_name = format!("avocado-checksum-extract-{}", uuid::Uuid::new_v4()); - let volume_mount = format!("{}:/opt/_avocado:ro", volume_state.volume_name); - - let create_cmd = [ - "docker", - "create", - "--name", - &container_name, - "-v", - &volume_mount, - "busybox", - "true", - ]; - - let mut cmd = tokio::process::Command::new(create_cmd[0]); - cmd.args(&create_cmd[1..]); - let output = cmd - .output() - .await - .context("Failed to create extract container")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("Failed to create extract container: {}", stderr); - } - - // Extract checksum files using docker cp - let mut entries = Vec::new(); - - // Copy checksum files ONLY from extensions directory - let extensions_dir = format!("{}/output/extensions", target_arch); - - let search_dirs = vec![ - (extensions_dir.as_str(), "output/extensions"), - ]; - - for (source_dir, _) in &search_dirs { - print_info( - &format!("Attempting to copy checksums from: /opt/_avocado/{}", source_dir), - OutputLevel::Normal, - ); - - // Copy the entire directory (without trailing slash to copy the dir itself) - let container_path = format!("{}:/opt/_avocado/{}", container_name, source_dir); - let dest_path = temp_dir.path().to_str().unwrap(); - - let cp_cmd = ["docker", - "cp", - &container_path, - dest_path]; - - let mut cmd = tokio::process::Command::new(cp_cmd[0]); - cmd.args(&cp_cmd[1..]); - let output = cmd.output().await?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - print_info( - &format!(" ⚠ Docker cp failed: {}", stderr.trim()), - OutputLevel::Normal, - ); - } else { - print_info( - " ✓ Copied successfully", - OutputLevel::Normal, - ); - } - } - - // Clean up container - let _ = self.cleanup_container(&container_name).await; - - // Read extracted checksum files from all subdirectories - print_info( - &format!("Looking for .{} files in extracted directories...", file_ext), - OutputLevel::Normal, - ); - - // Docker cp copies just the final directory, not the full path - // So /opt/_avocado/qemuarm64/output/extensions becomes temp_dir/extensions - let dir_mapping = vec![ - (extensions_dir.as_str(), "extensions"), - ]; - - for (source_dir, dir_name) in &dir_mapping { - let search_path = temp_dir.path().join(dir_name); - - print_info( - &format!(" Scanning: {} -> {}", source_dir, search_path.display()), - OutputLevel::Normal, - ); - - if !search_path.exists() { - print_info( - " Directory not found", - OutputLevel::Normal, - ); - continue; - } - - if let Ok(dir_entries) = std::fs::read_dir(&search_path) { - let mut found_count = 0; - for entry in dir_entries.flatten() { - let path = entry.path(); - - if path.extension().and_then(|e| e.to_str()) == Some(file_ext) { - found_count += 1; - let checksum = std::fs::read_to_string(&path)?.trim().to_string(); - let image_name = path.file_stem().unwrap().to_str().unwrap(); - let size = 0; // Size not needed for signing - - // All checksums are from extensions directory - let container_path = format!("/opt/_avocado/{}/output/extensions/{}", target_arch, image_name); - - print_info( - &format!(" Found checksum: {}", image_name), - OutputLevel::Normal, - ); - - entries.push(crate::utils::image_signing::HashManifestEntry { - container_path, - hash: checksum, - size, - }); - } - } - - if found_count == 0 { - print_info( - &format!(" No .{} files found in this directory", file_ext), - OutputLevel::Normal, - ); - } - } - } - - Ok(crate::utils::image_signing::HashManifest { - runtime: self.runtime_name.clone(), - checksum_algorithm: checksum_algorithm.name().to_string(), - files: entries, - }) - } - - /// Write signatures to Docker volume - async fn write_signatures_to_volume( - &self, - signatures: &[crate::utils::image_signing::SignatureData], - ) -> Result<()> { - if self.verbose { - print_info( - &format!( - "Writing {} signature file(s) to volume...", - signatures.len() - ), - OutputLevel::Verbose, - ); - } - - // Get volume name - let volume_manager = - crate::utils::volume::VolumeManager::new("docker".to_string(), self.verbose); - let volume_state = volume_manager - .get_or_create_volume(&std::env::current_dir()?) - .await?; - - // Use SdkContainer's write_signatures_to_volume method - let container = SdkContainer::new().verbose(self.verbose); - container - .write_signatures_to_volume(&volume_state.volume_name, signatures) - .await?; - - Ok(()) - } - - /// Clean up a container - async fn cleanup_container(&self, container_name: &str) -> Result<()> { - let rm_cmd = ["docker", "rm", "-f", container_name]; - - let mut cmd = tokio::process::Command::new(rm_cmd[0]); - cmd.args(&rm_cmd[1..]); - let _ = cmd.output().await; - - Ok(()) - } } #[cfg(test)] diff --git a/src/commands/runtime/mod.rs b/src/commands/runtime/mod.rs index 641b400..824e347 100644 --- a/src/commands/runtime/mod.rs +++ b/src/commands/runtime/mod.rs @@ -6,8 +6,10 @@ pub mod dnf; pub mod install; pub mod list; pub mod provision; +pub mod sign; pub use build::RuntimeBuildCommand; +pub use sign::RuntimeSignCommand; #[allow(unused_imports)] pub use clean::RuntimeCleanCommand; #[allow(unused_imports)] diff --git a/src/commands/runtime/sign.rs b/src/commands/runtime/sign.rs new file mode 100644 index 0000000..0c658c9 --- /dev/null +++ b/src/commands/runtime/sign.rs @@ -0,0 +1,747 @@ +//! Runtime image signing command implementation. +//! +//! Signs runtime images (extension images) using configured signing keys. + +use crate::utils::{ + config::load_config, + container::SdkContainer, + image_signing::{validate_signing_key_for_use, ChecksumAlgorithm}, + output::{print_info, print_success, print_warning, OutputLevel}, + target::resolve_target_required, +}; +use anyhow::{Context, Result}; +use std::collections::HashSet; + +/// Command to sign runtime images +pub struct RuntimeSignCommand { + runtime_name: String, + config_path: String, + verbose: bool, + target: Option, + #[allow(dead_code)] // Included for API consistency with other commands + container_args: Option>, + #[allow(dead_code)] // Included for API consistency with other commands + dnf_args: Option>, +} + +impl RuntimeSignCommand { + pub fn new( + runtime_name: String, + config_path: String, + verbose: bool, + target: Option, + container_args: Option>, + dnf_args: Option>, + ) -> Self { + Self { + runtime_name, + config_path, + verbose, + target, + container_args, + dnf_args, + } + } + + pub async fn execute(&self) -> Result<()> { + // Load configuration + let config = load_config(&self.config_path)?; + let content = std::fs::read_to_string(&self.config_path)?; + let parsed: serde_yaml::Value = serde_yaml::from_str(&content)?; + + // Resolve target architecture + let target_arch = resolve_target_required(self.target.as_deref(), &config)?; + + print_info( + &format!( + "Signing runtime images for '{}' (target: {})", + self.runtime_name, target_arch + ), + OutputLevel::Normal, + ); + + // Verify runtime exists + let runtime_config = parsed + .get("runtime") + .context("No runtime configuration found")?; + + runtime_config.get(&self.runtime_name).with_context(|| { + format!("Runtime '{}' not found in configuration", self.runtime_name) + })?; + + // Get the list of required extensions for filtering + let merged_runtime = config + .get_merged_runtime_config(&self.runtime_name, &target_arch, &self.config_path)? + .with_context(|| { + format!( + "Runtime '{}' not found or has no configuration for target '{}'", + self.runtime_name, target_arch + ) + })?; + + let binding = serde_yaml::Mapping::new(); + let runtime_deps = merged_runtime + .get("dependencies") + .and_then(|v| v.as_mapping()) + .unwrap_or(&binding); + + let mut required_extensions = HashSet::new(); + for (_dep_name, dep_spec) in runtime_deps { + if let Some(ext_name) = dep_spec.get("ext").and_then(|v| v.as_str()) { + required_extensions.insert(ext_name.to_string()); + } + } + + let all_required_extensions = + self.find_all_extension_dependencies(&config, &required_extensions, &target_arch)?; + + // Sign images + self.sign_runtime_images(&config, &target_arch, &all_required_extensions) + .await?; + + print_success( + &format!("Successfully signed runtime '{}'", self.runtime_name), + OutputLevel::Normal, + ); + Ok(()) + } + + /// Recursively find all extension dependencies, including nested external extensions + fn find_all_extension_dependencies( + &self, + config: &crate::utils::config::Config, + direct_extensions: &HashSet, + target_arch: &str, + ) -> Result> { + let mut all_extensions = HashSet::new(); + let mut visited = HashSet::new(); + + // Process each direct extension dependency + for ext_name in direct_extensions { + self.collect_extension_dependencies( + config, + ext_name, + &mut all_extensions, + &mut visited, + target_arch, + )?; + } + + Ok(all_extensions) + } + + /// Recursively collect all dependencies for a single extension + fn collect_extension_dependencies( + &self, + config: &crate::utils::config::Config, + ext_name: &str, + all_extensions: &mut HashSet, + visited: &mut HashSet, + target_arch: &str, + ) -> Result<()> { + // Avoid infinite loops + if visited.contains(ext_name) { + return Ok(()); + } + visited.insert(ext_name.to_string()); + + // Add this extension to the result set + all_extensions.insert(ext_name.to_string()); + + // Load the main config to check for local extensions + let content = std::fs::read_to_string(&self.config_path)?; + let parsed: serde_yaml::Value = serde_yaml::from_str(&content)?; + + // Check if this is a local extension + if let Some(ext_config) = parsed + .get("ext") + .and_then(|e| e.as_mapping()) + .and_then(|table| table.get(ext_name)) + { + // This is a local extension - check its dependencies + if let Some(dependencies) = ext_config.get("dependencies").and_then(|d| d.as_mapping()) + { + for (_dep_name, dep_spec) in dependencies { + if let Some(nested_ext_name) = dep_spec.get("ext").and_then(|v| v.as_str()) { + // Check if this is an external extension dependency + if let Some(external_config_path) = + dep_spec.get("config").and_then(|v| v.as_str()) + { + // This is an external extension - load its config and process recursively + let external_extensions = config.load_external_extensions( + &self.config_path, + external_config_path, + )?; + + // Add the external extension itself + self.collect_extension_dependencies( + config, + nested_ext_name, + all_extensions, + visited, + target_arch, + )?; + + // Process its dependencies from the external config + if let Some(ext_config) = external_extensions.get(nested_ext_name) { + if let Some(nested_deps) = + ext_config.get("dependencies").and_then(|d| d.as_mapping()) + { + for (_nested_dep_name, nested_dep_spec) in nested_deps { + if let Some(nested_nested_ext_name) = + nested_dep_spec.get("ext").and_then(|v| v.as_str()) + { + self.collect_extension_dependencies( + config, + nested_nested_ext_name, + all_extensions, + visited, + target_arch, + )?; + } + } + } + } + } else { + // This is a local extension dependency + self.collect_extension_dependencies( + config, + nested_ext_name, + all_extensions, + visited, + target_arch, + )?; + } + } + } + } + } else { + // This might be an external extension - we need to find it in the runtime dependencies + // to get its config path, then process its dependencies + let merged_runtime = config + .get_merged_runtime_config(&self.runtime_name, target_arch, &self.config_path)? + .with_context(|| { + format!( + "Runtime '{}' not found or has no configuration for target '{}'", + self.runtime_name, target_arch + ) + })?; + + if let Some(runtime_deps) = merged_runtime + .get("dependencies") + .and_then(|v| v.as_mapping()) + { + for (_dep_name, dep_spec) in runtime_deps { + if let Some(dep_ext_name) = dep_spec.get("ext").and_then(|v| v.as_str()) { + if dep_ext_name == ext_name { + if let Some(external_config_path) = + dep_spec.get("config").and_then(|v| v.as_str()) + { + // Found the external extension - process its dependencies + let external_extensions = config.load_external_extensions( + &self.config_path, + external_config_path, + )?; + + if let Some(ext_config) = external_extensions.get(ext_name) { + if let Some(nested_deps) = + ext_config.get("dependencies").and_then(|d| d.as_mapping()) + { + for (_nested_dep_name, nested_dep_spec) in nested_deps { + if let Some(nested_ext_name) = + nested_dep_spec.get("ext").and_then(|v| v.as_str()) + { + self.collect_extension_dependencies( + config, + nested_ext_name, + all_extensions, + visited, + target_arch, + )?; + } + } + } + } + } + break; + } + } + } + } + } + + Ok(()) + } + + /// Sign runtime images using configured signing key + pub async fn sign_runtime_images( + &self, + config: &crate::utils::config::Config, + target_arch: &str, + required_extensions: &HashSet, + ) -> Result<()> { + // Check if runtime has signing configuration + let runtime_signing_key_name = match config.get_runtime_signing_key(&self.runtime_name) { + Some(keyid) => { + // Get the key name from signing_keys mapping + let signing_keys = config.get_signing_keys(); + signing_keys + .and_then(|keys| { + keys.iter() + .find(|(_, v)| *v == &keyid) + .map(|(k, _)| k.clone()) + }) + .context("Signing key ID not found in signing_keys mapping")? + } + None => { + // No signing configured for this runtime + print_warning( + &format!( + "No signing key configured for runtime '{}'. Skipping signing.", + self.runtime_name + ), + OutputLevel::Normal, + ); + return Ok(()); + } + }; + + // Get the keyid for signing + let keyid = config + .get_runtime_signing_key(&self.runtime_name) + .context("Failed to get signing key ID")?; + + // Get checksum algorithm (defaults to sha256) + let checksum_str = config + .runtime + .as_ref() + .and_then(|r| r.get(&self.runtime_name)) + .and_then(|rc| rc.signing.as_ref()) + .map(|s| s.checksum_algorithm.as_str()) + .unwrap_or("sha256"); + + let checksum_algorithm: ChecksumAlgorithm = checksum_str.parse()?; + + print_info( + &format!( + "Signing runtime images with key '{}' using {} checksums", + runtime_signing_key_name, + checksum_algorithm.name() + ), + OutputLevel::Normal, + ); + + // Validate the signing key is usable + validate_signing_key_for_use(&runtime_signing_key_name, &keyid)?; + + // Multi-pass signing workflow: + // 1. Run container to generate checksums and save as files + // 2. Extract checksums from volume + // 3. Sign checksums on host + // 4. Write signatures back to volume + + // Get SDK image for checksum generation + let sdk_image = config + .get_sdk_image() + .context("No SDK container image specified in configuration")?; + + // Step 1: Generate checksums in container (saved as .sha256 or .blake3 files) + self.generate_checksums_in_container( + &checksum_algorithm, + sdk_image, + target_arch, + required_extensions, + ) + .await?; + + // Step 2: Extract checksums from volume + let manifest = self + .extract_checksums_from_volume(&checksum_algorithm, target_arch) + .await?; + + if manifest.files.is_empty() { + print_warning( + &format!( + "No image files found to sign. Searched in: {}/output/extensions", + target_arch + ), + OutputLevel::Normal, + ); + print_info( + "This may indicate: (1) checksum generation failed, (2) docker cp failed, or (3) no required extensions have .raw images", + OutputLevel::Normal, + ); + return Ok(()); + } + + // Step 3: Sign checksums on host + let signatures = crate::utils::image_signing::sign_hash_manifest( + &manifest, + &runtime_signing_key_name, + &keyid, + )?; + + // Step 4: Write signatures back to volume + self.write_signatures_to_volume(&signatures).await?; + + print_success( + &format!("Signed {} image file(s)", signatures.len()), + OutputLevel::Normal, + ); + + Ok(()) + } + + /// Generate checksums for images in container using standard tools + async fn generate_checksums_in_container( + &self, + checksum_algorithm: &ChecksumAlgorithm, + sdk_image: &str, + target_arch: &str, + required_extensions: &HashSet, + ) -> Result<()> { + if self.verbose { + print_info("Generating checksums in container...", OutputLevel::Verbose); + } + + // Get volume name + let volume_manager = + crate::utils::volume::VolumeManager::new("docker".to_string(), self.verbose); + let volume_state = volume_manager + .get_or_create_volume(&std::env::current_dir()?) + .await?; + + // Determine checksum command and file extension based on algorithm + let (checksum_cmd, file_ext) = match checksum_algorithm { + ChecksumAlgorithm::Sha256 => ("sha256sum", "sha256"), + ChecksumAlgorithm::Blake3 => ("b3sum", "blake3"), + }; + + // Build shell script to generate checksums ONLY for required extension .raw images + let checksum_ext = file_ext; + + // Build list of extension patterns to checksum + let mut extension_patterns = Vec::new(); + for ext_name in required_extensions { + // Match both with and without version: ext-name-*.raw or ext-name.raw + extension_patterns.push(format!("{}-*.raw", ext_name)); + extension_patterns.push(format!("{}.raw", ext_name)); + } + + let pattern_checks = extension_patterns + .iter() + .map(|pattern| { + format!( + r#" + for file in {pattern}; do + if [ -f "$file" ] && [ ! -f "$file.{checksum_ext}" ]; then + echo " Generating checksum for: $file" + {checksum_cmd} "$file" | awk '{{print $1}}' > "$file.{checksum_ext}" + echo " Created: $file.{checksum_ext}" + fi + done"#, + pattern = pattern, + checksum_ext = checksum_ext, + checksum_cmd = checksum_cmd + ) + }) + .collect::>() + .join("\n"); + + let script = format!( + r#"#!/bin/sh +set -e +cd /opt/_avocado/{target} + +echo "=== Generating checksums for extension images only ===" + +# Generate checksums ONLY for required extension .raw images +if [ -d output/extensions ]; then + echo "Checking output/extensions" + cd output/extensions + {pattern_checks} + cd /opt/_avocado/{target} +else + echo " output/extensions directory not found" +fi + +echo "=== Checksum generation complete ===" +"#, + target = target_arch, + pattern_checks = pattern_checks + ); + + // Run container with volume mounted read-write + let container_name = format!("avocado-checksum-gen-{}", uuid::Uuid::new_v4()); + let volume_mount = format!("{}:/opt/_avocado:rw", volume_state.volume_name); + + let run_cmd = [ + "docker", + "run", + "--rm", + "--name", + &container_name, + "-v", + &volume_mount, + sdk_image, + "sh", + "-c", + &script, + ]; + + let mut cmd = tokio::process::Command::new(run_cmd[0]); + cmd.args(&run_cmd[1..]); + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + let output = cmd + .output() + .await + .context("Failed to run checksum generation")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Checksum generation failed: {}", stderr); + } + + if self.verbose { + let stdout = String::from_utf8_lossy(&output.stdout); + print_info( + &format!("Checksum generation output:\n{}", stdout), + OutputLevel::Verbose, + ); + } + + Ok(()) + } + + /// Extract checksums from volume by reading checksum files + async fn extract_checksums_from_volume( + &self, + checksum_algorithm: &ChecksumAlgorithm, + target_arch: &str, + ) -> Result { + if self.verbose { + print_info("Extracting checksums from volume...", OutputLevel::Verbose); + } + + // Get volume name + let volume_manager = + crate::utils::volume::VolumeManager::new("docker".to_string(), self.verbose); + let volume_state = volume_manager + .get_or_create_volume(&std::env::current_dir()?) + .await?; + + // Determine file extension based on algorithm + let file_ext = match checksum_algorithm { + ChecksumAlgorithm::Sha256 => "sha256", + ChecksumAlgorithm::Blake3 => "blake3", + }; + + // Create temp directory for extracted checksums + let temp_dir = tempfile::tempdir().context("Failed to create temp directory")?; + + // Create temporary container to extract checksum files + let container_name = format!("avocado-checksum-extract-{}", uuid::Uuid::new_v4()); + let volume_mount = format!("{}:/opt/_avocado:ro", volume_state.volume_name); + + let create_cmd = [ + "docker", + "create", + "--name", + &container_name, + "-v", + &volume_mount, + "busybox", + "true", + ]; + + let mut cmd = tokio::process::Command::new(create_cmd[0]); + cmd.args(&create_cmd[1..]); + let output = cmd + .output() + .await + .context("Failed to create extract container")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Failed to create extract container: {}", stderr); + } + + // Extract checksum files using docker cp + let mut entries = Vec::new(); + + // Copy checksum files ONLY from extensions directory + let extensions_dir = format!("{}/output/extensions", target_arch); + + let search_dirs = vec![(extensions_dir.as_str(), "output/extensions")]; + + for (source_dir, _) in &search_dirs { + print_info( + &format!( + "Attempting to copy checksums from: /opt/_avocado/{}", + source_dir + ), + OutputLevel::Normal, + ); + + // Copy the entire directory (without trailing slash to copy the dir itself) + let container_path = format!("{}:/opt/_avocado/{}", container_name, source_dir); + let dest_path = temp_dir.path().to_str().unwrap(); + + let cp_cmd = ["docker", "cp", &container_path, dest_path]; + + let mut cmd = tokio::process::Command::new(cp_cmd[0]); + cmd.args(&cp_cmd[1..]); + let output = cmd.output().await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + print_info( + &format!(" ⚠ Docker cp failed: {}", stderr.trim()), + OutputLevel::Normal, + ); + } else { + print_info(" ✓ Copied successfully", OutputLevel::Normal); + } + } + + // Clean up container + let _ = self.cleanup_container(&container_name).await; + + // Read extracted checksum files from all subdirectories + print_info( + &format!( + "Looking for .{} files in extracted directories...", + file_ext + ), + OutputLevel::Normal, + ); + + // Docker cp copies just the final directory, not the full path + // So /opt/_avocado/qemuarm64/output/extensions becomes temp_dir/extensions + let dir_mapping = vec![(extensions_dir.as_str(), "extensions")]; + + for (source_dir, dir_name) in &dir_mapping { + let search_path = temp_dir.path().join(dir_name); + + print_info( + &format!(" Scanning: {} -> {}", source_dir, search_path.display()), + OutputLevel::Normal, + ); + + if !search_path.exists() { + print_info(" Directory not found", OutputLevel::Normal); + continue; + } + + if let Ok(dir_entries) = std::fs::read_dir(&search_path) { + let mut found_count = 0; + for entry in dir_entries.flatten() { + let path = entry.path(); + + if path.extension().and_then(|e| e.to_str()) == Some(file_ext) { + found_count += 1; + let checksum = std::fs::read_to_string(&path)?.trim().to_string(); + let image_name = path.file_stem().unwrap().to_str().unwrap(); + let size = 0; // Size not needed for signing + + // All checksums are from extensions directory + let container_path = format!( + "/opt/_avocado/{}/output/extensions/{}", + target_arch, image_name + ); + + print_info( + &format!(" Found checksum: {}", image_name), + OutputLevel::Normal, + ); + + entries.push(crate::utils::image_signing::HashManifestEntry { + container_path, + hash: checksum, + size, + }); + } + } + + if found_count == 0 { + print_info( + &format!(" No .{} files found in this directory", file_ext), + OutputLevel::Normal, + ); + } + } + } + + Ok(crate::utils::image_signing::HashManifest { + runtime: self.runtime_name.clone(), + checksum_algorithm: checksum_algorithm.name().to_string(), + files: entries, + }) + } + + /// Write signatures to Docker volume + async fn write_signatures_to_volume( + &self, + signatures: &[crate::utils::image_signing::SignatureData], + ) -> Result<()> { + if self.verbose { + print_info( + &format!( + "Writing {} signature file(s) to volume...", + signatures.len() + ), + OutputLevel::Verbose, + ); + } + + // Get volume name + let volume_manager = + crate::utils::volume::VolumeManager::new("docker".to_string(), self.verbose); + let volume_state = volume_manager + .get_or_create_volume(&std::env::current_dir()?) + .await?; + + // Use SdkContainer's write_signatures_to_volume method + let container = SdkContainer::new().verbose(self.verbose); + container + .write_signatures_to_volume(&volume_state.volume_name, signatures) + .await?; + + Ok(()) + } + + /// Clean up a container + async fn cleanup_container(&self, container_name: &str) -> Result<()> { + let rm_cmd = ["docker", "rm", "-f", container_name]; + + let mut cmd = tokio::process::Command::new(rm_cmd[0]); + cmd.args(&rm_cmd[1..]); + let _ = cmd.output().await; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new() { + let cmd = RuntimeSignCommand::new( + "test-runtime".to_string(), + "avocado.yaml".to_string(), + false, + Some("x86_64".to_string()), + None, + None, + ); + + assert_eq!(cmd.runtime_name, "test-runtime"); + assert_eq!(cmd.config_path, "avocado.yaml"); + assert!(!cmd.verbose); + assert_eq!(cmd.target, Some("x86_64".to_string())); + } +} diff --git a/src/commands/sign.rs b/src/commands/sign.rs new file mode 100644 index 0000000..e8d3077 --- /dev/null +++ b/src/commands/sign.rs @@ -0,0 +1,224 @@ +//! Sign command implementation that signs runtime images. +//! +//! This is a convenience command that wraps `avocado runtime sign`. +//! It signs all runtimes with signing configuration, or a specific runtime with `-r`. + +use anyhow::{Context, Result}; + +use crate::commands::runtime::RuntimeSignCommand; +use crate::utils::{ + config::Config, + output::{print_info, print_success, OutputLevel}, +}; + +/// Implementation of the 'sign' command that signs runtime images. +pub struct SignCommand { + /// Path to configuration file + pub config_path: String, + /// Enable verbose output + pub verbose: bool, + /// Runtime name to sign (if not provided, signs all runtimes with signing config) + pub runtime: Option, + /// Global target architecture + pub target: Option, + /// Additional arguments to pass to the container runtime + pub container_args: Option>, + /// Additional arguments to pass to DNF commands + pub dnf_args: Option>, +} + +impl SignCommand { + /// Create a new SignCommand instance + pub fn new( + config_path: String, + verbose: bool, + runtime: Option, + target: Option, + container_args: Option>, + dnf_args: Option>, + ) -> Self { + Self { + config_path, + verbose, + runtime, + target, + container_args, + dnf_args, + } + } + + /// Execute the sign command + pub async fn execute(&self) -> Result<()> { + // Load the configuration + let config = Config::load(&self.config_path) + .with_context(|| format!("Failed to load config from {}", self.config_path))?; + + // Early target validation and logging - fail fast if target is unsupported + let target = + crate::utils::target::validate_and_log_target(self.target.as_deref(), &config)?; + + // If a specific runtime is requested, sign only that runtime + if let Some(ref runtime_name) = self.runtime { + return self.sign_single_runtime(runtime_name, &target).await; + } + + // Otherwise, sign all runtimes that have signing configuration + self.sign_all_runtimes(&config, &target).await + } + + /// Sign a single runtime + async fn sign_single_runtime(&self, runtime_name: &str, target: &str) -> Result<()> { + print_info( + &format!( + "Signing runtime '{}' for target '{}'", + runtime_name, target + ), + OutputLevel::Normal, + ); + + let sign_cmd = RuntimeSignCommand::new( + runtime_name.to_string(), + self.config_path.clone(), + self.verbose, + Some(target.to_string()), + self.container_args.clone(), + self.dnf_args.clone(), + ); + + sign_cmd.execute().await.with_context(|| { + format!("Failed to sign runtime '{}'", runtime_name) + })?; + + Ok(()) + } + + /// Sign all runtimes that have signing configuration + async fn sign_all_runtimes(&self, config: &Config, target: &str) -> Result<()> { + let content = std::fs::read_to_string(&self.config_path)?; + let parsed: serde_yaml::Value = serde_yaml::from_str(&content)?; + + let runtime_section = parsed + .get("runtime") + .and_then(|r| r.as_mapping()) + .ok_or_else(|| anyhow::anyhow!("No runtime configuration found"))?; + + // Collect runtimes that have signing configuration + let mut runtimes_to_sign = Vec::new(); + + for runtime_name_val in runtime_section.keys() { + if let Some(runtime_name) = runtime_name_val.as_str() { + // Check if this runtime has signing configuration + if config.get_runtime_signing_key(runtime_name).is_some() { + // Check target compatibility + let merged_runtime = config.get_merged_runtime_config( + runtime_name, + target, + &self.config_path, + )?; + + if let Some(merged_value) = merged_runtime { + // Check if runtime has explicit target + if let Some(runtime_target) = + merged_value.get("target").and_then(|t| t.as_str()) + { + // Runtime has explicit target - only include if it matches + if runtime_target == target { + runtimes_to_sign.push(runtime_name.to_string()); + } + } else { + // Runtime has no target specified - include for all targets + runtimes_to_sign.push(runtime_name.to_string()); + } + } + } + } + } + + if runtimes_to_sign.is_empty() { + print_info( + "No runtimes with signing configuration found.", + OutputLevel::Normal, + ); + return Ok(()); + } + + print_info( + &format!( + "Signing {} runtime(s) with signing configuration...", + runtimes_to_sign.len() + ), + OutputLevel::Normal, + ); + + for runtime_name in &runtimes_to_sign { + if self.verbose { + print_info( + &format!("Signing runtime '{}'", runtime_name), + OutputLevel::Normal, + ); + } + + let sign_cmd = RuntimeSignCommand::new( + runtime_name.clone(), + self.config_path.clone(), + self.verbose, + Some(target.to_string()), + self.container_args.clone(), + self.dnf_args.clone(), + ); + + sign_cmd.execute().await.with_context(|| { + format!("Failed to sign runtime '{}'", runtime_name) + })?; + } + + print_success( + &format!("Successfully signed {} runtime(s)!", runtimes_to_sign.len()), + OutputLevel::Normal, + ); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new() { + let cmd = SignCommand::new( + "avocado.yaml".to_string(), + true, + Some("my-runtime".to_string()), + Some("x86_64".to_string()), + Some(vec!["--privileged".to_string()]), + Some(vec!["--nogpgcheck".to_string()]), + ); + + assert_eq!(cmd.config_path, "avocado.yaml"); + assert!(cmd.verbose); + assert_eq!(cmd.runtime, Some("my-runtime".to_string())); + assert_eq!(cmd.target, Some("x86_64".to_string())); + assert_eq!(cmd.container_args, Some(vec!["--privileged".to_string()])); + assert_eq!(cmd.dnf_args, Some(vec!["--nogpgcheck".to_string()])); + } + + #[test] + fn test_new_all_runtimes() { + let cmd = SignCommand::new( + "config.toml".to_string(), + false, + None, + None, + None, + None, + ); + + assert_eq!(cmd.config_path, "config.toml"); + assert!(!cmd.verbose); + assert_eq!(cmd.runtime, None); + assert_eq!(cmd.target, None); + assert_eq!(cmd.container_args, None); + assert_eq!(cmd.dnf_args, None); + } +} diff --git a/src/main.rs b/src/main.rs index 725b458..cd3c96b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,11 +19,13 @@ use commands::provision::ProvisionCommand; use commands::runtime::{ RuntimeBuildCommand, RuntimeCleanCommand, RuntimeDeployCommand, RuntimeDepsCommand, RuntimeDnfCommand, RuntimeInstallCommand, RuntimeListCommand, RuntimeProvisionCommand, + RuntimeSignCommand, }; use commands::sdk::{ SdkCleanCommand, SdkCompileCommand, SdkDepsCommand, SdkDnfCommand, SdkInstallCommand, SdkRunCommand, }; +use commands::sign::SignCommand; use commands::signing_keys::{ SigningKeysCreateCommand, SigningKeysListCommand, SigningKeysRemoveCommand, }; @@ -240,6 +242,27 @@ enum Commands { #[command(subcommand)] command: SigningKeysCommands, }, + /// Sign runtime images (shortcut for 'runtime sign') + Sign { + /// Path to avocado.yaml configuration file + #[arg(short = 'C', long, default_value = "avocado.yaml")] + config: String, + /// Enable verbose output + #[arg(short, long)] + verbose: bool, + /// Runtime name to sign (if not provided, signs all runtimes with signing config) + #[arg(short = 'r', long = "runtime")] + runtime: Option, + /// Target architecture + #[arg(short, long)] + target: Option, + /// Additional arguments to pass to the container runtime + #[arg(long = "container-arg", num_args = 1, allow_hyphen_values = true, action = clap::ArgAction::Append)] + container_args: Option>, + /// Additional arguments to pass to DNF commands + #[arg(long = "dnf-arg", num_args = 1, allow_hyphen_values = true, action = clap::ArgAction::Append)] + dnf_args: Option>, + }, } #[derive(Subcommand)] @@ -578,6 +601,27 @@ enum RuntimeCommands { #[arg(long = "dnf-arg", num_args = 1, allow_hyphen_values = true, action = clap::ArgAction::Append)] dnf_args: Option>, }, + /// Sign runtime images + Sign { + /// Path to avocado.yaml configuration file + #[arg(short = 'C', long, default_value = "avocado.yaml")] + config: String, + /// Enable verbose output + #[arg(short, long)] + verbose: bool, + /// Runtime name to sign + #[arg(short = 'r', long = "runtime", required = true)] + runtime: String, + /// Target architecture + #[arg(short, long)] + target: Option, + /// Additional arguments to pass to the container runtime + #[arg(long = "container-arg", num_args = 1, allow_hyphen_values = true, action = clap::ArgAction::Append)] + container_args: Option>, + /// Additional arguments to pass to DNF commands + #[arg(long = "dnf-arg", num_args = 1, allow_hyphen_values = true, action = clap::ArgAction::Append)] + dnf_args: Option>, + }, } /// Parse environment variable arguments in the format "KEY=VALUE" into a HashMap @@ -784,6 +828,25 @@ async fn main() -> Result<()> { Ok(()) } }, + Commands::Sign { + config, + verbose, + runtime, + target, + container_args, + dnf_args, + } => { + let sign_cmd = SignCommand::new( + config, + verbose, + runtime, + target.or(cli.target), + container_args, + dnf_args, + ); + sign_cmd.execute().await?; + Ok(()) + } Commands::Runtime { command } => match command { RuntimeCommands::Install { runtime, @@ -930,6 +993,25 @@ async fn main() -> Result<()> { deploy_cmd.execute().await?; Ok(()) } + RuntimeCommands::Sign { + config, + verbose, + runtime, + target, + container_args, + dnf_args, + } => { + let sign_cmd = RuntimeSignCommand::new( + runtime, + config, + verbose, + target.or(cli.target), + container_args, + dnf_args, + ); + sign_cmd.execute().await?; + Ok(()) + } }, Commands::Ext { command } => match command { ExtCommands::Install { From 0f2d9d36fad1948347f1ac9ffb258cd7222c3795 Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Wed, 17 Dec 2025 17:11:24 -0500 Subject: [PATCH 07/12] copy ext to runtime on build --- docs/signing-keys.md | 13 +++--- src/commands/runtime/build.rs | 78 +++++++++++++++++++++++++++-------- src/commands/runtime/mod.rs | 2 +- src/commands/runtime/sign.rs | 32 +++++++------- src/commands/sign.rs | 28 +++++-------- src/utils/image_signing.rs | 7 +++- 6 files changed, 102 insertions(+), 58 deletions(-) diff --git a/docs/signing-keys.md b/docs/signing-keys.md index 2328463..d50c4c9 100644 --- a/docs/signing-keys.md +++ b/docs/signing-keys.md @@ -172,7 +172,7 @@ The signing process is split into three distinct passes to support both file-bas │ 2. Computes checksums ONLY for required extension .raw files│ │ using sha256sum or b3sum │ │ 3. Saves checksums as .sha256 or .blake3 files next to │ -│ each image in output/extensions/ │ +│ each image in runtimes//extensions/ │ └─────────────────────────────────────────────────────────────┘ ↓ (checksum files) ┌─────────────────────────────────────────────────────────────┐ @@ -220,7 +220,7 @@ This architecture solves a key challenge: **Docker volumes are not directly acce 1. **Checksum Generation**: - Uses standard container utilities (`sha256sum` or `b3sum`) - - Only checksums `.raw` files in `output/extensions/` directory + - Only checksums `.raw` files in `runtimes//extensions/` directory - Only checksums extensions that are dependencies of the runtime being built - Skips files that already have checksum files to avoid recursive checksumming - Saves checksums as `.sha256` or `.blake3` files next to each image @@ -237,7 +237,7 @@ This architecture solves a key challenge: **Docker volumes are not directly acce "checksum_algorithm": "blake3", "files": [ { - "container_path": "/opt/_avocado/qemuarm64/output/extensions/bootfiles.raw", + "container_path": "/opt/_avocado/qemuarm64/runtimes/production/extensions/bootfiles.raw", "hash": "abc123...", "size": 1048576 } @@ -269,11 +269,14 @@ Signature files are JSON format containing: ### Signed Files The following files are signed during runtime builds: -- **Extension images only**: `$AVOCADO_PREFIX//output/extensions/*.raw` +- **Extension images only**: `$AVOCADO_PREFIX//runtimes//extensions/*.raw` - Only extensions that are dependencies of the runtime being built are signed - Each extension's `.raw` image file gets a corresponding `.sig` signature file + - Extensions are copied from `output/extensions/` to the runtime-specific directory during build -Where `` is the target architecture (e.g., `qemuarm64`, `x86_64-unknown-linux-gnu`). +Where: +- `` is the target architecture (e.g., `qemuarm64`, `x86_64-unknown-linux-gnu`) +- `` is the name of the runtime being built (e.g., `production`, `staging`) **Note**: Currently, only extension `.raw` images are signed. Stone-generated runtime images and var images are not signed in this version. diff --git a/src/commands/runtime/build.rs b/src/commands/runtime/build.rs index b8ed1e9..c63f956 100644 --- a/src/commands/runtime/build.rs +++ b/src/commands/runtime/build.rs @@ -192,6 +192,9 @@ impl RuntimeBuildCommand { let all_required_extensions = self.find_all_extension_dependencies(&config, &required_extensions, target_arch)?; + // Build copy commands for required extensions + let mut copy_commands = Vec::new(); + // Build extension symlink commands from config let mut symlink_commands = Vec::new(); let mut processed_extensions = HashSet::new(); @@ -208,14 +211,25 @@ impl RuntimeBuildCommand { .and_then(|v| v.as_str()) .unwrap_or("0.1.0"); + // Add copy command for this extension + copy_commands.push(format!( + r#" +# Copy {ext_name}-{ext_version}.raw from output/extensions to runtime-specific directory +if [ -f "$AVOCADO_PREFIX/output/extensions/{ext_name}-{ext_version}.raw" ]; then + cp -f "$AVOCADO_PREFIX/output/extensions/{ext_name}-{ext_version}.raw" "$RUNTIME_EXT_DIR/{ext_name}-{ext_version}.raw" + echo " Copied: {ext_name}-{ext_version}.raw" +fi"# + )); + symlink_commands.push(format!( r#" -OUTPUT_EXT=$AVOCADO_PREFIX/output/extensions/{ext_name}-{ext_version}.raw +# Link from runtime-specific extensions directory +RUNTIME_EXT=$RUNTIME_EXT_DIR/{ext_name}-{ext_version}.raw RUNTIMES_EXT=$VAR_DIR/lib/avocado/extensions/{ext_name}-{ext_version}.raw -if [ -f "$OUTPUT_EXT" ]; then - if ! cmp -s "$OUTPUT_EXT" "$RUNTIMES_EXT" 2>/dev/null; then - ln -f $OUTPUT_EXT $RUNTIMES_EXT +if [ -f "$RUNTIME_EXT" ]; then + if ! cmp -s "$RUNTIME_EXT" "$RUNTIMES_EXT" 2>/dev/null; then + ln -f $RUNTIME_EXT $RUNTIMES_EXT fi else echo "Missing image for extension {ext_name}-{ext_version}." @@ -231,15 +245,28 @@ fi"# for ext_name in &all_required_extensions { if !processed_extensions.contains(ext_name) { // This is an external extension - use wildcard to find versioned file + + // Add copy command for external extension + copy_commands.push(format!( + r#" +# Copy external extension {ext_name} with any version +EXT_FILE=$(ls "$AVOCADO_PREFIX/output/extensions/{ext_name}"-*.raw 2>/dev/null | head -n 1) +if [ -n "$EXT_FILE" ]; then + EXT_BASENAME=$(basename "$EXT_FILE") + cp -f "$EXT_FILE" "$RUNTIME_EXT_DIR/$EXT_BASENAME" + echo " Copied: $EXT_BASENAME" +fi"# + )); + symlink_commands.push(format!( r#" -# Find external extension {ext_name} with any version -OUTPUT_EXT=$(ls $AVOCADO_PREFIX/output/extensions/{ext_name}-*.raw 2>/dev/null | head -n 1) -if [ -n "$OUTPUT_EXT" ]; then - EXT_FILENAME=$(basename "$OUTPUT_EXT") +# Find external extension {ext_name} with any version from runtime-specific directory +RUNTIME_EXT=$(ls $RUNTIME_EXT_DIR/{ext_name}-*.raw 2>/dev/null | head -n 1) +if [ -n "$RUNTIME_EXT" ]; then + EXT_FILENAME=$(basename "$RUNTIME_EXT") RUNTIMES_EXT=$VAR_DIR/lib/avocado/extensions/$EXT_FILENAME - if ! cmp -s "$OUTPUT_EXT" "$RUNTIMES_EXT" 2>/dev/null; then - ln -f "$OUTPUT_EXT" "$RUNTIMES_EXT" + if ! cmp -s "$RUNTIME_EXT" "$RUNTIMES_EXT" 2>/dev/null; then + ln -f "$RUNTIME_EXT" "$RUNTIMES_EXT" fi else echo "Missing image for external extension {ext_name}." @@ -248,6 +275,12 @@ fi"# } } + let copy_section = if copy_commands.is_empty() { + "# No extensions to copy".to_string() + } else { + copy_commands.join("\n") + }; + let symlink_section = if symlink_commands.is_empty() { "# No extensions configured for symlinking".to_string() } else { @@ -281,6 +314,14 @@ mkdir -p "$VAR_DIR/lib/avocado/os-releases/$VERSION_ID" OUTPUT_DIR="$AVOCADO_PREFIX/runtimes/$RUNTIME_NAME" mkdir -p $OUTPUT_DIR +# Create runtime-specific extensions directory +RUNTIME_EXT_DIR="$AVOCADO_PREFIX/runtimes/$RUNTIME_NAME/extensions" +mkdir -p "$RUNTIME_EXT_DIR" + +# Copy required extension images from global output/extensions to runtime-specific location +echo "Copying required extension images to runtime-specific directory..." +{} + {} # Create symlinks in os-releases/ pointing to enabled extensions @@ -306,7 +347,7 @@ mkfs.btrfs -r "$VAR_DIR" \ echo -e "\033[94m[INFO]\033[0m Running SDK lifecycle hook 'avocado-build' for '$TARGET_ARCH'." avocado-build-$TARGET_ARCH $RUNTIME_NAME "#, - self.runtime_name, target_arch, symlink_section + self.runtime_name, target_arch, copy_section, symlink_section ); Ok(script) @@ -577,8 +618,9 @@ ext: let script = cmd.create_build_script(&parsed, "x86_64").unwrap(); assert!(script.contains("test-ext-1.0.0.raw")); - // Extension should be copied to avocado extensions directory but not symlinked to systemd directories - assert!(script.contains("$AVOCADO_PREFIX/output/extensions/test-ext-1.0.0.raw")); + // Extension should be copied from output/extensions to runtime-specific extensions directory + assert!(script.contains("$AVOCADO_PREFIX/output/extensions")); + assert!(script.contains("$RUNTIME_EXT_DIR/test-ext-1.0.0.raw")); assert!(script.contains("$VAR_DIR/lib/avocado/extensions/test-ext-1.0.0.raw")); } @@ -618,8 +660,9 @@ ext: let script = cmd.create_build_script(&parsed, "x86_64").unwrap(); - // Extension should be copied to avocado extensions directory but not symlinked to systemd directories - assert!(script.contains("$AVOCADO_PREFIX/output/extensions/test-ext-1.0.0.raw")); + // Extension should be copied from output/extensions to runtime-specific directory + assert!(script.contains("$AVOCADO_PREFIX/output/extensions")); + assert!(script.contains("$RUNTIME_EXT_DIR/test-ext-1.0.0.raw")); assert!(script.contains("$VAR_DIR/lib/avocado/extensions/test-ext-1.0.0.raw")); // Should NOT include symlinks to systemd directories (runtime will handle this) assert!(!script.contains("ln -sf /var/lib/avocado/extensions/test-ext-1.0.0.raw $SYSEXT")); @@ -659,8 +702,9 @@ ext: let script = cmd.create_build_script(&parsed, "x86_64").unwrap(); - // Extension should be copied to avocado extensions directory but not symlinked to systemd directories - assert!(script.contains("$AVOCADO_PREFIX/output/extensions/test-ext-1.0.0.raw")); + // Extension should be copied from output/extensions to runtime-specific directory + assert!(script.contains("$AVOCADO_PREFIX/output/extensions")); + assert!(script.contains("$RUNTIME_EXT_DIR/test-ext-1.0.0.raw")); assert!(script.contains("$VAR_DIR/lib/avocado/extensions/test-ext-1.0.0.raw")); // Should NOT include symlinks to systemd directories (runtime will handle this) assert!(!script.contains("ln -sf /var/lib/avocado/extensions/test-ext-1.0.0.raw $SYSEXT")); diff --git a/src/commands/runtime/mod.rs b/src/commands/runtime/mod.rs index 824e347..08fbe05 100644 --- a/src/commands/runtime/mod.rs +++ b/src/commands/runtime/mod.rs @@ -9,7 +9,6 @@ pub mod provision; pub mod sign; pub use build::RuntimeBuildCommand; -pub use sign::RuntimeSignCommand; #[allow(unused_imports)] pub use clean::RuntimeCleanCommand; #[allow(unused_imports)] @@ -22,3 +21,4 @@ pub use install::RuntimeInstallCommand; #[allow(unused_imports)] pub use list::RuntimeListCommand; pub use provision::RuntimeProvisionCommand; +pub use sign::RuntimeSignCommand; diff --git a/src/commands/runtime/sign.rs b/src/commands/runtime/sign.rs index 0c658c9..f4b9121 100644 --- a/src/commands/runtime/sign.rs +++ b/src/commands/runtime/sign.rs @@ -362,8 +362,8 @@ impl RuntimeSignCommand { if manifest.files.is_empty() { print_warning( &format!( - "No image files found to sign. Searched in: {}/output/extensions", - target_arch + "No image files found to sign. Searched in: {}/runtimes/{}/extensions", + target_arch, self.runtime_name ), OutputLevel::Normal, ); @@ -453,21 +453,23 @@ impl RuntimeSignCommand { set -e cd /opt/_avocado/{target} -echo "=== Generating checksums for extension images only ===" +echo "=== Generating checksums for extension images in runtime-specific directory ===" -# Generate checksums ONLY for required extension .raw images -if [ -d output/extensions ]; then - echo "Checking output/extensions" - cd output/extensions +# Generate checksums ONLY for required extension .raw images in runtime-specific directory +RUNTIME_EXT_DIR="runtimes/{runtime}/extensions" +if [ -d "$RUNTIME_EXT_DIR" ]; then + echo "Checking $RUNTIME_EXT_DIR" + cd "$RUNTIME_EXT_DIR" {pattern_checks} cd /opt/_avocado/{target} else - echo " output/extensions directory not found" + echo " Runtime extensions directory not found: $RUNTIME_EXT_DIR" fi echo "=== Checksum generation complete ===" "#, target = target_arch, + runtime = self.runtime_name, pattern_checks = pattern_checks ); @@ -571,10 +573,10 @@ echo "=== Checksum generation complete ===" // Extract checksum files using docker cp let mut entries = Vec::new(); - // Copy checksum files ONLY from extensions directory - let extensions_dir = format!("{}/output/extensions", target_arch); + // Copy checksum files from runtime-specific extensions directory + let extensions_dir = format!("{}/runtimes/{}/extensions", target_arch, self.runtime_name); - let search_dirs = vec![(extensions_dir.as_str(), "output/extensions")]; + let search_dirs = vec![(extensions_dir.as_str(), "runtimes/extensions")]; for (source_dir, _) in &search_dirs { print_info( @@ -619,7 +621,7 @@ echo "=== Checksum generation complete ===" ); // Docker cp copies just the final directory, not the full path - // So /opt/_avocado/qemuarm64/output/extensions becomes temp_dir/extensions + // So /opt/_avocado/qemuarm64/runtimes//extensions becomes temp_dir/extensions let dir_mapping = vec![(extensions_dir.as_str(), "extensions")]; for (source_dir, dir_name) in &dir_mapping { @@ -646,10 +648,10 @@ echo "=== Checksum generation complete ===" let image_name = path.file_stem().unwrap().to_str().unwrap(); let size = 0; // Size not needed for signing - // All checksums are from extensions directory + // All checksums are from runtime-specific extensions directory let container_path = format!( - "/opt/_avocado/{}/output/extensions/{}", - target_arch, image_name + "/opt/_avocado/{}/runtimes/{}/extensions/{}", + target_arch, self.runtime_name, image_name ); print_info( diff --git a/src/commands/sign.rs b/src/commands/sign.rs index e8d3077..c0a3b6c 100644 --- a/src/commands/sign.rs +++ b/src/commands/sign.rs @@ -69,10 +69,7 @@ impl SignCommand { /// Sign a single runtime async fn sign_single_runtime(&self, runtime_name: &str, target: &str) -> Result<()> { print_info( - &format!( - "Signing runtime '{}' for target '{}'", - runtime_name, target - ), + &format!("Signing runtime '{}' for target '{}'", runtime_name, target), OutputLevel::Normal, ); @@ -85,9 +82,10 @@ impl SignCommand { self.dnf_args.clone(), ); - sign_cmd.execute().await.with_context(|| { - format!("Failed to sign runtime '{}'", runtime_name) - })?; + sign_cmd + .execute() + .await + .with_context(|| format!("Failed to sign runtime '{}'", runtime_name))?; Ok(()) } @@ -167,9 +165,10 @@ impl SignCommand { self.dnf_args.clone(), ); - sign_cmd.execute().await.with_context(|| { - format!("Failed to sign runtime '{}'", runtime_name) - })?; + sign_cmd + .execute() + .await + .with_context(|| format!("Failed to sign runtime '{}'", runtime_name))?; } print_success( @@ -205,14 +204,7 @@ mod tests { #[test] fn test_new_all_runtimes() { - let cmd = SignCommand::new( - "config.toml".to_string(), - false, - None, - None, - None, - None, - ); + let cmd = SignCommand::new("config.toml".to_string(), false, None, None, None, None); assert_eq!(cmd.config_path, "config.toml"); assert!(!cmd.verbose); diff --git a/src/utils/image_signing.rs b/src/utils/image_signing.rs index efed2bf..1c38dcb 100644 --- a/src/utils/image_signing.rs +++ b/src/utils/image_signing.rs @@ -204,8 +204,11 @@ pub fn sign_runtime_images( ) -> Result> { let mut signed_files = Vec::new(); - // Sign extension images in output/extensions/ - let ext_dir = avocado_prefix.join("output/extensions"); + // Sign extension images in runtime-specific extensions directory + let ext_dir = avocado_prefix + .join("runtimes") + .join(runtime_name) + .join("extensions"); if ext_dir.exists() { for entry in fs::read_dir(&ext_dir).with_context(|| { format!("Failed to read extensions directory: {}", ext_dir.display()) From 345793c5c546d204dd29a577e6e13b1ea0373f2c Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Wed, 17 Dec 2025 17:28:01 -0500 Subject: [PATCH 08/12] bump deps to latest versions --- Cargo.lock | 324 +++++++++++++++++------------------------------------ Cargo.toml | 10 +- 2 files changed, 110 insertions(+), 224 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6a854dc..8166b9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,7 +154,7 @@ dependencies = [ "sha2", "tar", "tempfile", - "thiserror 1.0.69", + "thiserror", "tokio", "tokio-test", "toml", @@ -210,9 +210,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytes" @@ -222,9 +222,9 @@ checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "cc" -version = "1.2.48" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", "shlex", @@ -304,9 +304,9 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "console" -version = "0.16.1" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b430743a6eb14e9764d4260d4c0d8123087d504eeb9c48f2b2a5e810dd369df4" +checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" dependencies = [ "encode_unicode", "libc", @@ -363,9 +363,9 @@ dependencies = [ [[package]] name = "cryptoki" -version = "0.6.2" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9123ecc6a29329cd3f852e6e6814f302ed777820e1eb60b098b89aee0eb91b" +checksum = "781357a7779a8e92ea985121bbf379a9adf0777f44ab6392efc6abd5aa9b67db" dependencies = [ "bitflags 1.3.2", "cryptoki-sys", @@ -377,9 +377,9 @@ dependencies = [ [[package]] name = "cryptoki-sys" -version = "0.1.8" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "750380200f47d4ff677be725b6e0d78b590e1d0343573dcd4b62147f25dc6efa" +checksum = "753e27d860277930ae9f394c119c8c70303236aab0ffab1d51f3d207dbb2bc4b" dependencies = [ "libloading", ] @@ -411,19 +411,6 @@ dependencies = [ "syn", ] -[[package]] -name = "dashmap" -version = "5.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" -dependencies = [ - "cfg-if", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - [[package]] name = "der" version = "0.7.10" @@ -446,23 +433,23 @@ dependencies = [ [[package]] name = "directories" -version = "5.0.1" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" dependencies = [ "dirs-sys", ] [[package]] name = "dirs-sys" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -698,12 +685,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - [[package]] name = "hashbrown" version = "0.16.1" @@ -795,9 +776,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ "base64", "bytes", @@ -889,9 +870,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -903,9 +884,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -950,7 +931,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown", ] [[package]] @@ -1004,37 +985,31 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "libc" -version = "0.2.177" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "libloading" -version = "0.7.4" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "winapi", + "windows-link", ] [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50" dependencies = [ "bitflags 2.10.0", "libc", - "redox_syscall", + "redox_syscall 0.6.0", ] [[package]] @@ -1060,9 +1035,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru-slab" @@ -1088,9 +1063,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", @@ -1142,7 +1117,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -1228,7 +1203,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.17", + "thiserror", "tokio", "tracing", "web-time", @@ -1249,7 +1224,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror", "tinyvec", "tracing", "web-time", @@ -1352,15 +1327,24 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_syscall" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec96166dafa0886eb81fe1c0a388bece180fbef2135f97c1e2cf8302e74b43b5" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "redox_users" -version = "0.4.6" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 1.0.69", + "thiserror", ] [[package]] @@ -1394,9 +1378,9 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" -version = "0.12.24" +version = "0.12.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f" dependencies = [ "base64", "bytes", @@ -1491,9 +1475,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" dependencies = [ "web-time", "zeroize", @@ -1531,12 +1515,27 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "secrecy" version = "0.8.0" @@ -1597,11 +1596,11 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.9" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -1631,23 +1630,23 @@ dependencies = [ [[package]] name = "serial_test" -version = "2.0.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e56dd856803e253c8f298af3f4d7eb0ae5e23a737252cd90bb4f3b435033b2d" +checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" dependencies = [ - "dashmap", "futures", - "lazy_static", "log", + "once_cell", "parking_lot", + "scc", "serial_test_derive", ] [[package]] name = "serial_test_derive" -version = "2.0.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91d129178576168c589c9ec973feedf7d3126c01ac2bf08795109aa35b69fb8f" +checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" dependencies = [ "proc-macro2", "quote", @@ -1691,9 +1690,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "slab" @@ -1800,33 +1799,13 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.17", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] @@ -1941,44 +1920,42 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.23" +version = "0.9.9+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "eb5238e643fc34a1d5d7e753e1532a91912d74b63b92b3ea51fde8d1b7bc79dd" dependencies = [ - "serde", + "indexmap", + "serde_core", "serde_spanned", "toml_datetime", - "toml_edit", + "toml_parser", + "toml_writer", + "winnow", ] [[package]] name = "toml_datetime" -version = "0.6.11" +version = "0.7.4+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "fe3cea6b2aa3b910092f6abd4053ea464fab5f9c170ba5e9a6aead16ec4af2b6" dependencies = [ - "serde", + "serde_core", ] [[package]] -name = "toml_edit" -version = "0.22.27" +name = "toml_parser" +version = "1.0.5+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "4c03bee5ce3696f31250db0bbaff18bc43301ce0e8db2ed1f07cbb2acf89984c" dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", "winnow", ] [[package]] -name = "toml_write" -version = "0.1.2" +name = "toml_writer" +version = "1.0.5+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "a9cd6190959dce0994aa8970cd32ab116d1851ead27e866039acaf2524ce44fa" [[package]] name = "tower" @@ -1997,9 +1974,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.10.0", "bytes", @@ -2112,9 +2089,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.18.1" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ "getrandom 0.3.4", "js-sys", @@ -2261,22 +2238,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - [[package]] name = "winapi-util" version = "0.1.11" @@ -2286,12 +2247,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-core" version = "0.62.2" @@ -2351,15 +2306,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -2387,21 +2333,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -2435,12 +2366,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -2453,12 +2378,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -2471,12 +2390,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2501,12 +2414,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -2519,12 +2426,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -2537,12 +2438,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -2555,12 +2450,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2578,9 +2467,6 @@ name = "winnow" version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" -dependencies = [ - "memchr", -] [[package]] name = "wit-bindgen" @@ -2629,18 +2515,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.30" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea879c944afe8a2b25fef16bb4ba234f47c694565e97383b36f3a878219065c" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.30" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf955aa904d6040f70dc8e9384444cb1030aed272ba3cb09bbc4ab9e7c1f34f5" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 20e9a2a..036524b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ path = "src/lib.rs" [dependencies] serde = { version = "1.0", features = ["derive"] } -toml = "0.8" +toml = "0.9" serde_yaml = "0.9" anyhow = "1.0" clap = { version = "4.0", features = ["derive"] } @@ -30,8 +30,8 @@ tokio = { version = "1.0", features = [ "process", "io-util", ] } -thiserror = "1.0" -directories = "5.0.1" +thiserror = "2.0" +directories = "6.0" reqwest = { version = "0.12.7", default-features = false, features = [ "json", "rustls-tls", @@ -52,8 +52,8 @@ base64 = "0.22" sha2 = "0.10" blake3 = "1.5" chrono = { version = "0.4", features = ["serde"] } -cryptoki = "0.6" +cryptoki = "0.10" [dev-dependencies] tokio-test = "0.4" -serial_test = "2.0.0" +serial_test = "3.0" From e1b3e27fdb5bff8d6a0b8c72b297abe30ba3118b Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Wed, 17 Dec 2025 17:34:00 -0500 Subject: [PATCH 09/12] switch to ed25519-compact --- Cargo.lock | 164 +++---------------------------------- Cargo.toml | 4 +- src/utils/image_signing.rs | 17 ++-- src/utils/signing_keys.rs | 22 +++-- 4 files changed, 35 insertions(+), 172 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8166b9e..8da2794 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,12 +139,12 @@ dependencies = [ "clap", "cryptoki", "directories", - "ed25519-dalek", + "ed25519-compact", "flate2", "futures-util", "indicatif", "libc", - "rand 0.8.5", + "rand", "regex", "reqwest", "serde", @@ -168,12 +168,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64ct" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" - [[package]] name = "bitflags" version = "1.3.2" @@ -315,12 +309,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "constant_time_eq" version = "0.3.1" @@ -385,41 +373,10 @@ dependencies = [ ] [[package]] -name = "curve25519-dalek" -version = "4.1.3" +name = "ct-codecs" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures", - "curve25519-dalek-derive", - "digest", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "zeroize", -] +checksum = "9b10589d1a5e400d61f9f38f12f884cfd080ff345de8f17efda36fe0e4a02aa8" [[package]] name = "digest" @@ -464,28 +421,13 @@ dependencies = [ ] [[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" +name = "ed25519-compact" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +checksum = "33ce99a9e19c84beb4cc35ece85374335ccc398240712114c85038319ed709bd" dependencies = [ - "curve25519-dalek", - "ed25519", - "rand_core 0.6.4", - "serde", - "sha2", - "subtle", - "zeroize", + "ct-codecs", + "getrandom 0.3.4", ] [[package]] @@ -516,12 +458,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - [[package]] name = "filetime" version = "0.2.26" @@ -1146,16 +1082,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - [[package]] name = "portable-atomic" version = "1.11.1" @@ -1218,7 +1144,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand", "ring", "rustc-hash", "rustls", @@ -1259,35 +1185,14 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.3", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", + "rand_chacha", + "rand_core", ] [[package]] @@ -1297,16 +1202,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.16", + "rand_core", ] [[package]] @@ -1437,15 +1333,6 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - [[package]] name = "rustix" version = "1.1.2" @@ -1545,12 +1432,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - [[package]] name = "serde" version = "1.0.228" @@ -1679,15 +1560,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "rand_core 0.6.4", -] - [[package]] name = "simd-adler32" version = "0.3.8" @@ -1716,16 +1588,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 036524b..9b76de8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,8 +46,8 @@ libc = "0.2" walkdir = "2.4" tempfile = "3.0" regex = "1.0" -ed25519-dalek = { version = "2.1", features = ["rand_core"] } -rand = "0.8" +ed25519-compact = "2.2" +rand = "0.9" base64 = "0.22" sha2 = "0.10" blake3 = "1.5" diff --git a/src/utils/image_signing.rs b/src/utils/image_signing.rs index 1c38dcb..51e2ba9 100644 --- a/src/utils/image_signing.rs +++ b/src/utils/image_signing.rs @@ -11,7 +11,7 @@ use anyhow::{Context, Result}; use base64::prelude::*; use blake3; -use ed25519_dalek::{Signature, Signer, SigningKey}; +use ed25519_compact::{SecretKey, Signature}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::fs; @@ -100,7 +100,7 @@ pub fn compute_file_hash(file_path: &Path, algorithm: &ChecksumAlgorithm) -> Res } /// Load a signing key from disk -fn load_signing_key(keyid: &str) -> Result { +fn load_signing_key(keyid: &str) -> Result { let key_file_path = super::signing_keys::get_key_file_path(keyid)?.with_extension("key"); let private_key_b64 = fs::read_to_string(&key_file_path).with_context(|| { @@ -121,7 +121,7 @@ fn load_signing_key(keyid: &str) -> Result { let mut key_bytes = [0u8; 32]; key_bytes.copy_from_slice(&private_key_bytes); - Ok(SigningKey::from_bytes(&key_bytes)) + SecretKey::from_slice(&key_bytes).context("Failed to create secret key from bytes") } /// Sign a file and save the signature @@ -144,12 +144,15 @@ pub fn sign_file( })?; // Sign the hash - let signature: Signature = signing_key.sign(&hash); + let signature: Signature = signing_key.sign(&hash, None); // Create signature file content let sig_content = create_signature_content( &hash, - signature.to_bytes(), + signature + .as_ref() + .try_into() + .expect("signature should be 64 bytes"), checksum_algorithm, key_name, keyid, @@ -310,8 +313,8 @@ pub fn sign_hash_manifest( ) })?; Box::new(move |hash: &[u8]| { - let signature: Signature = signing_key.sign(hash); - Ok(signature.to_bytes().to_vec()) + let signature: Signature = signing_key.sign(hash, None); + Ok(signature.as_ref().to_vec()) }) } else if is_pkcs11_uri(&entry.uri) { // PKCS#11 signing diff --git a/src/utils/signing_keys.rs b/src/utils/signing_keys.rs index ff6d895..a2506c5 100644 --- a/src/utils/signing_keys.rs +++ b/src/utils/signing_keys.rs @@ -7,7 +7,7 @@ use anyhow::{Context, Result}; use base64::prelude::*; use chrono::{DateTime, Utc}; use directories::ProjectDirs; -use ed25519_dalek::{SigningKey, VerifyingKey}; +use ed25519_compact::{KeyPair, PublicKey, SecretKey, Seed}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashMap; @@ -131,26 +131,24 @@ pub fn get_key_file_path(keyid: &str) -> Result { } /// Generate a key ID from a public key (SHA-256 hash, first 16 hex chars) -pub fn generate_keyid(public_key: &VerifyingKey) -> String { +pub fn generate_keyid(public_key: &PublicKey) -> String { let mut hasher = Sha256::new(); - hasher.update(public_key.as_bytes()); + hasher.update(public_key.as_ref()); let hash = hasher.finalize(); format!("sha256-{}", hex::encode(&hash[..8])) } /// Generate a new ed25519 keypair -pub fn generate_keypair() -> (SigningKey, VerifyingKey) { - let mut rng = rand::thread_rng(); - let signing_key = SigningKey::generate(&mut rng); - let verifying_key = signing_key.verifying_key(); - (signing_key, verifying_key) +pub fn generate_keypair() -> (SecretKey, PublicKey) { + let keypair = KeyPair::from_seed(Seed::default()); + (keypair.sk, keypair.pk) } /// Save a keypair to disk pub fn save_keypair( keyid: &str, - signing_key: &SigningKey, - verifying_key: &VerifyingKey, + signing_key: &SecretKey, + verifying_key: &PublicKey, ) -> Result { let keys_dir = get_signing_keys_dir()?; fs::create_dir_all(&keys_dir).with_context(|| { @@ -165,7 +163,7 @@ pub fn save_keypair( let public_key_path = base_path.with_extension("pub"); // Save private key (base64 encoded) - let private_key_b64 = BASE64_STANDARD.encode(signing_key.to_bytes()); + let private_key_b64 = BASE64_STANDARD.encode(signing_key.as_ref()); fs::write(&private_key_path, &private_key_b64).with_context(|| { format!( "Failed to write private key: {}", @@ -187,7 +185,7 @@ pub fn save_keypair( } // Save public key (base64 encoded) - let public_key_b64 = BASE64_STANDARD.encode(verifying_key.as_bytes()); + let public_key_b64 = BASE64_STANDARD.encode(verifying_key.as_ref()); fs::write(&public_key_path, &public_key_b64) .with_context(|| format!("Failed to write public key: {}", public_key_path.display()))?; From bfa25dadf41434034ee94c12437e60090b8713c5 Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Wed, 17 Dec 2025 18:11:25 -0500 Subject: [PATCH 10/12] update signing keys to support ed25519-compact --- src/utils/image_signing.rs | 23 ++++++++++++++++++----- src/utils/signing_keys.rs | 23 ++++++++++++++++++++++- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/utils/image_signing.rs b/src/utils/image_signing.rs index 51e2ba9..df06266 100644 --- a/src/utils/image_signing.rs +++ b/src/utils/image_signing.rs @@ -101,6 +101,8 @@ pub fn compute_file_hash(file_path: &Path, algorithm: &ChecksumAlgorithm) -> Res /// Load a signing key from disk fn load_signing_key(keyid: &str) -> Result { + use ed25519_compact::{KeyPair, Seed}; + let key_file_path = super::signing_keys::get_key_file_path(keyid)?.with_extension("key"); let private_key_b64 = fs::read_to_string(&key_file_path).with_context(|| { @@ -115,13 +117,24 @@ fn load_signing_key(keyid: &str) -> Result { .context("Failed to decode private key from base64")?; if private_key_bytes.len() != 32 { - anyhow::bail!("Invalid private key length"); + anyhow::bail!( + "Invalid private key length: expected 32 bytes, got {}. The key may be corrupted.", + private_key_bytes.len() + ); } - let mut key_bytes = [0u8; 32]; - key_bytes.copy_from_slice(&private_key_bytes); - - SecretKey::from_slice(&key_bytes).context("Failed to create secret key from bytes") + // Load the seed (32 bytes) and create the keypair from it + // This works for both keys created with ed25519-dalek and ed25519-compact + // as both store the 32-byte seed + let seed = Seed::from_slice(&private_key_bytes).with_context(|| { + format!( + "Failed to parse seed bytes. The key file may be corrupted: {}", + key_file_path.display() + ) + })?; + + let keypair = KeyPair::from_seed(seed); + Ok(keypair.sk) } /// Sign a file and save the signature diff --git a/src/utils/signing_keys.rs b/src/utils/signing_keys.rs index a2506c5..35aee0b 100644 --- a/src/utils/signing_keys.rs +++ b/src/utils/signing_keys.rs @@ -163,7 +163,9 @@ pub fn save_keypair( let public_key_path = base_path.with_extension("pub"); // Save private key (base64 encoded) - let private_key_b64 = BASE64_STANDARD.encode(signing_key.as_ref()); + // Store the 32-byte seed, which can be used to reconstruct the key + let seed_bytes = signing_key.seed(); + let private_key_b64 = BASE64_STANDARD.encode(seed_bytes.as_ref()); fs::write(&private_key_path, &private_key_b64).with_context(|| { format!( "Failed to write private key: {}", @@ -312,6 +314,25 @@ mod tests { assert_eq!(keyid.len(), 7 + 16); // "sha256-" + 16 hex chars } + #[test] + fn test_key_serialization() { + // Test that we can save and load keys using the seed + let (sk, pk) = generate_keypair(); + + // Serialize the seed (this is what we store on disk) + let seed = sk.seed(); + let seed_bytes = seed.as_ref(); + assert_eq!(seed_bytes.len(), 32, "Seed should be 32 bytes"); + + // Reconstruct the key from the seed (this is what we do when loading) + let seed_reconstructed = Seed::from_slice(seed_bytes) + .expect("Should parse seed from bytes"); + let keypair_reconstructed = KeyPair::from_seed(seed_reconstructed); + + // The reconstructed key should produce the same public key + assert_eq!(pk.as_ref(), keypair_reconstructed.pk.as_ref(), "Public keys should match"); + } + #[test] fn test_is_file_uri() { assert!(is_file_uri("file:///path/to/key")); From f17d2fa1e5653fc467af8cb89147fe77d01d5281 Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Wed, 17 Dec 2025 19:24:17 -0500 Subject: [PATCH 11/12] Update signing keys to support hardware tpm2 --- Cargo.lock | 31 ++ Cargo.toml | 1 + docs/signing-keys.md | 162 ++++++- src/commands/signing_keys/create.rs | 105 +++- src/commands/signing_keys/mod.rs | 11 +- src/commands/signing_keys/remove.rs | 36 +- src/lib.rs | 2 +- src/main.rs | 35 +- src/utils/image_signing.rs | 71 +-- src/utils/mod.rs | 1 + src/utils/pkcs11_devices.rs | 723 ++++++++++++++++++++++++++++ src/utils/signing_keys.rs | 18 +- tests/README.md | 129 +++++ tests/pkcs11_integration_test.rs | 613 +++++++++++++++++++++++ 14 files changed, 1863 insertions(+), 75 deletions(-) create mode 100644 src/utils/pkcs11_devices.rs create mode 100644 tests/README.md create mode 100644 tests/pkcs11_integration_test.rs diff --git a/Cargo.lock b/Cargo.lock index 8da2794..3b7940f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -147,6 +147,7 @@ dependencies = [ "rand", "regex", "reqwest", + "rpassword", "serde", "serde_json", "serde_yaml", @@ -1327,6 +1328,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rpassword" +version = "7.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.59.0", +] + +[[package]] +name = "rtoolbox" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -2177,6 +2199,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index 9b76de8..acf4f07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ sha2 = "0.10" blake3 = "1.5" chrono = { version = "0.4", features = ["serde"] } cryptoki = "0.10" +rpassword = "7.3" [dev-dependencies] tokio-test = "0.4" diff --git a/docs/signing-keys.md b/docs/signing-keys.md index d50c4c9..5e55cb2 100644 --- a/docs/signing-keys.md +++ b/docs/signing-keys.md @@ -18,10 +18,153 @@ avocado signing-keys create my-production-key # Create a key (defaults to key ID as name) avocado signing-keys create -# Register a hardware-backed PKCS#11 key +# Register a hardware-backed PKCS#11 key (manual method) avocado signing-keys create yubikey-signing --uri "pkcs11:token=YubiKey;object=signing-key" ``` +### Creating Hardware-Backed Keys (TPM, YubiKey, HSMs) + +The avocado CLI supports hardware-backed signing keys via PKCS#11, providing unified support for TPM, YubiKey, HSMs, and other PKCS#11-compatible devices. + +#### TPM 2.0 Keys + +**Prerequisites:** +```bash +# Install TPM PKCS#11 module (Ubuntu/Debian) +sudo apt install libtpm2-pkcs11-1 libtpm2-pkcs11-tools tpm2-tools + +# Add your user to the tss group for TPM access +sudo usermod -a -G tss $USER + +# Log out and back in (or use newgrp) +newgrp tss + +# For other distros: +# Fedora/RHEL: sudo dnf install tpm2-pkcs11 tpm2-tools +# Arch: sudo pacman -S tpm2-pkcs11 tpm2-tools + +# Verify installation (the CLI will auto-detect the library location) +# On x86_64: /usr/lib/x86_64-linux-gnu/pkcs11/libtpm2_pkcs11.so +# On ARM64: /usr/lib/aarch64-linux-gnu/pkcs11/libtpm2_pkcs11.so +# The CLI searches all architecture-specific paths automatically +``` + +**Initialize TPM PKCS#11 Token (First Time Setup):** +```bash +# Create a PKCS#11 store in the TPM +mkdir -p ~/.tpm2_pkcs11 +tpm2_ptool init + +# Create a new token (you'll be prompted to set a PIN) +tpm2_ptool addtoken --pid=1 --label=avocado --userpin=yourpin --sopin=yoursopin + +# Verify the token is created +tpm2_ptool listtoken +``` + +**Generate a new key in TPM:** +```bash +# Generate with PIN prompt (specify token name) +avocado signing-keys create my-tpm-key --pkcs11-device tpm --token avocado --generate --auth prompt + +# Generate with PIN from environment variable +export AVOCADO_PKCS11_PIN=your-tpm-pin +avocado signing-keys create my-tpm-key --pkcs11-device tpm --token avocado --generate --auth env + +# Generate without specifying token (uses first available) +avocado signing-keys create my-tpm-key --pkcs11-device tpm --generate --auth prompt + +# Generate without PIN (if TPM has no auth) +avocado signing-keys create my-tpm-key --pkcs11-device tpm --token avocado --generate --auth none +``` + +**Reference an existing TPM key:** +```bash +# Reference key by label +avocado signing-keys create prod-key --pkcs11-device tpm --token avocado --key-label existing-tpm-key --auth prompt +``` + +#### YubiKey Keys + +**Prerequisites:** +```bash +# Ubuntu/Debian - Option 1: YubiKey manager (recommended) +sudo apt install yubikey-manager libykcs11-1 + +# Ubuntu/Debian - Option 2: OpenSC (alternative) +sudo apt install opensc-pkcs11 + +# For other distros: +# Fedora/RHEL: sudo dnf install ykcs11 (or opensc) +# Arch: sudo pacman -S yubikey-manager (or opensc) + +# Verify installation (the CLI will auto-detect the library location) +# The CLI automatically finds libraries across all architectures + +# Optional: Set module path explicitly only if auto-detection fails +export PKCS11_MODULE_PATH=/path/to/your/libykcs11.so +``` + +**Generate a new key in YubiKey:** +```bash +# Generate in YubiKey PIV slot +avocado signing-keys create yk-prod --pkcs11-device yubikey --generate --auth prompt + +# YubiKey will prompt for PIN (default: 123456) +# Consider requiring touch for signing operations (configure via ykman) +``` + +**Reference an existing YubiKey key:** +```bash +# Reference existing PIV key +avocado signing-keys create yk-key --pkcs11-device yubikey --key-label "PIV AUTH key" --auth prompt +``` + +#### Manual PKCS#11 URI Registration (Advanced) + +For other devices or custom configurations, you can manually register PKCS#11 keys using URIs: + +```bash +# Register any PKCS#11 device +avocado signing-keys create custom-hsm --uri "pkcs11:token=MyHSM;object=signing-key" + +# Example with more URI parameters +avocado signing-keys create hsm-prod --uri "pkcs11:token=Luna%20SA;object=prod-signing;type=private" +``` + +#### Hardware Key Algorithm Support + +Hardware devices support different algorithms than file-based keys: + +| Device Type | Supported Algorithms | Default | +|-------------|---------------------|---------| +| File-based | Ed25519 | Ed25519 | +| TPM 2.0 | ECC P-256, RSA-2048 | ECC P-256 | +| YubiKey | ECC P-256, RSA-2048 | ECC P-256 | +| HSMs | Varies by device | Device-dependent | + +**Note:** Most hardware devices do NOT support Ed25519. The CLI automatically uses ECC P-256 for hardware keys as it's universally supported. + +#### Authentication Methods + +Three authentication methods are supported: + +1. **`--auth prompt`** (default): Interactively prompts for PIN/password + ```bash + avocado signing-keys create my-key --pkcs11-device tpm --generate --auth prompt + ``` + +2. **`--auth env`**: Reads PIN from `AVOCADO_PKCS11_PIN` environment variable + ```bash + export AVOCADO_PKCS11_PIN=my-secure-pin + avocado signing-keys create my-key --pkcs11-device tpm --generate --auth env + ``` + +3. **`--auth none`**: No authentication (for devices without PIN protection) + ```bash + avocado signing-keys create my-key --pkcs11-device tpm --generate --auth none + ``` + ### Listing Keys ```bash @@ -314,11 +457,16 @@ Successfully built runtime 'production' ## PKCS#11 Support Status - **Key Registration**: ✅ PKCS#11 URIs can be registered via `avocado signing-keys create --uri` +- **Key Generation**: ✅ Generate keys in TPM, YubiKey via `--pkcs11-device` and `--generate` +- **Key Reference**: ✅ Reference existing hardware keys via `--key-label` - **Key Listing**: ✅ PKCS#11 keys are listed and displayed -- **Signing Operations**: ⚠️ PKCS#11 signing support is planned but not yet implemented -- **Workaround**: Currently, only file-based keys (ed25519) can be used for actual signing +- **Signing Operations**: ✅ PKCS#11 signing fully implemented and functional +- **Supported Devices**: ✅ TPM 2.0, YubiKey, and any PKCS#11-compatible device + +### How It Works -When PKCS#11 support is fully implemented: -- Signing will occur on the host (Pass 2 of multi-pass workflow) -- Hardware devices (TPM, YubiKey) will be accessed directly -- No container privileges or device passthrough required +- **Key Creation/Generation**: Happens on the host with direct access to hardware device +- **Signing**: Occurs on the host (Pass 3 of multi-pass workflow) with direct hardware access +- **No Container Passthrough**: Hardware devices stay on host, only checksums are extracted from containers +- **Algorithm Detection**: Automatically detects key algorithm from device +- **PIN Management**: Flexible authentication (prompt, environment variable, or none) diff --git a/src/commands/signing_keys/create.rs b/src/commands/signing_keys/create.rs index 6735187..b1a06ff 100644 --- a/src/commands/signing_keys/create.rs +++ b/src/commands/signing_keys/create.rs @@ -14,18 +14,98 @@ pub struct SigningKeysCreateCommand { pub name: Option, /// Optional PKCS#11 URI for hardware-backed keys pub uri: Option, + /// Hardware device type (tpm, yubikey, auto) + pub pkcs11_device: Option, + /// PKCS#11 token label + pub token: Option, + /// Label of existing key to reference in the device + pub key_label: Option, + /// Generate a new key in the device + pub generate: bool, + /// Authentication method for PKCS#11 device + pub auth: String, } impl SigningKeysCreateCommand { - pub fn new(name: Option, uri: Option) -> Self { - Self { name, uri } + pub fn new( + name: Option, + uri: Option, + pkcs11_device: Option, + token: Option, + key_label: Option, + generate: bool, + auth: String, + ) -> Self { + Self { + name, + uri, + pkcs11_device, + token, + key_label, + generate, + auth, + } } pub fn execute(&self) -> Result<()> { + use crate::utils::pkcs11_devices::{ + build_pkcs11_uri, find_existing_key, generate_keypair as generate_pkcs11_keypair, + get_device_auth, init_pkcs11_session, DeviceType, KeyAlgorithm, Pkcs11AuthMethod, + }; + use std::str::FromStr; + let mut registry = KeysRegistry::load()?; - let (keyid, uri, key_type) = if let Some(pkcs11_uri) = &self.uri { - // Register an external PKCS#11 key + let (keyid, uri, algorithm, key_type) = if let Some(device_type_str) = &self.pkcs11_device { + // PKCS#11 hardware device flow + let device_type = DeviceType::from_str(device_type_str)?; + let auth_method = Pkcs11AuthMethod::from_str(&self.auth)?; + + // Get authentication + let auth = get_device_auth(&auth_method)?; + + // Initialize PKCS#11 and open session + let (_pkcs11, session) = init_pkcs11_session(&device_type, self.token.as_deref(), &auth, &auth_method)?; + + let (_public_key_bytes, keyid, algorithm) = if self.generate { + // Generate new key in device + let label = self.name.as_ref().ok_or_else(|| { + anyhow::anyhow!("--name is required when generating a hardware key") + })?; + + // Default to ECC P-256 (most widely supported) + let key_algorithm = KeyAlgorithm::EccP256; + + generate_pkcs11_keypair(&session, label, &key_algorithm)? + } else if let Some(label) = &self.key_label { + // Reference existing key in device + find_existing_key(&session, label)? + } else { + anyhow::bail!("Either --generate or --key-label is required with --pkcs11-device"); + }; + + // Get token info for building URI + let slot = session.get_session_info()?.slot_id(); + let token_info = _pkcs11.get_token_info(slot)?; + let token_label = token_info.label(); + + // Build PKCS#11 URI + let object_label = self + .name + .as_ref() + .or(self.key_label.as_ref()) + .ok_or_else(|| anyhow::anyhow!("Name or key-label required"))?; + + let pkcs11_uri = build_pkcs11_uri(token_label, object_label); + + ( + keyid, + pkcs11_uri, + algorithm, + format!("{}/PKCS#11", device_type), + ) + } else if let Some(pkcs11_uri) = &self.uri { + // Manual PKCS#11 URI registration (existing flow) if !is_pkcs11_uri(pkcs11_uri) { anyhow::bail!( "Invalid URI: '{}'. Expected a pkcs11: URI (e.g., 'pkcs11:token=YubiKey;object=signing-key')", @@ -33,12 +113,17 @@ impl SigningKeysCreateCommand { ); } - // For PKCS#11 keys, we generate a keyid from the URI itself + // For manually registered PKCS#11 keys, we generate a keyid from the URI itself // since we don't have direct access to the public key let keyid = generate_keyid_from_uri(pkcs11_uri); - (keyid, pkcs11_uri.clone(), "PKCS#11") + ( + keyid, + pkcs11_uri.clone(), + "unknown".to_string(), + "PKCS#11".to_string(), + ) } else { - // Generate a new ed25519 keypair + // Generate a new ed25519 keypair (file-based, existing flow) let (signing_key, verifying_key) = generate_keypair(); let keyid = generate_keyid(&verifying_key); @@ -46,7 +131,7 @@ impl SigningKeysCreateCommand { let key_path = save_keypair(&keyid, &signing_key, &verifying_key)?; let uri = path_to_file_uri(&key_path); - (keyid, uri, "file") + (keyid, uri, "ed25519".to_string(), "file".to_string()) }; // Determine the name (use provided name or fall back to keyid) @@ -60,7 +145,7 @@ impl SigningKeysCreateCommand { // Create the key entry let entry = KeyEntry { keyid: keyid.clone(), - algorithm: "ed25519".to_string(), + algorithm: algorithm.clone(), created_at: Utc::now(), uri: uri.clone(), }; @@ -73,7 +158,7 @@ impl SigningKeysCreateCommand { println!("Created signing key:"); println!(" Name: {}", name); println!(" Key ID: {}", keyid); - println!(" Algorithm: ed25519"); + println!(" Algorithm: {}", algorithm); println!(" Type: {}", key_type); if key_type == "file" { diff --git a/src/commands/signing_keys/mod.rs b/src/commands/signing_keys/mod.rs index bafe7c0..149c514 100644 --- a/src/commands/signing_keys/mod.rs +++ b/src/commands/signing_keys/mod.rs @@ -3,15 +3,10 @@ //! Provides commands for creating, listing, and removing signing keys //! stored in the global avocado configuration. -mod create; -mod list; -mod remove; +pub mod create; +pub mod list; +pub mod remove; -// These exports are used by the binary target (main.rs) but not the library target, -// which causes clippy warnings in the lib build. We allow unused_imports here. -#[allow(unused_imports)] pub use create::SigningKeysCreateCommand; -#[allow(unused_imports)] pub use list::SigningKeysListCommand; -#[allow(unused_imports)] pub use remove::SigningKeysRemoveCommand; diff --git a/src/commands/signing_keys/remove.rs b/src/commands/signing_keys/remove.rs index 14c633d..4e84f5f 100644 --- a/src/commands/signing_keys/remove.rs +++ b/src/commands/signing_keys/remove.rs @@ -18,36 +18,48 @@ impl SigningKeysRemoveCommand { pub fn execute(&self) -> Result<()> { let mut registry = KeysRegistry::load()?; - // Get the key entry before removing - let entry = registry.get_key(&self.name).cloned(); - - if entry.is_none() { - anyhow::bail!("No signing key found with name '{}'", self.name); - } - - let entry = entry.unwrap(); + // Try to find by name first, then by key ID + let (key_name, entry) = if let Some(entry) = registry.get_key(&self.name).cloned() { + // Found by name + (self.name.clone(), entry) + } else { + // Try to find by key ID + let mut found = None; + for (name, entry) in ®istry.keys { + if entry.keyid == self.name { + found = Some((name.clone(), entry.clone())); + break; + } + } + + if let Some((name, entry)) = found { + (name, entry) + } else { + anyhow::bail!("No signing key found with name or key ID '{}'", self.name); + } + }; // Remove from registry - registry.remove_key(&self.name)?; + registry.remove_key(&key_name)?; registry.save()?; // If it's a file-based key, delete the key files if is_file_uri(&entry.uri) { match delete_key_files(&entry.keyid) { Ok(()) => { - println!("Removed signing key '{}'", self.name); + println!("Removed signing key '{}'", key_name); println!(" Key ID: {}", entry.keyid); println!(" Deleted key files from disk"); } Err(e) => { // Key was removed from registry, but file deletion failed - println!("Removed signing key '{}' from registry", self.name); + println!("Removed signing key '{}' from registry", key_name); println!(" Warning: Failed to delete key files: {}", e); } } } else { // PKCS#11 key - just remove from registry - println!("Removed signing key '{}'", self.name); + println!("Removed signing key '{}'", key_name); println!(" Key ID: {}", entry.keyid); println!(" Note: PKCS#11 key reference removed (hardware key unchanged)"); } diff --git a/src/lib.rs b/src/lib.rs index 509ce79..57af0db 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,5 +4,5 @@ #![allow(dead_code)] -mod commands; +pub mod commands; pub mod utils; diff --git a/src/main.rs b/src/main.rs index cd3c96b..2be7057 100644 --- a/src/main.rs +++ b/src/main.rs @@ -274,6 +274,21 @@ enum SigningKeysCommands { /// PKCS#11 URI for hardware-backed keys (e.g., 'pkcs11:token=YubiKey;object=signing-key') #[arg(long)] uri: Option, + /// Hardware device type (tpm, yubikey, or auto-detect) + #[arg(long, value_name = "DEVICE")] + pkcs11_device: Option, + /// PKCS#11 token label (e.g., 'avocado', 'YubiKey PIV'). If not provided, uses the first available token. + #[arg(long, value_name = "TOKEN")] + token: Option, + /// Label of existing key to reference in the device + #[arg(long, value_name = "LABEL")] + key_label: Option, + /// Generate a new key in the device + #[arg(long)] + generate: bool, + /// Authentication method for PKCS#11 device (none, prompt, env) + #[arg(long, default_value = "prompt", value_name = "METHOD")] + auth: String, }, /// List all registered signing keys List, @@ -812,8 +827,24 @@ async fn main() -> Result<()> { Ok(()) } Commands::SigningKeys { command } => match command { - SigningKeysCommands::Create { name, uri } => { - let cmd = SigningKeysCreateCommand::new(name, uri); + SigningKeysCommands::Create { + name, + uri, + pkcs11_device, + token, + key_label, + generate, + auth, + } => { + let cmd = SigningKeysCreateCommand::new( + name, + uri, + pkcs11_device, + token, + key_label, + generate, + auth, + ); cmd.execute()?; Ok(()) } diff --git a/src/utils/image_signing.rs b/src/utils/image_signing.rs index df06266..602d123 100644 --- a/src/utils/image_signing.rs +++ b/src/utils/image_signing.rs @@ -102,7 +102,7 @@ pub fn compute_file_hash(file_path: &Path, algorithm: &ChecksumAlgorithm) -> Res /// Load a signing key from disk fn load_signing_key(keyid: &str) -> Result { use ed25519_compact::{KeyPair, Seed}; - + let key_file_path = super::signing_keys::get_key_file_path(keyid)?.with_extension("key"); let private_key_b64 = fs::read_to_string(&key_file_path).with_context(|| { @@ -132,7 +132,7 @@ fn load_signing_key(keyid: &str) -> Result { key_file_path.display() ) })?; - + let keypair = KeyPair::from_seed(seed); Ok(keypair.sk) } @@ -380,32 +380,47 @@ pub fn sign_hash_manifest( /// Sign a hash using PKCS#11 hardware token /// -/// This function provides basic PKCS#11 signing support. Full implementation -/// requires proper token/slot discovery and PIN management. -fn sign_with_pkcs11(uri: &str, _hash: &[u8]) -> Result> { - // Parse PKCS#11 URI (simplified - full URI parsing would be more complex) - // Format: pkcs11:token=TokenName;object=KeyLabel - - // For now, return an error with helpful message - // Full implementation would: - // 1. Initialize PKCS#11 library - // 2. Find slot/token - // 3. Open session - // 4. Find private key object - // 5. Perform signing operation - // 6. Return signature - - anyhow::bail!( - "PKCS#11 signing is not yet fully implemented. URI: {}\n\ - \n\ - To implement PKCS#11 signing:\n\ - 1. Install PKCS#11 library for your device (e.g., opensc for YubiKey)\n\ - 2. Set PKCS11_MODULE_PATH environment variable\n\ - 3. Ensure device is connected and accessible\n\ - \n\ - Currently, only file-based ed25519 keys are supported for signing.", - uri - ) +/// This function implements PKCS#11 signing for hardware devices (TPM, YubiKey, HSMs). +fn sign_with_pkcs11(uri: &str, hash: &[u8]) -> Result> { + use super::pkcs11_devices::{ + init_pkcs11_session, parse_pkcs11_uri, sign_with_pkcs11_device, DeviceType, + Pkcs11AuthMethod, + }; + use std::env; + + // Parse PKCS#11 URI to extract token and object labels + let (token_label, object_label) = + parse_pkcs11_uri(uri).context("Failed to parse PKCS#11 URI")?; + + // Determine device type from token label or environment + let device_type = if token_label.to_lowercase().contains("tpm") { + DeviceType::Tpm + } else if token_label.to_lowercase().contains("yubikey") + || token_label.to_lowercase().contains("yubico") + { + DeviceType::Yubikey + } else { + DeviceType::Auto + }; + + // Get auth method from environment or use prompt + let auth_method = if env::var("AVOCADO_PKCS11_PIN").is_ok() { + Pkcs11AuthMethod::EnvVar("AVOCADO_PKCS11_PIN".to_string()) + } else { + // For signing operations, we'll try without auth first + // Most systems cache the PIN from key creation + Pkcs11AuthMethod::None + }; + + // Initialize PKCS#11 and open session (no specific token, use first available) + let (_pkcs11, session) = init_pkcs11_session(&device_type, None, "", &auth_method) + .context("Failed to initialize PKCS#11 session for signing")?; + + // Sign the data + let signature = sign_with_pkcs11_device(&session, &object_label, hash) + .context("Failed to sign data with PKCS#11 device")?; + + Ok(signature) } fn hex_encode(bytes: &[u8]) -> String { diff --git a/src/utils/mod.rs b/src/utils/mod.rs index dc32966..0b089c5 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -3,6 +3,7 @@ pub mod container; pub mod image_signing; pub mod interpolation; pub mod output; +pub mod pkcs11_devices; pub mod signing_keys; pub mod target; pub mod volume; diff --git a/src/utils/pkcs11_devices.rs b/src/utils/pkcs11_devices.rs new file mode 100644 index 0000000..203dd7b --- /dev/null +++ b/src/utils/pkcs11_devices.rs @@ -0,0 +1,723 @@ +//! PKCS#11 device integration for hardware-backed signing keys. +//! +//! Provides unified support for TPM, YubiKey, HSMs, and other PKCS#11-compatible devices. + +use anyhow::{Context, Result}; +use cryptoki::context::{CInitializeArgs, Pkcs11}; +use cryptoki::mechanism::Mechanism; +use cryptoki::object::{Attribute, AttributeType, ObjectClass, ObjectHandle}; +use cryptoki::session::{Session, UserType}; +use cryptoki::slot::Slot; +use cryptoki::types::AuthPin; +use sha2::{Digest, Sha256}; +use std::env; +use std::fmt; +use std::fs; +use std::path::PathBuf; +use std::str::FromStr; + +/// Supported hardware device types +#[derive(Debug, Clone, PartialEq)] +pub enum DeviceType { + Tpm, + Yubikey, + Auto, +} + +impl fmt::Display for DeviceType { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + DeviceType::Tpm => write!(f, "TPM"), + DeviceType::Yubikey => write!(f, "YubiKey"), + DeviceType::Auto => write!(f, "Auto"), + } + } +} + +impl FromStr for DeviceType { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "tpm" => Ok(DeviceType::Tpm), + "yubikey" | "yk" => Ok(DeviceType::Yubikey), + "auto" => Ok(DeviceType::Auto), + _ => anyhow::bail!( + "Unsupported device type '{}'. Supported: tpm, yubikey, auto", + s + ), + } + } +} + +/// Authentication methods for PKCS#11 devices +#[derive(Debug, Clone)] +pub enum Pkcs11AuthMethod { + None, + Prompt, + EnvVar(String), +} + +impl FromStr for Pkcs11AuthMethod { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "none" => Ok(Pkcs11AuthMethod::None), + "prompt" => Ok(Pkcs11AuthMethod::Prompt), + "env" => Ok(Pkcs11AuthMethod::EnvVar("AVOCADO_PKCS11_PIN".to_string())), + _ => anyhow::bail!( + "Unsupported auth method '{}'. Supported: none, prompt, env", + s + ), + } + } +} + +/// Supported key algorithms +#[derive(Debug, Clone, PartialEq)] +pub enum KeyAlgorithm { + EccP256, + Rsa2048, +} + +impl KeyAlgorithm { + pub fn as_str(&self) -> &str { + match self { + KeyAlgorithm::EccP256 => "ecdsa-p256", + KeyAlgorithm::Rsa2048 => "rsa2048", + } + } +} + +/// Get PKCS#11 module path for a device type +pub fn get_pkcs11_module_path(device_type: &DeviceType) -> Result { + // 1. Check PKCS11_MODULE_PATH env var (highest priority) + if let Ok(path) = env::var("PKCS11_MODULE_PATH") { + let p = PathBuf::from(path); + if p.exists() { + return Ok(p); + } + anyhow::bail!( + "PKCS11_MODULE_PATH set but file does not exist: {}", + p.display() + ); + } + + // 2. Search for modules dynamically (architecture-agnostic) + let module_names = match device_type { + DeviceType::Tpm => vec!["libtpm2_pkcs11.so"], + DeviceType::Yubikey => vec!["libykcs11.so", "opensc-pkcs11.so"], + DeviceType::Auto => vec!["libtpm2_pkcs11.so", "libykcs11.so", "opensc-pkcs11.so"], + }; + + // Search in standard library directories + let search_dirs = get_library_search_paths(); + + for dir in &search_dirs { + for module_name in &module_names { + // Check with exact name + let path = dir.join(module_name); + if path.exists() { + return Ok(path); + } + + // Check in pkcs11 subdirectory + let pkcs11_path = dir.join("pkcs11").join(module_name); + if pkcs11_path.exists() { + return Ok(pkcs11_path); + } + + // Check for versioned .so files (e.g., .so.1, .so.1.9.0) + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let filename = entry.file_name(); + let filename_str = filename.to_string_lossy(); + if filename_str.starts_with(module_name) { + return Ok(entry.path()); + } + } + } + + // Check pkcs11 subdirectory for versioned files + let pkcs11_dir = dir.join("pkcs11"); + if let Ok(entries) = fs::read_dir(&pkcs11_dir) { + for entry in entries.flatten() { + let filename = entry.file_name(); + let filename_str = filename.to_string_lossy(); + if filename_str.starts_with(module_name) { + return Ok(entry.path()); + } + } + } + } + } + + anyhow::bail!( + "{} PKCS#11 module not found. Set PKCS11_MODULE_PATH or install the appropriate package:\n \ + - TPM: libtpm2-pkcs11-1 (Ubuntu/Debian) or tpm2-pkcs11 (Fedora/Arch)\n \ + - YubiKey: libykcs11-1 or opensc-pkcs11 (Ubuntu/Debian) or ykcs11/opensc (Fedora/Arch)\n\ + \n\ + Searched in: {}", + device_type, + search_dirs.iter().map(|p| p.display().to_string()).collect::>().join(", ") + ) +} + +/// Get standard library search paths for the current system +fn get_library_search_paths() -> Vec { + let mut paths = Vec::new(); + + // 1. Check p11-kit module directory (standard on most systems) + if let Ok(output) = std::process::Command::new("pkg-config") + .args(["--variable=p11_module_path", "p11-kit-1"]) + .output() + { + if output.status.success() { + let path_str = String::from_utf8_lossy(&output.stdout); + let path = PathBuf::from(path_str.trim()); + if path.exists() { + paths.push(path); + } + } + } + + // 2. Multi-arch library directories (works for all architectures) + if let Ok(output) = std::process::Command::new("gcc") + .args(["-print-multiarch"]) + .output() + { + if output.status.success() { + let multiarch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !multiarch.is_empty() { + paths.push(PathBuf::from(format!("/usr/lib/{}", multiarch))); + } + } + } + + // 3. Standard library paths (work on all architectures) + paths.extend(vec![ + PathBuf::from("/usr/lib"), + PathBuf::from("/usr/local/lib"), + PathBuf::from("/usr/lib64"), + PathBuf::from("/lib"), + PathBuf::from("/lib64"), + ]); + + // 4. Check LD_LIBRARY_PATH + if let Ok(ld_path) = env::var("LD_LIBRARY_PATH") { + for path in ld_path.split(':') { + if !path.is_empty() { + paths.push(PathBuf::from(path)); + } + } + } + + // 5. Architecture-specific paths as fallback (detected from system) + #[cfg(target_arch = "x86_64")] + paths.extend(vec![PathBuf::from("/usr/lib/x86_64-linux-gnu")]); + + #[cfg(target_arch = "aarch64")] + paths.extend(vec![PathBuf::from("/usr/lib/aarch64-linux-gnu")]); + + #[cfg(target_arch = "arm")] + paths.extend(vec![ + PathBuf::from("/usr/lib/arm-linux-gnueabihf"), + PathBuf::from("/usr/lib/arm-linux-gnueabi"), + ]); + + #[cfg(target_arch = "riscv64")] + paths.extend(vec![PathBuf::from("/usr/lib/riscv64-linux-gnu")]); + + paths +} + +/// Get authentication PIN/password from user +pub fn get_device_auth(method: &Pkcs11AuthMethod) -> Result { + match method { + Pkcs11AuthMethod::None => Ok(String::new()), + Pkcs11AuthMethod::Prompt => { + let pin = rpassword::prompt_password("Enter PIN for PKCS#11 device: ") + .context("Failed to read PIN from prompt")?; + Ok(pin) + } + Pkcs11AuthMethod::EnvVar(var_name) => env::var(var_name).with_context(|| { + format!( + "Environment variable '{}' not set. Set it or use --auth prompt", + var_name + ) + }), + } +} + +/// Discover a device token by device type and optional token label +pub fn discover_device_token( + pkcs11: &Pkcs11, + device_type: &DeviceType, + token_label: Option<&str>, +) -> Result<(Slot, cryptoki::slot::TokenInfo)> { + let slots = pkcs11 + .get_slots_with_token() + .context("Failed to get PKCS#11 slots with tokens")?; + + if slots.is_empty() { + let help_msg = match device_type { + DeviceType::Tpm => { + "No TPM tokens found. Initialize the TPM PKCS#11 token first:\n\ + \n\ + 1. Ensure you're in the 'tss' group:\n\ + sudo usermod -a -G tss $USER\n\ + newgrp tss\n\ + \n\ + 2. Initialize the TPM PKCS#11 store:\n\ + mkdir -p ~/.tpm2_pkcs11\n\ + tpm2_ptool init\n\ + \n\ + 3. Create a token:\n\ + tpm2_ptool addtoken --pid=1 --label=avocado --userpin=yourpin --sopin=yoursopin\n\ + \n\ + 4. Verify:\n\ + tpm2_ptool listtoken" + } + DeviceType::Yubikey => { + "No YubiKey tokens found. Ensure:\n\ + 1. YubiKey is inserted\n\ + 2. PIV application is initialized (use 'ykman piv info')" + } + DeviceType::Auto => { + "No PKCS#11 tokens found. Ensure the device is connected and initialized." + } + }; + anyhow::bail!("{}", help_msg); + } + + // If a specific token label was provided, look for exact match + if let Some(requested_label) = token_label { + for slot in &slots { + let token_info = pkcs11 + .get_token_info(*slot) + .context("Failed to get token info")?; + + if token_info.label().trim() == requested_label.trim() { + return Ok((*slot, token_info)); + } + } + + // Token not found - collect available tokens for error message + let mut available_tokens = Vec::new(); + for slot in &slots { + if let Ok(token_info) = pkcs11.get_token_info(*slot) { + available_tokens.push(token_info.label().to_string()); + } + } + + anyhow::bail!( + "Token '{}' not found. Available tokens: {}", + requested_label, + available_tokens.join(", ") + ); + } + + // No specific token requested - use first available token + if !slots.is_empty() { + let slot = slots[0]; + let token_info = pkcs11 + .get_token_info(slot) + .context("Failed to get token info")?; + return Ok((slot, token_info)); + } + + // If no match found but we have tokens, return error with available tokens + let mut available_tokens = Vec::new(); + for slot in pkcs11.get_slots_with_token()? { + if let Ok(token_info) = pkcs11.get_token_info(slot) { + available_tokens.push(token_info.label().to_string()); + } + } + + anyhow::bail!( + "No matching {} token found. Available tokens: {}", + device_type, + available_tokens.join(", ") + ) +} + +/// Generate a keypair in the PKCS#11 device +pub fn generate_keypair( + session: &Session, + label: &str, + algorithm: &KeyAlgorithm, +) -> Result<(Vec, String, String)> { + let mechanism = match algorithm { + KeyAlgorithm::EccP256 => Mechanism::EccKeyPairGen, + KeyAlgorithm::Rsa2048 => Mechanism::RsaPkcsKeyPairGen, + }; + + // Build public key template + let mut pub_key_template = vec![ + Attribute::Token(true), + Attribute::Label(label.as_bytes().to_vec()), + Attribute::Verify(true), + ]; + + // Build private key template + let priv_key_template = vec![ + Attribute::Token(true), + Attribute::Label(label.as_bytes().to_vec()), + Attribute::Sign(true), + Attribute::Sensitive(true), + Attribute::Private(true), + ]; + + // Add algorithm-specific attributes + match algorithm { + KeyAlgorithm::EccP256 => { + // NIST P-256 curve OID: 1.2.840.10045.3.1.7 + let p256_oid = vec![0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07]; + pub_key_template.push(Attribute::EcParams(p256_oid)); + } + KeyAlgorithm::Rsa2048 => { + pub_key_template.push(Attribute::ModulusBits(2048.into())); + pub_key_template.push(Attribute::PublicExponent(vec![0x01, 0x00, 0x01])); + // 65537 + } + } + + let (public_key, _private_key) = session + .generate_key_pair(&mechanism, &pub_key_template, &priv_key_template) + .context("Failed to generate key pair in PKCS#11 device")?; + + // Extract public key bytes for keyid generation + let public_key_bytes = extract_public_key_bytes(session, public_key, algorithm)?; + + // Generate keyid from public key + let keyid = generate_keyid_from_public_key(&public_key_bytes); + + Ok((public_key_bytes, keyid, algorithm.as_str().to_string())) +} + +/// Find an existing key by label +pub fn find_existing_key(session: &Session, label: &str) -> Result<(Vec, String, String)> { + let template = vec![ + Attribute::Label(label.as_bytes().to_vec()), + Attribute::Class(ObjectClass::PUBLIC_KEY), + ]; + + session + .find_objects(&template) + .context("Failed to search for key objects")?; + + let objects = session + .find_objects(&template) + .context("Failed to find key objects")?; + + if objects.is_empty() { + anyhow::bail!("No key found with label '{}'", label); + } + + let public_key_handle = objects[0]; + + // Determine algorithm from key type + let key_type_attr = session + .get_attributes(public_key_handle, &[AttributeType::KeyType]) + .context("Failed to get key type")?; + + let algorithm = detect_algorithm_from_attributes(&key_type_attr)?; + + // Extract public key bytes + let public_key_bytes = extract_public_key_bytes(session, public_key_handle, &algorithm)?; + + // Generate keyid + let keyid = generate_keyid_from_public_key(&public_key_bytes); + + Ok((public_key_bytes, keyid, algorithm.as_str().to_string())) +} + +/// Extract public key bytes from a PKCS#11 object +fn extract_public_key_bytes( + session: &Session, + public_key_handle: ObjectHandle, + algorithm: &KeyAlgorithm, +) -> Result> { + match algorithm { + KeyAlgorithm::EccP256 => { + // For EC keys, get the EC_POINT attribute + let attrs = session + .get_attributes(public_key_handle, &[AttributeType::EcPoint]) + .context("Failed to get EC_POINT attribute")?; + + for attr in attrs { + if let Attribute::EcPoint(point) = attr { + return Ok(point); + } + } + + anyhow::bail!("EC_POINT attribute not found") + } + KeyAlgorithm::Rsa2048 => { + // For RSA keys, get the modulus + let attrs = session + .get_attributes(public_key_handle, &[AttributeType::Modulus]) + .context("Failed to get modulus attribute")?; + + for attr in attrs { + if let Attribute::Modulus(modulus) = attr { + return Ok(modulus); + } + } + + anyhow::bail!("Modulus attribute not found") + } + } +} + +/// Detect algorithm from PKCS#11 attributes +fn detect_algorithm_from_attributes(attributes: &[Attribute]) -> Result { + for attr in attributes { + if let Attribute::KeyType(key_type) = attr { + match *key_type { + cryptoki::object::KeyType::EC => { + // Default to P-256 for EC keys + // Could inspect EC_PARAMS to determine exact curve + return Ok(KeyAlgorithm::EccP256); + } + cryptoki::object::KeyType::RSA => { + // Default to RSA-2048 + // Could inspect modulus length to determine exact size + return Ok(KeyAlgorithm::Rsa2048); + } + _ => continue, + } + } + } + + anyhow::bail!("Unable to determine key algorithm from attributes") +} + +/// Generate a keyid from public key bytes (SHA-256, first 16 hex chars) +pub fn generate_keyid_from_public_key(public_key_bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(public_key_bytes); + let hash = hasher.finalize(); + format!("sha256-{}", hex_encode(&hash[..8])) +} + +/// Build a PKCS#11 URI +pub fn build_pkcs11_uri(token_label: &str, object_label: &str) -> String { + format!( + "pkcs11:token={};object={};type=private", + uri_encode(token_label), + uri_encode(object_label) + ) +} + +/// Parse a PKCS#11 URI to extract token and object labels +pub fn parse_pkcs11_uri(uri: &str) -> Result<(String, String)> { + if !uri.starts_with("pkcs11:") { + anyhow::bail!("Invalid PKCS#11 URI: must start with 'pkcs11:'"); + } + + let params = &uri[7..]; // Skip "pkcs11:" + let mut token_label = None; + let mut object_label = None; + + for param in params.split(';') { + if let Some((key, value)) = param.split_once('=') { + match key { + "token" => token_label = Some(uri_decode(value)?), + "object" => object_label = Some(uri_decode(value)?), + _ => {} // Ignore other parameters + } + } + } + + let token = + token_label.ok_or_else(|| anyhow::anyhow!("PKCS#11 URI missing 'token' parameter"))?; + let object = + object_label.ok_or_else(|| anyhow::anyhow!("PKCS#11 URI missing 'object' parameter"))?; + + Ok((token, object)) +} + +/// URI-encode a string (simple implementation for labels) +fn uri_encode(s: &str) -> String { + s.replace(' ', "%20") + .replace(';', "%3B") + .replace('=', "%3D") +} + +/// URI-decode a string (simple implementation for labels) +fn uri_decode(s: &str) -> Result { + let decoded = s + .replace("%20", " ") + .replace("%3B", ";") + .replace("%3D", "="); + Ok(decoded) +} + +/// Hex encode bytes +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +/// Initialize PKCS#11 and open session +pub fn init_pkcs11_session( + device_type: &DeviceType, + token_label: Option<&str>, + auth: &str, + auth_method: &Pkcs11AuthMethod, +) -> Result<(Pkcs11, Session)> { + // Get module path + let module_path = get_pkcs11_module_path(device_type)?; + + // Initialize PKCS#11 + let pkcs11 = Pkcs11::new(module_path).context("Failed to load PKCS#11 module")?; + + pkcs11 + .initialize(CInitializeArgs::OsThreads) + .context("Failed to initialize PKCS#11")?; + + // Find token + let (slot, _token_info) = discover_device_token(&pkcs11, device_type, token_label)?; + + // Open session + let session = pkcs11 + .open_rw_session(slot) + .context("Failed to open PKCS#11 session")?; + + // Login if auth provided + if !auth.is_empty() + || matches!( + auth_method, + Pkcs11AuthMethod::Prompt | Pkcs11AuthMethod::EnvVar(_) + ) + { + let pin = if auth.is_empty() { + get_device_auth(auth_method)? + } else { + auth.to_string() + }; + + if !pin.is_empty() { + session + .login(UserType::User, Some(&AuthPin::new(pin))) + .context("Failed to login to PKCS#11 device")?; + } + } + + Ok((pkcs11, session)) +} + +/// Sign data using a PKCS#11 device +pub fn sign_with_pkcs11_device( + session: &Session, + object_label: &str, + data: &[u8], +) -> Result> { + // Find private key + let template = vec![ + Attribute::Label(object_label.as_bytes().to_vec()), + Attribute::Class(ObjectClass::PRIVATE_KEY), + ]; + + let objects = session + .find_objects(&template) + .context("Failed to find private key object")?; + + if objects.is_empty() { + anyhow::bail!("No private key found with label '{}'", object_label); + } + + let private_key_handle = objects[0]; + + // Determine the mechanism based on key type + let key_type_attr = session + .get_attributes(private_key_handle, &[AttributeType::KeyType]) + .context("Failed to get key type")?; + + let mechanism = if let Some(Attribute::KeyType(key_type)) = key_type_attr.first() { + match *key_type { + cryptoki::object::KeyType::EC => Mechanism::Ecdsa, + cryptoki::object::KeyType::RSA => Mechanism::RsaPkcs, + _ => anyhow::bail!("Unsupported key type for signing"), + } + } else { + anyhow::bail!("Unable to determine key type"); + }; + + // Sign the data + let signature = session + .sign(&mechanism, private_key_handle, data) + .context("Failed to sign data with PKCS#11 device")?; + + Ok(signature) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_device_type_from_str() { + assert_eq!(DeviceType::from_str("tpm").unwrap(), DeviceType::Tpm); + assert_eq!(DeviceType::from_str("TPM").unwrap(), DeviceType::Tpm); + assert_eq!( + DeviceType::from_str("yubikey").unwrap(), + DeviceType::Yubikey + ); + assert_eq!(DeviceType::from_str("yk").unwrap(), DeviceType::Yubikey); + assert_eq!(DeviceType::from_str("auto").unwrap(), DeviceType::Auto); + assert!(DeviceType::from_str("invalid").is_err()); + } + + #[test] + fn test_auth_method_from_str() { + match Pkcs11AuthMethod::from_str("none").unwrap() { + Pkcs11AuthMethod::None => {} + _ => panic!("Expected None"), + } + + match Pkcs11AuthMethod::from_str("prompt").unwrap() { + Pkcs11AuthMethod::Prompt => {} + _ => panic!("Expected Prompt"), + } + + match Pkcs11AuthMethod::from_str("env").unwrap() { + Pkcs11AuthMethod::EnvVar(v) => assert_eq!(v, "AVOCADO_PKCS11_PIN"), + _ => panic!("Expected EnvVar"), + } + } + + #[test] + fn test_build_pkcs11_uri() { + let uri = build_pkcs11_uri("MyToken", "signing-key"); + assert_eq!(uri, "pkcs11:token=MyToken;object=signing-key;type=private"); + + let uri = build_pkcs11_uri("Token With Spaces", "key label"); + assert!(uri.contains("%20")); + } + + #[test] + fn test_parse_pkcs11_uri() { + let (token, object) = parse_pkcs11_uri("pkcs11:token=MyToken;object=signing-key").unwrap(); + assert_eq!(token, "MyToken"); + assert_eq!(object, "signing-key"); + + let (token, object) = + parse_pkcs11_uri("pkcs11:token=Token%20Name;object=key%20label;type=private").unwrap(); + assert_eq!(token, "Token Name"); + assert_eq!(object, "key label"); + + assert!(parse_pkcs11_uri("invalid").is_err()); + assert!(parse_pkcs11_uri("pkcs11:token=Only").is_err()); + } + + #[test] + fn test_generate_keyid_from_public_key() { + let test_key = b"test public key data"; + let keyid = generate_keyid_from_public_key(test_key); + assert!(keyid.starts_with("sha256-")); + assert_eq!(keyid.len(), 7 + 16); // "sha256-" + 16 hex chars + } +} + diff --git a/src/utils/signing_keys.rs b/src/utils/signing_keys.rs index 35aee0b..9edb398 100644 --- a/src/utils/signing_keys.rs +++ b/src/utils/signing_keys.rs @@ -25,7 +25,7 @@ const SIGNING_KEYS_DIR: &str = "signing-keys"; pub struct KeyEntry { /// Unique key identifier (SHA-256 hash of public key) pub keyid: String, - /// Cryptographic algorithm used (always "ed25519" for now) + /// Cryptographic algorithm used (e.g., "ed25519", "ecdsa-p256", "ecdsa-p384", "rsa2048", "rsa4096") pub algorithm: String, /// Timestamp when the key was created/registered pub created_at: DateTime, @@ -318,19 +318,23 @@ mod tests { fn test_key_serialization() { // Test that we can save and load keys using the seed let (sk, pk) = generate_keypair(); - + // Serialize the seed (this is what we store on disk) let seed = sk.seed(); let seed_bytes = seed.as_ref(); assert_eq!(seed_bytes.len(), 32, "Seed should be 32 bytes"); - + // Reconstruct the key from the seed (this is what we do when loading) - let seed_reconstructed = Seed::from_slice(seed_bytes) - .expect("Should parse seed from bytes"); + let seed_reconstructed = + Seed::from_slice(seed_bytes).expect("Should parse seed from bytes"); let keypair_reconstructed = KeyPair::from_seed(seed_reconstructed); - + // The reconstructed key should produce the same public key - assert_eq!(pk.as_ref(), keypair_reconstructed.pk.as_ref(), "Public keys should match"); + assert_eq!( + pk.as_ref(), + keypair_reconstructed.pk.as_ref(), + "Public keys should match" + ); } #[test] diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..4021f8e --- /dev/null +++ b/tests/README.md @@ -0,0 +1,129 @@ +# Integration Tests for PKCS#11/TPM Support + +## Running Tests Locally + +### Automated Tests (Software TPM) + +The integration tests automatically start a software TPM simulator (swtpm) for testing: + +```bash +# Install prerequisites +sudo apt install swtpm swtpm-tools libtpm2-pkcs11-1 tpm2-tools + +# Run the tests +cargo test --test pkcs11_integration_test +``` + +### What the Tests Do + +1. **Automatically start swtpm** - A software TPM simulator in the background +2. **Initialize the TPM** - Set up the simulated TPM device +3. **Test key generation** - Generate ECC P-256 keys in the TPM +4. **Test signing** - Sign data using TPM-backed keys +5. **Test end-to-end workflow** - Complete key lifecycle +6. **Test key registration and removal** - Register TPM keys with avocado and remove them by name or key ID +7. **Cleanup** - Automatically stop swtpm and remove temporary files + +### Test Output + +``` +running 11 tests +test test_auth_method_parsing ... ok +test test_device_type_parsing ... ok +test test_keyid_generation ... ok +test test_pkcs11_uri_building ... ok +test test_pkcs11_uri_parsing ... ok +test test_tpm_connection ... ok +test test_tpm_key_generation ... ok +test test_tpm_signing ... ok +test test_end_to_end_tpm_workflow ... ok +test test_tpm_key_registration_and_removal ... ok +``` + +If swtpm is not available, tests will be skipped with a helpful message. + +## CI/CD Integration + +### GitHub Actions Example + +```yaml +- name: Install TPM Testing Tools + run: | + sudo apt-get update + sudo apt-get install -y swtpm swtpm-tools libtpm2-pkcs11-1 tpm2-tools + +- name: Run PKCS#11 Integration Tests + run: cargo test --test pkcs11_integration_test +``` + +### GitLab CI Example + +```yaml +test:pkcs11: + stage: test + before_script: + - apt-get update + - apt-get install -y swtpm swtpm-tools libtpm2-pkcs11-1 tpm2-tools + script: + - cargo test --test pkcs11_integration_test +``` + +## Testing with Real Hardware + +To test with actual TPM or YubiKey hardware: + +### Physical TPM + +```bash +# Install TPM packages +sudo apt install libtpm2-pkcs11-1 tpm2-tools + +# Add your user to the TPM group +sudo usermod -a -G tss $USER + +# Log out and back in, then test +avocado signing-keys create my-tpm-key --pkcs11-device tpm --generate --auth none +``` + +### YubiKey + +```bash +# Install YubiKey packages +sudo apt install yubikey-manager libykcs11-1 + +# Insert YubiKey, then test +avocado signing-keys create my-yk-key --pkcs11-device yubikey --generate --auth prompt +``` + +## Troubleshooting + +### swtpm not found + +```bash +sudo apt install swtpm swtpm-tools +``` + +### TPM PKCS#11 library not found + +```bash +sudo apt install libtpm2-pkcs11-1 +``` + +### Tests are skipped + +If tests are skipped, check that both swtpm and libtpm2-pkcs11-1 are installed: + +```bash +which swtpm +dpkg -l | grep libtpm2-pkcs11 +``` + +### Permission denied errors + +When testing with real TPM hardware, ensure your user is in the `tss` group: + +```bash +sudo usermod -a -G tss $USER +newgrp tss +``` + diff --git a/tests/pkcs11_integration_test.rs b/tests/pkcs11_integration_test.rs new file mode 100644 index 0000000..cdaf3ac --- /dev/null +++ b/tests/pkcs11_integration_test.rs @@ -0,0 +1,613 @@ +//! Integration tests for PKCS#11 device support +//! +//! These tests automatically start a software TPM (swtpm) for testing. +//! Prerequisites: +//! +//! 1. Install swtpm and libtpm2-pkcs11-1: +//! ```bash +//! sudo apt-get install swtpm swtpm-tools libtpm2-pkcs11-1 tpm2-tools +//! ``` +//! +//! 2. Run the tests: +//! ```bash +//! cargo test --test pkcs11_integration_test +//! ``` +//! +//! The tests will automatically: +//! - Start a software TPM simulator +//! - Initialize it +//! - Set up the environment +//! - Run tests +//! - Clean up + +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::thread; +use std::time::Duration; + +/// SWTPM manager to handle TPM simulator lifecycle +struct SwtpmInstance { + process: Option, + state_dir: PathBuf, +} + +impl SwtpmInstance { + fn new() -> Result> { + // Create temporary directory for TPM state + let state_dir = std::env::temp_dir().join(format!("tpm-test-{}", std::process::id())); + fs::create_dir_all(&state_dir)?; + + let port = 2321; + + // Start swtpm socket + let process = Command::new("swtpm") + .args([ + "socket", + "--tpmstate", + &format!("dir={}", state_dir.display()), + "--tpm2", + "--ctrl", + &format!("type=tcp,port={}", port + 1), + "--server", + &format!("type=tcp,port={}", port), + "--flags", + "not-need-init", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + + // Wait for swtpm to be ready + thread::sleep(Duration::from_millis(500)); + + // Set environment variables for TPM communication + env::set_var( + "TPM2TOOLS_TCTI", + format!("swtpm:host=127.0.0.1,port={}", port), + ); + env::set_var( + "TPM2_PKCS11_TCTI", + format!("swtpm:host=127.0.0.1,port={}", port), + ); + + let instance = SwtpmInstance { + process: Some(process), + state_dir, + }; + + // Initialize the TPM + instance.initialize_tpm()?; + + Ok(instance) + } + + fn initialize_tpm(&self) -> Result<(), Box> { + // Initialize TPM + let output = Command::new("tpm2_startup").arg("-c").output()?; + + if !output.status.success() { + eprintln!( + "Failed to initialize TPM: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + // Create primary key + let output = Command::new("tpm2_createprimary") + .args([ + "-C", + "o", + "-c", + &format!("{}/primary.ctx", self.state_dir.display()), + ]) + .output()?; + + if !output.status.success() { + eprintln!( + "Failed to create primary key: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + Ok(()) + } + + fn is_available() -> bool { + // Check if swtpm is installed + Command::new("swtpm") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } +} + +impl Drop for SwtpmInstance { + fn drop(&mut self) { + // Kill swtpm process + if let Some(mut process) = self.process.take() { + let _ = process.kill(); + let _ = process.wait(); + } + + // Clean up state directory + let _ = fs::remove_dir_all(&self.state_dir); + } +} + +/// Check if TPM PKCS#11 library is available +fn tpm_pkcs11_available() -> bool { + use avocado_cli::utils::pkcs11_devices::{get_pkcs11_module_path, DeviceType}; + + get_pkcs11_module_path(&DeviceType::Tpm).is_ok() +} + +#[test] +#[ignore] // Run with: cargo test --test pkcs11_integration_test -- --ignored +fn test_pkcs11_uri_parsing() { + use avocado_cli::utils::pkcs11_devices::parse_pkcs11_uri; + + // Test valid URIs + let (token, object) = parse_pkcs11_uri("pkcs11:token=MyToken;object=signing-key").unwrap(); + assert_eq!(token, "MyToken"); + assert_eq!(object, "signing-key"); + + // Test URI with spaces (encoded) + let (token, object) = + parse_pkcs11_uri("pkcs11:token=Test%20Token;object=my%20key;type=private").unwrap(); + assert_eq!(token, "Test Token"); + assert_eq!(object, "my key"); + + // Test invalid URIs + assert!(parse_pkcs11_uri("invalid:token=Test").is_err()); + assert!(parse_pkcs11_uri("pkcs11:token=Test").is_err()); // Missing object + assert!(parse_pkcs11_uri("pkcs11:object=test").is_err()); // Missing token +} + +#[test] +#[ignore] // Run with: cargo test --test pkcs11_integration_test -- --ignored +fn test_pkcs11_uri_building() { + use avocado_cli::utils::pkcs11_devices::build_pkcs11_uri; + + let uri = build_pkcs11_uri("MyToken", "signing-key"); + assert_eq!(uri, "pkcs11:token=MyToken;object=signing-key;type=private"); + + // Test with spaces + let uri = build_pkcs11_uri("Test Token", "my key"); + assert!(uri.contains("Test%20Token")); + assert!(uri.contains("my%20key")); +} + +#[test] +#[ignore] // Run with: cargo test --test pkcs11_integration_test -- --ignored +fn test_device_type_parsing() { + use avocado_cli::utils::pkcs11_devices::DeviceType; + use std::str::FromStr; + + assert_eq!(DeviceType::from_str("tpm").unwrap(), DeviceType::Tpm); + assert_eq!(DeviceType::from_str("TPM").unwrap(), DeviceType::Tpm); + assert_eq!( + DeviceType::from_str("yubikey").unwrap(), + DeviceType::Yubikey + ); + assert_eq!(DeviceType::from_str("yk").unwrap(), DeviceType::Yubikey); + assert_eq!(DeviceType::from_str("auto").unwrap(), DeviceType::Auto); + assert!(DeviceType::from_str("invalid").is_err()); +} + +#[test] +#[ignore] // Run with: cargo test --test pkcs11_integration_test -- --ignored +fn test_auth_method_parsing() { + use avocado_cli::utils::pkcs11_devices::Pkcs11AuthMethod; + use std::str::FromStr; + + match Pkcs11AuthMethod::from_str("none").unwrap() { + Pkcs11AuthMethod::None => {} + _ => panic!("Expected None"), + } + + match Pkcs11AuthMethod::from_str("prompt").unwrap() { + Pkcs11AuthMethod::Prompt => {} + _ => panic!("Expected Prompt"), + } + + match Pkcs11AuthMethod::from_str("env").unwrap() { + Pkcs11AuthMethod::EnvVar(v) => assert_eq!(v, "AVOCADO_PKCS11_PIN"), + _ => panic!("Expected EnvVar"), + } + + assert!(Pkcs11AuthMethod::from_str("invalid").is_err()); +} + +#[test] +#[ignore] // Run with: cargo test --test pkcs11_integration_test -- --ignored +fn test_keyid_generation() { + use avocado_cli::utils::pkcs11_devices::generate_keyid_from_public_key; + + let test_key = b"test public key data"; + let keyid = generate_keyid_from_public_key(test_key); + + assert!(keyid.starts_with("sha256-")); + assert_eq!(keyid.len(), 7 + 16); // "sha256-" + 16 hex chars + + // Test determinism + let keyid2 = generate_keyid_from_public_key(test_key); + assert_eq!(keyid, keyid2); +} + +#[test] +fn test_tpm_connection() { + if !SwtpmInstance::is_available() { + eprintln!("swtpm not available, skipping test. Install: sudo apt install swtpm"); + return; + } + + if !tpm_pkcs11_available() { + eprintln!("TPM PKCS#11 library not available, skipping test. Install: sudo apt install libtpm2-pkcs11-1"); + return; + } + + let _tpm = SwtpmInstance::new().expect("Failed to start TPM simulator"); + + use avocado_cli::utils::pkcs11_devices::{get_pkcs11_module_path, DeviceType}; + use cryptoki::context::{CInitializeArgs, Pkcs11}; + + let module_path = + get_pkcs11_module_path(&DeviceType::Tpm).expect("Failed to find PKCS#11 module path"); + + println!("Using PKCS#11 module: {}", module_path.display()); + + let pkcs11 = Pkcs11::new(module_path).expect("Failed to load PKCS#11 module"); + + pkcs11 + .initialize(CInitializeArgs::OsThreads) + .expect("Failed to initialize PKCS#11"); + + let slots = pkcs11 + .get_slots_with_token() + .expect("Failed to get slots with tokens"); + + if slots.is_empty() { + eprintln!("No tokens found - this is expected for a fresh TPM"); + return; + } + + println!("Found {} token(s)", slots.len()); + + for slot in slots { + let token_info = pkcs11 + .get_token_info(slot) + .expect("Failed to get token info"); + println!("Token label: {}", token_info.label()); + } +} + +#[test] +fn test_tpm_key_generation() { + if !SwtpmInstance::is_available() { + eprintln!("swtpm not available, skipping test. Install: sudo apt install swtpm"); + return; + } + + if !tpm_pkcs11_available() { + eprintln!("TPM PKCS#11 library not available, skipping test. Install: sudo apt install libtpm2-pkcs11-1"); + return; + } + + let _tpm = SwtpmInstance::new().expect("Failed to start TPM simulator"); + + use avocado_cli::utils::pkcs11_devices::{ + generate_keypair as generate_pkcs11_keypair, init_pkcs11_session, DeviceType, KeyAlgorithm, + Pkcs11AuthMethod, + }; + + let auth_method = Pkcs11AuthMethod::None; + let device_type = DeviceType::Tpm; + + let (_pkcs11, session) = init_pkcs11_session(&device_type, None, "", &auth_method) + .expect("Failed to initialize PKCS#11 session"); + + // Generate an ECC P-256 keypair + let label = "test-tpm-key"; + let algorithm = KeyAlgorithm::EccP256; + + let (public_key_bytes, keyid, algo_str) = generate_pkcs11_keypair(&session, label, &algorithm) + .expect("Failed to generate keypair in TPM"); + + assert!( + !public_key_bytes.is_empty(), + "Public key bytes should not be empty" + ); + assert!( + keyid.starts_with("sha256-"), + "KeyID should start with sha256-" + ); + assert_eq!(algo_str, "ecdsa-p256", "Algorithm should be ecdsa-p256"); + + println!("Generated TPM key:"); + println!(" KeyID: {}", keyid); + println!(" Algorithm: {}", algo_str); + println!(" Public key size: {} bytes", public_key_bytes.len()); +} + +#[test] +fn test_tpm_signing() { + if !SwtpmInstance::is_available() { + eprintln!("swtpm not available, skipping test. Install: sudo apt install swtpm"); + return; + } + + if !tpm_pkcs11_available() { + eprintln!("TPM PKCS#11 library not available, skipping test. Install: sudo apt install libtpm2-pkcs11-1"); + return; + } + + let _tpm = SwtpmInstance::new().expect("Failed to start TPM simulator"); + + use avocado_cli::utils::pkcs11_devices::{ + generate_keypair as generate_pkcs11_keypair, init_pkcs11_session, sign_with_pkcs11_device, + DeviceType, KeyAlgorithm, Pkcs11AuthMethod, + }; + use sha2::{Digest, Sha256}; + + let auth_method = Pkcs11AuthMethod::None; + let device_type = DeviceType::Tpm; + + let (_pkcs11, session) = init_pkcs11_session(&device_type, None, "", &auth_method) + .expect("Failed to initialize PKCS#11 session"); + + // Generate a keypair + let label = "test-tpm-signing-key"; + let algorithm = KeyAlgorithm::EccP256; + + let (_public_key_bytes, _keyid, _algo_str) = + generate_pkcs11_keypair(&session, label, &algorithm) + .expect("Failed to generate keypair in TPM"); + + // Create some test data to sign + let test_data = b"Hello, TPM PKCS#11 signing!"; + let mut hasher = Sha256::new(); + hasher.update(test_data); + let hash = hasher.finalize(); + + // Sign the hash + let signature = + sign_with_pkcs11_device(&session, label, &hash).expect("Failed to sign data with TPM"); + + assert!(!signature.is_empty(), "Signature should not be empty"); + println!("TPM signature size: {} bytes", signature.len()); +} + +/// Manual test instructions for hardware devices +#[test] +#[ignore] +fn test_manual_instructions() { + println!("\n=== Manual Hardware Testing Instructions ===\n"); + println!("The automated tests use a software TPM (swtpm)."); + println!("To test with real hardware:\n"); + println!("1. For Physical TPM:"); + println!(" sudo apt install libtpm2-pkcs11-1 tpm2-tools"); + println!(" sudo usermod -a -G tss $USER # Add user to TPM group"); + println!(" # Log out and back in"); + println!( + " avocado signing-keys create my-tpm-key --pkcs11-device tpm --generate --auth none\n" + ); + println!("2. For YubiKey:"); + println!(" sudo apt install yubikey-manager libykcs11-1"); + println!(" # Insert YubiKey"); + println!(" avocado signing-keys create my-yk-key --pkcs11-device yubikey --generate --auth prompt\n"); + println!("3. Run integration tests:"); + println!(" cargo test --test pkcs11_integration_test\n"); +} + +#[test] +fn test_end_to_end_tpm_workflow() { + if !SwtpmInstance::is_available() { + eprintln!("swtpm not available, skipping test. Install: sudo apt install swtpm"); + return; + } + + if !tpm_pkcs11_available() { + eprintln!("TPM PKCS#11 library not available, skipping test. Install: sudo apt install libtpm2-pkcs11-1"); + return; + } + + let _tpm = SwtpmInstance::new().expect("Failed to start TPM simulator"); + + use avocado_cli::utils::pkcs11_devices::{ + build_pkcs11_uri, find_existing_key, generate_keypair as generate_pkcs11_keypair, + init_pkcs11_session, sign_with_pkcs11_device, DeviceType, KeyAlgorithm, Pkcs11AuthMethod, + }; + use sha2::{Digest, Sha256}; + + let auth_method = Pkcs11AuthMethod::None; + let device_type = DeviceType::Tpm; + + // Step 1: Initialize PKCS#11 session + let (pkcs11, session) = init_pkcs11_session(&device_type, None, "", &auth_method) + .expect("Failed to initialize PKCS#11 session"); + + // Step 2: Generate a key + let label = "e2e-test-key"; + let algorithm = KeyAlgorithm::EccP256; + + let (public_key_bytes, keyid, algo_str) = + generate_pkcs11_keypair(&session, label, &algorithm).expect("Failed to generate keypair"); + + println!("Step 1: Generated key with ID: {}", keyid); + assert_eq!(algo_str, "ecdsa-p256"); + + // Step 3: Find the key we just created + let (found_pubkey, found_keyid, _found_algo) = + find_existing_key(&session, label).expect("Failed to find existing key"); + + println!("Step 2: Found existing key: {}", found_keyid); + assert_eq!(keyid, found_keyid); + assert_eq!(public_key_bytes.len(), found_pubkey.len()); + + // Step 4: Build PKCS#11 URI + let slot = session + .get_session_info() + .expect("Failed to get session info") + .slot_id(); + let token_info = pkcs11 + .get_token_info(slot) + .expect("Failed to get token info"); + let uri = build_pkcs11_uri(token_info.label(), label); + + println!("Step 3: Built URI: {}", uri); + assert!(uri.starts_with("pkcs11:")); + + // Step 5: Sign some data + let test_data = b"End-to-end test data"; + let mut hasher = Sha256::new(); + hasher.update(test_data); + let hash = hasher.finalize(); + + let signature = + sign_with_pkcs11_device(&session, label, &hash).expect("Failed to sign with TPM"); + + println!( + "Step 4: Signed data, signature size: {} bytes", + signature.len() + ); + assert!(!signature.is_empty()); + + println!("\n✅ End-to-end TPM workflow test passed!"); +} + +#[test] +fn test_tpm_key_registration_and_removal() { + if !SwtpmInstance::is_available() { + eprintln!("swtpm not available, skipping test. Install: sudo apt install swtpm"); + return; + } + + if !tpm_pkcs11_available() { + eprintln!("TPM PKCS#11 library not available, skipping test. Install: sudo apt install libtpm2-pkcs11-1"); + return; + } + + let _tpm = SwtpmInstance::new().expect("Failed to start TPM simulator"); + + use avocado_cli::commands::signing_keys::create::SigningKeysCreateCommand; + use avocado_cli::commands::signing_keys::remove::SigningKeysRemoveCommand; + use avocado_cli::utils::signing_keys::KeysRegistry; + use std::env; + + // Set up test environment + let test_home = env::temp_dir().join(format!("avocado-test-{}", std::process::id())); + fs::create_dir_all(&test_home).expect("Failed to create test home directory"); + env::set_var("HOME", &test_home); + + // Step 1: Create a TPM key using the command + let key_name = "test-tpm-remove-key"; + let create_cmd = SigningKeysCreateCommand::new( + Some(key_name.to_string()), + None, + Some("tpm".to_string()), + None, // token - use first available + Some("test-key-label".to_string()), + false, // don't generate, reference existing + "none".to_string(), + ); + + // First, generate the key in TPM directly + use avocado_cli::utils::pkcs11_devices::{ + generate_keypair as generate_pkcs11_keypair, init_pkcs11_session, DeviceType, + KeyAlgorithm, Pkcs11AuthMethod, + }; + + let auth_method = Pkcs11AuthMethod::None; + let device_type = DeviceType::Tpm; + + let (_pkcs11, session) = init_pkcs11_session(&device_type, None, "", &auth_method) + .expect("Failed to initialize PKCS#11 session"); + + let (_public_key_bytes, keyid, _algo_str) = + generate_pkcs11_keypair(&session, "test-key-label", &KeyAlgorithm::EccP256) + .expect("Failed to generate keypair in TPM"); + + println!("Generated TPM key with ID: {}", keyid); + + // Step 2: Register the key using the create command + create_cmd + .execute() + .expect("Failed to register TPM key with avocado"); + + println!("Registered key '{}' with avocado", key_name); + + // Step 3: Verify key is in registry + let registry = KeysRegistry::load().expect("Failed to load registry"); + let entry = registry + .get_key(key_name) + .expect("Key should be in registry"); + + assert_eq!(entry.keyid, keyid); + println!("Verified key exists in registry"); + + // Step 4: Remove the key by name + let remove_cmd = SigningKeysRemoveCommand::new(key_name.to_string()); + remove_cmd + .execute() + .expect("Failed to remove key by name"); + + println!("Removed key by name"); + + // Step 5: Verify key is removed from registry + let registry = KeysRegistry::load().expect("Failed to load registry after removal"); + assert!( + registry.get_key(key_name).is_none(), + "Key should be removed from registry" + ); + + println!("Verified key removed from registry"); + + // Step 6: Test removal by key ID + // Generate and register another key + let key_name2 = "test-tpm-remove-key-2"; + let create_cmd2 = SigningKeysCreateCommand::new( + Some(key_name2.to_string()), + None, + Some("tpm".to_string()), + None, + Some("test-key-label-2".to_string()), + false, + "none".to_string(), + ); + + let (_public_key_bytes2, keyid2, _algo_str2) = + generate_pkcs11_keypair(&session, "test-key-label-2", &KeyAlgorithm::EccP256) + .expect("Failed to generate second keypair in TPM"); + + create_cmd2 + .execute() + .expect("Failed to register second TPM key"); + + println!("Generated and registered second key with ID: {}", keyid2); + + // Remove by key ID + let remove_cmd2 = SigningKeysRemoveCommand::new(keyid2.clone()); + remove_cmd2 + .execute() + .expect("Failed to remove key by key ID"); + + println!("Removed key by key ID"); + + // Verify removal + let registry = KeysRegistry::load().expect("Failed to load registry after second removal"); + assert!( + registry.get_key(key_name2).is_none(), + "Second key should be removed from registry" + ); + + println!("Verified second key removed from registry"); + + // Clean up test home + let _ = fs::remove_dir_all(&test_home); + + println!("\n✅ TPM key registration and removal test passed!"); +} + From 167475d53e27737211660a96005b9df12b282693 Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Wed, 17 Dec 2025 20:20:09 -0500 Subject: [PATCH 12/12] Update signing keys to support yubikey --- YUBIKEY_TESTING.md | 231 +++++++++++++++++++++++++++ docs/signing-keys.md | 22 +++ src/commands/signing_keys/create.rs | 19 +-- src/commands/signing_keys/list.rs | 19 ++- src/commands/signing_keys/remove.rs | 76 ++++++++- src/main.rs | 9 +- src/utils/image_signing.rs | 26 +-- src/utils/pkcs11_devices.rs | 236 ++++++++++++++++++++++++---- src/utils/signing_keys.rs | 15 +- tests/pkcs11_integration_test.rs | 19 +-- 10 files changed, 600 insertions(+), 72 deletions(-) create mode 100644 YUBIKEY_TESTING.md diff --git a/YUBIKEY_TESTING.md b/YUBIKEY_TESTING.md new file mode 100644 index 0000000..aaaacef --- /dev/null +++ b/YUBIKEY_TESTING.md @@ -0,0 +1,231 @@ +# YubiKey Testing Guide + +This guide walks through testing the PKCS#11 integration with a physical YubiKey. + +## Prerequisites + +You have OpenSC installed (`/usr/lib/x86_64-linux-gnu/opensc-pkcs11.so`), which can interface with YubiKey PIV applets. + +## Step 1: Check YubiKey Detection + +First, verify your YubiKey is detected by the system: + +```bash +# Check if YubiKey is detected via USB +lsusb | grep -i yubico + +# List PKCS#11 tokens (should show YubiKey PIV token) +pkcs11-tool --module /usr/lib/x86_64-linux-gnu/opensc-pkcs11.so --list-token-slots +``` + +Expected output should show something like: +``` +Available slots: +Slot 0 (0x0): Yubico YubiKey OTP+FIDO+CCID 00 00 + token label : YubiKey PIV #XXXXXXXX + token manufacturer : Yubico (www.yubico.com) + ... +``` + +## Step 2: Initialize YubiKey PIV (If Needed) + +If this is a fresh YubiKey or PIV hasn't been initialized: + +```bash +# Install YubiKey manager (optional, for easier management) +sudo apt install yubikey-manager + +# Check YubiKey info +ykman info + +# The PIV applet should already be available by default +# Default PIN is 123456, default PUK is 12345678 +``` + +**Important**: YubiKey PIV slots: +- Slot 9a: Authentication +- Slot 9c: Digital Signature (recommended for signing keys) +- Slot 9d: Key Management +- Slot 9e: Card Authentication + +## Step 3: Test Creating a Key in YubiKey + +### Option A: Generate New Key in YubiKey + +```bash +# Generate a new ECC P-256 key in the YubiKey +avocado signing-keys create my-yubikey-key \ + --pkcs11-device yubikey \ + --token "YubiKey PIV #XXXXXXXX" \ + --generate \ + --auth prompt +``` + +When prompted, enter your YubiKey PIV PIN (default: `123456`). + +**Note**: Replace `YubiKey PIV #XXXXXXXX` with your actual token label from Step 1. + +### Option B: Reference Existing Key + +If you already have a key in the YubiKey: + +```bash +# Reference an existing key by label +avocado signing-keys create my-existing-yk-key \ + --pkcs11-device yubikey \ + --token "YubiKey PIV #XXXXXXXX" \ + --key-label "SIGN key" \ + --auth prompt +``` + +## Step 4: List Registered Keys + +```bash +# List all signing keys registered with avocado +avocado signing-keys list +``` + +Expected output: +``` +Registered signing keys: + + my-yubikey-key + Key ID: sha256-abcdef123456 + Algorithm: ecdsa-p256 + Type: pkcs11 + Created: 2025-12-18 00:45:00 UTC +``` + +## Step 5: Test Signing (Optional) + +If you want to test that the YubiKey key actually works for signing: + +```bash +# You would use this key when signing an image +# This will prompt for your YubiKey PIN when signing +``` + +## Step 6: Remove Key from Registry + +### Remove by Name + +```bash +avocado signing-keys remove my-yubikey-key +``` + +Expected output: +``` +Removed signing key 'my-yubikey-key' + Key ID: sha256-abcdef123456 + Note: PKCS#11 key reference removed (hardware key unchanged) +``` + +### Remove by Key ID + +```bash +avocado signing-keys remove sha256-abcdef123456 +``` + +**Important**: Removing the key from avocado's registry only removes the reference. The actual key remains in your YubiKey hardware. + +## Step 7: Verify Removal + +```bash +# List keys - should not show the removed key +avocado signing-keys list + +# Verify key still exists in YubiKey hardware +pkcs11-tool --module /usr/lib/x86_64-linux-gnu/opensc-pkcs11.so \ + --list-objects --login --pin 123456 +``` + +## Troubleshooting + +### YubiKey Not Detected + +```bash +# Check USB connection +lsusb | grep -i yubico + +# Check if pcscd is running (needed for smart cards) +sudo systemctl status pcscd +sudo systemctl start pcscd +``` + +### "No matching token found" + +The token name must match exactly. List available tokens: + +```bash +pkcs11-tool --module /usr/lib/x86_64-linux-gnu/opensc-pkcs11.so --list-token-slots +``` + +Use the exact token label shown (e.g., `YubiKey PIV #12345678`). + +### "PIN incorrect" or Authentication Fails + +- Default PIV PIN: `123456` +- Default PIV PUK: `12345678` +- After 3 failed PIN attempts, the PIV applet locks +- You can unlock with PUK or reset the PIV applet (WARNING: erases all keys!) + +```bash +# Reset PIV applet (DESTRUCTIVE - erases all keys!) +ykman piv reset +``` + +### Using Environment Variable for PIN + +```bash +# Set PIN in environment variable +export AVOCADO_PKCS11_PIN=123456 + +# Create key without prompting +avocado signing-keys create my-yk-key \ + --pkcs11-device yubikey \ + --token "YubiKey PIV #XXXXXXXX" \ + --generate \ + --auth env +``` + +## Testing Checklist + +- [ ] YubiKey detected via `lsusb` +- [ ] Token visible via `pkcs11-tool` +- [ ] Can create new key in YubiKey with `--generate` +- [ ] Can reference existing key with `--key-label` +- [ ] Key appears in `avocado signing-keys list` +- [ ] Can remove key by name +- [ ] Can remove key by key ID +- [ ] Key removed from registry but still in YubiKey hardware + +## Key Differences: TPM vs YubiKey + +| Feature | TPM | YubiKey | +|---------|-----|---------| +| Library | `libtpm2_pkcs11.so` | `opensc-pkcs11.so` or `libykcs11.so` | +| Token Label | Often "avocado" or custom | "YubiKey PIV #XXXXXXXX" | +| Default PIN | Custom (set during init) | `123456` | +| Key Storage | Unlimited (limited by TPM memory) | 4 PIV slots (9a, 9c, 9d, 9e) | +| Portability | Machine-bound | Portable (USB device) | + +## Advanced: Using Specific YubiKey PIV Slots + +YubiKey PIV has specific slots for different purposes. By default, avocado uses the label you provide, but you can target specific slots: + +```bash +# Generate key in PIV slot 9c (Digital Signature) +# The slot is determined by the key label in some PKCS#11 implementations +# For OpenSC, you might need to pre-create keys with specific labels + +# List all objects in YubiKey +pkcs11-tool --module /usr/lib/x86_64-linux-gnu/opensc-pkcs11.so \ + --list-objects --login --pin 123456 +``` + +## Support + +For issues specific to: +- **YubiKey hardware**: https://support.yubico.com/ +- **OpenSC**: https://github.com/OpenSC/OpenSC/wiki +- **Avocado CLI**: Check project documentation or file an issue diff --git a/docs/signing-keys.md b/docs/signing-keys.md index 5e55cb2..0d12bdc 100644 --- a/docs/signing-keys.md +++ b/docs/signing-keys.md @@ -184,10 +184,32 @@ Registered signing keys: ### Removing Keys +**Remove key reference (hardware key remains intact):** ```bash +# Remove by name avocado signing-keys remove my-production-key + +# Remove by key ID +avocado signing-keys remove sha256-069beb292983492c +``` + +**Permanently delete hardware key from device (requires confirmation):** +```bash +avocado signing-keys remove my-tpm-key --delete ``` +When you run the `--delete` command, you'll be prompted: +``` +⚠️ WARNING: This will PERMANENTLY delete the hardware key 'my-tpm-key' from the hardware device. +This action cannot be undone. Continue? [y/N]: +``` + +**Important Behavior:** +- **Without `--delete`**: Removes the key reference from avocado's registry only. The hardware key remains in the device. +- **With `--delete`**: Prompts for confirmation, then permanently deletes the key from the hardware device via PKCS#11. Requires PIN authentication. +- **File-based keys**: Always deleted from disk when removed (no `--delete` needed for destructive behavior). +- **PKCS#11 keys**: Require `--delete` to delete from hardware. + ## Configuration Format ### Mapping Keys in avocado.yaml diff --git a/src/commands/signing_keys/create.rs b/src/commands/signing_keys/create.rs index b1a06ff..6fd51cf 100644 --- a/src/commands/signing_keys/create.rs +++ b/src/commands/signing_keys/create.rs @@ -65,9 +65,10 @@ impl SigningKeysCreateCommand { let auth = get_device_auth(&auth_method)?; // Initialize PKCS#11 and open session - let (_pkcs11, session) = init_pkcs11_session(&device_type, self.token.as_deref(), &auth, &auth_method)?; + let (_pkcs11, session) = + init_pkcs11_session(&device_type, self.token.as_deref(), &auth, &auth_method)?; - let (_public_key_bytes, keyid, algorithm) = if self.generate { + let (_public_key_bytes, keyid, algorithm, private_key_label) = if self.generate { // Generate new key in device let label = self.name.as_ref().ok_or_else(|| { anyhow::anyhow!("--name is required when generating a hardware key") @@ -76,7 +77,9 @@ impl SigningKeysCreateCommand { // Default to ECC P-256 (most widely supported) let key_algorithm = KeyAlgorithm::EccP256; - generate_pkcs11_keypair(&session, label, &key_algorithm)? + let (pub_key, kid, algo) = + generate_pkcs11_keypair(&session, label, &key_algorithm)?; + (pub_key, kid, algo, label.clone()) } else if let Some(label) = &self.key_label { // Reference existing key in device find_existing_key(&session, label)? @@ -89,14 +92,8 @@ impl SigningKeysCreateCommand { let token_info = _pkcs11.get_token_info(slot)?; let token_label = token_info.label(); - // Build PKCS#11 URI - let object_label = self - .name - .as_ref() - .or(self.key_label.as_ref()) - .ok_or_else(|| anyhow::anyhow!("Name or key-label required"))?; - - let pkcs11_uri = build_pkcs11_uri(token_label, object_label); + // Build PKCS#11 URI using the private key label (for signing operations) + let pkcs11_uri = build_pkcs11_uri(token_label, &private_key_label); ( keyid, diff --git a/src/commands/signing_keys/list.rs b/src/commands/signing_keys/list.rs index d0e0ae5..e1cbcba 100644 --- a/src/commands/signing_keys/list.rs +++ b/src/commands/signing_keys/list.rs @@ -2,6 +2,7 @@ use anyhow::Result; +use crate::utils::pkcs11_devices::parse_pkcs11_uri; use crate::utils::signing_keys::{is_file_uri, is_pkcs11_uri, KeysRegistry}; /// Command to list all registered signing keys @@ -31,11 +32,23 @@ impl SigningKeysListCommand { for (name, entry) in keys { let key_type = if is_file_uri(&entry.uri) { - "file" + "file".to_string() } else if is_pkcs11_uri(&entry.uri) { - "pkcs11" + // Parse the URI to determine device type from token label + if let Ok((token_label, _)) = parse_pkcs11_uri(&entry.uri) { + let token_lower = token_label.to_lowercase(); + if token_lower.contains("tpm") || token_lower == "avocado" { + "tpm".to_string() + } else if token_lower.contains("yubi") || token_lower.contains("piv") { + "yubikey".to_string() + } else { + "pkcs11".to_string() + } + } else { + "pkcs11".to_string() + } } else { - "unknown" + "unknown".to_string() }; println!(" {}", name); diff --git a/src/commands/signing_keys/remove.rs b/src/commands/signing_keys/remove.rs index 4e84f5f..8c01de8 100644 --- a/src/commands/signing_keys/remove.rs +++ b/src/commands/signing_keys/remove.rs @@ -1,18 +1,37 @@ //! Remove signing key command. use anyhow::Result; +use std::io::{self, Write}; +use crate::utils::pkcs11_devices::delete_pkcs11_key; use crate::utils::signing_keys::{delete_key_files, is_file_uri, KeysRegistry}; /// Command to remove a signing key from the registry and filesystem pub struct SigningKeysRemoveCommand { /// Name of the key to remove pub name: String, + /// Delete hardware key from device + pub delete: bool, } impl SigningKeysRemoveCommand { - pub fn new(name: String) -> Self { - Self { name } + pub fn new(name: String, delete: bool) -> Self { + Self { name, delete } + } + + /// Prompt user for confirmation (returns true if user confirms) + fn confirm_deletion(key_name: &str, key_type: &str) -> Result { + println!( + "⚠️ WARNING: This will PERMANENTLY delete the {} key '{}' from the hardware device.", + key_type, key_name + ); + print!("This action cannot be undone. Continue? [y/N]: "); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + Ok(matches!(input.trim().to_lowercase().as_str(), "y" | "yes")) } pub fn execute(&self) -> Result<()> { @@ -31,7 +50,7 @@ impl SigningKeysRemoveCommand { break; } } - + if let Some((name, entry)) = found { (name, entry) } else { @@ -45,6 +64,18 @@ impl SigningKeysRemoveCommand { // If it's a file-based key, delete the key files if is_file_uri(&entry.uri) { + // File-based key - always delete files + if self.delete { + // Prompt for confirmation + if !Self::confirm_deletion(&key_name, "file")? { + println!("Deletion cancelled."); + // Re-add to registry since we removed it earlier + registry.add_key(key_name.clone(), entry)?; + registry.save()?; + return Ok(()); + } + } + match delete_key_files(&entry.keyid) { Ok(()) => { println!("Removed signing key '{}'", key_name); @@ -58,10 +89,41 @@ impl SigningKeysRemoveCommand { } } } else { - // PKCS#11 key - just remove from registry - println!("Removed signing key '{}'", key_name); - println!(" Key ID: {}", entry.keyid); - println!(" Note: PKCS#11 key reference removed (hardware key unchanged)"); + // PKCS#11 key + if self.delete { + // Prompt for confirmation + if !Self::confirm_deletion(&key_name, "hardware")? { + println!("Deletion cancelled."); + // Re-add to registry since we removed it earlier + registry.add_key(key_name.clone(), entry)?; + registry.save()?; + return Ok(()); + } + + // Attempt to delete from hardware + match delete_pkcs11_key(&entry.uri) { + Ok(()) => { + println!("Removed signing key '{}'", key_name); + println!(" Key ID: {}", entry.keyid); + println!(" ✓ Deleted from registry"); + println!(" ✓ Deleted from hardware device"); + } + Err(e) => { + println!("Removed signing key '{}' from registry", key_name); + println!(" Key ID: {}", entry.keyid); + println!(" ⚠️ Warning: Failed to delete from hardware: {}", e); + println!( + " You may need to delete it manually using device-specific tools." + ); + } + } + } else { + // Just remove from registry + println!("Removed signing key '{}'", key_name); + println!(" Key ID: {}", entry.keyid); + println!(" Note: PKCS#11 key reference removed (hardware key unchanged)"); + println!(" Tip: Use --delete to permanently delete the hardware key"); + } } Ok(()) diff --git a/src/main.rs b/src/main.rs index 2be7057..452d69b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -294,8 +294,11 @@ enum SigningKeysCommands { List, /// Remove a signing key Remove { - /// Name of the key to remove + /// Name or key ID of the key to remove name: String, + /// Delete hardware key from device (requires confirmation) + #[arg(long)] + delete: bool, }, } @@ -853,8 +856,8 @@ async fn main() -> Result<()> { cmd.execute()?; Ok(()) } - SigningKeysCommands::Remove { name } => { - let cmd = SigningKeysRemoveCommand::new(name); + SigningKeysCommands::Remove { name, delete } => { + let cmd = SigningKeysRemoveCommand::new(name, delete); cmd.execute()?; Ok(()) } diff --git a/src/utils/image_signing.rs b/src/utils/image_signing.rs index 602d123..170c953 100644 --- a/src/utils/image_signing.rs +++ b/src/utils/image_signing.rs @@ -403,23 +403,31 @@ fn sign_with_pkcs11(uri: &str, hash: &[u8]) -> Result> { DeviceType::Auto }; - // Get auth method from environment or use prompt - let auth_method = if env::var("AVOCADO_PKCS11_PIN").is_ok() { + // Get auth method from environment or prompt + let auth_method = if let Ok(_pin) = env::var("AVOCADO_PKCS11_PIN") { Pkcs11AuthMethod::EnvVar("AVOCADO_PKCS11_PIN".to_string()) } else { - // For signing operations, we'll try without auth first - // Most systems cache the PIN from key creation - Pkcs11AuthMethod::None + // For signing operations, prompt for PIN + // TPM and YubiKey typically require authentication for private key operations + Pkcs11AuthMethod::Prompt }; - // Initialize PKCS#11 and open session (no specific token, use first available) - let (_pkcs11, session) = init_pkcs11_session(&device_type, None, "", &auth_method) - .context("Failed to initialize PKCS#11 session for signing")?; + // Get the authentication credential + let auth = super::pkcs11_devices::get_device_auth(&auth_method) + .context("Failed to get authentication for signing")?; + + // Initialize PKCS#11 and open session with authentication + let (pkcs11, session) = + init_pkcs11_session(&device_type, Some(&token_label), &auth, &auth_method) + .context("Failed to initialize PKCS#11 session for signing")?; // Sign the data - let signature = sign_with_pkcs11_device(&session, &object_label, hash) + let signature = sign_with_pkcs11_device(&session, &object_label, hash, &auth) .context("Failed to sign data with PKCS#11 device")?; + // Explicitly keep pkcs11 alive until signing is done + drop(pkcs11); + Ok(signature) } diff --git a/src/utils/pkcs11_devices.rs b/src/utils/pkcs11_devices.rs index 203dd7b..4622cb4 100644 --- a/src/utils/pkcs11_devices.rs +++ b/src/utils/pkcs11_devices.rs @@ -397,26 +397,106 @@ pub fn generate_keypair( } /// Find an existing key by label -pub fn find_existing_key(session: &Session, label: &str) -> Result<(Vec, String, String)> { +/// Find an existing PKCS#11 keypair and return (public_key_bytes, keyid, algorithm, private_key_label) +pub fn find_existing_key( + session: &Session, + label: &str, +) -> Result<(Vec, String, String, String)> { + // Try exact match first let template = vec![ Attribute::Label(label.as_bytes().to_vec()), Attribute::Class(ObjectClass::PUBLIC_KEY), ]; - session - .find_objects(&template) - .context("Failed to search for key objects")?; - - let objects = session + let mut objects = session .find_objects(&template) .context("Failed to find key objects")?; + // If no exact match, try to find any public key and match by trimmed label + if objects.is_empty() { + let all_keys_template = vec![Attribute::Class(ObjectClass::PUBLIC_KEY)]; + + let all_objects = session + .find_objects(&all_keys_template) + .context("Failed to list all public keys")?; + + // Check each object's label + for obj in all_objects { + let attrs = session + .get_attributes(obj, &[AttributeType::Label]) + .context("Failed to get key label")?; + + if let Some(Attribute::Label(obj_label)) = attrs.first() { + let obj_label_str = String::from_utf8_lossy(obj_label).trim().to_string(); + if obj_label_str == label.trim() { + objects = vec![obj]; + break; + } + } + } + } + if objects.is_empty() { - anyhow::bail!("No key found with label '{}'", label); + // List available keys for helpful error message + let all_keys_template = vec![Attribute::Class(ObjectClass::PUBLIC_KEY)]; + let all_objects = session.find_objects(&all_keys_template).unwrap_or_default(); + + let mut available_labels = Vec::new(); + for obj in all_objects { + if let Ok(attrs) = session.get_attributes(obj, &[AttributeType::Label]) { + if let Some(Attribute::Label(obj_label)) = attrs.first() { + available_labels + .push(format!("'{}'", String::from_utf8_lossy(obj_label).trim())); + } + } + } + + anyhow::bail!( + "No key found with label '{}'. Available keys: {}", + label, + if available_labels.is_empty() { + "none".to_string() + } else { + available_labels.join(", ") + } + ); } let public_key_handle = objects[0]; + // Get the public key's CKA_ID to find the matching private key + let id_attrs = session + .get_attributes(public_key_handle, &[AttributeType::Id]) + .context("Failed to get key ID")?; + + // Find the corresponding private key using the same CKA_ID + let private_key_label = if let Some(Attribute::Id(key_id)) = id_attrs.first() { + let priv_template = vec![ + Attribute::Id(key_id.clone()), + Attribute::Class(ObjectClass::PRIVATE_KEY), + ]; + + let priv_objects = session + .find_objects(&priv_template) + .context("Failed to find private key")?; + + if !priv_objects.is_empty() { + let priv_label_attrs = session + .get_attributes(priv_objects[0], &[AttributeType::Label]) + .context("Failed to get private key label")?; + + if let Some(Attribute::Label(priv_label)) = priv_label_attrs.first() { + String::from_utf8_lossy(priv_label).trim().to_string() + } else { + label.to_string() // Fallback to input label + } + } else { + label.to_string() // Fallback to input label + } + } else { + label.to_string() // Fallback to input label + }; + // Determine algorithm from key type let key_type_attr = session .get_attributes(public_key_handle, &[AttributeType::KeyType]) @@ -430,7 +510,12 @@ pub fn find_existing_key(session: &Session, label: &str) -> Result<(Vec, Str // Generate keyid let keyid = generate_keyid_from_public_key(&public_key_bytes); - Ok((public_key_bytes, keyid, algorithm.as_str().to_string())) + Ok(( + public_key_bytes, + keyid, + algorithm.as_str().to_string(), + private_key_label, + )) } /// Extract public key bytes from a PKCS#11 object @@ -565,7 +650,7 @@ pub fn init_pkcs11_session( device_type: &DeviceType, token_label: Option<&str>, auth: &str, - auth_method: &Pkcs11AuthMethod, + _auth_method: &Pkcs11AuthMethod, ) -> Result<(Pkcs11, Session)> { // Get module path let module_path = get_pkcs11_module_path(device_type)?; @@ -585,27 +670,98 @@ pub fn init_pkcs11_session( .open_rw_session(slot) .context("Failed to open PKCS#11 session")?; - // Login if auth provided - if !auth.is_empty() - || matches!( - auth_method, - Pkcs11AuthMethod::Prompt | Pkcs11AuthMethod::EnvVar(_) - ) + // Login - auth should contain the PIN already + if !auth.is_empty() { + let auth_pin = AuthPin::new(auth.to_string()); + session + .login(UserType::User, Some(&auth_pin)) + .context("Failed to login to PKCS#11 device")?; + } + + Ok((pkcs11, session)) +} + +/// Delete a PKCS#11 key from hardware device +pub fn delete_pkcs11_key(uri: &str) -> Result<()> { + // Parse the URI to get token and object label + let (token_label, object_label) = parse_pkcs11_uri(uri)?; + + // Determine device type from token label (best effort) + let device_type = if token_label.to_lowercase().contains("tpm") { + DeviceType::Tpm + } else if token_label.to_lowercase().contains("yubi") + || token_label.to_lowercase().contains("piv") { - let pin = if auth.is_empty() { - get_device_auth(auth_method)? - } else { - auth.to_string() - }; + DeviceType::Yubikey + } else { + DeviceType::Auto + }; - if !pin.is_empty() { - session - .login(UserType::User, Some(&AuthPin::new(pin))) - .context("Failed to login to PKCS#11 device")?; - } + // Get module path + let module_path = get_pkcs11_module_path(&device_type)?; + let pkcs11 = Pkcs11::new(module_path).context("Failed to load PKCS#11 module")?; + + pkcs11 + .initialize(CInitializeArgs::OsThreads) + .context("Failed to initialize PKCS#11")?; + + // Find the token + let (slot, _token_info) = discover_device_token(&pkcs11, &device_type, Some(&token_label))?; + + // Open a session + let session = pkcs11 + .open_rw_session(slot) + .context("Failed to open session")?; + + // For deletion, we need to login with PIN + let pin_str = rpassword::prompt_password("Enter PIN to delete hardware key: ") + .context("Failed to read PIN")?; + let auth_pin = AuthPin::new(pin_str.clone()); + + session + .login(UserType::User, Some(&auth_pin)) + .context("Failed to login to PKCS#11 device")?; + + // Find the private key object + let template = vec![ + Attribute::Label(object_label.as_bytes().to_vec()), + Attribute::Class(ObjectClass::PRIVATE_KEY), + ]; + + let objects = session + .find_objects(&template) + .context("Failed to find objects")?; + + if objects.is_empty() { + anyhow::bail!( + "Private key '{}' not found in hardware device", + object_label + ); } - Ok((pkcs11, session)) + let private_key_handle = objects[0]; + + // Delete the private key + session + .destroy_object(private_key_handle) + .context("Failed to delete private key from device")?; + + // Also try to delete the corresponding public key + let pub_template = vec![ + Attribute::Label(object_label.as_bytes().to_vec()), + Attribute::Class(ObjectClass::PUBLIC_KEY), + ]; + + let pub_objects = session + .find_objects(&pub_template) + .context("Failed to find public key")?; + + if !pub_objects.is_empty() { + let public_key_handle = pub_objects[0]; + let _ = session.destroy_object(public_key_handle); // Best effort, ignore errors + } + + Ok(()) } /// Sign data using a PKCS#11 device @@ -613,6 +769,7 @@ pub fn sign_with_pkcs11_device( session: &Session, object_label: &str, data: &[u8], + pin: &str, ) -> Result> { // Find private key let template = vec![ @@ -630,6 +787,32 @@ pub fn sign_with_pkcs11_device( let private_key_handle = objects[0]; + // Check if the key requires always-authenticate + let always_auth_attr = session + .get_attributes(private_key_handle, &[AttributeType::AlwaysAuthenticate]) + .ok(); + + let requires_auth = if let Some(attrs) = always_auth_attr { + if let Some(Attribute::AlwaysAuthenticate(val)) = attrs.first() { + *val + } else { + false + } + } else { + false + }; + + if requires_auth { + // Key requires per-operation authentication (common with YubiKey) + // Use the provided PIN for context-specific login + let auth_pin = AuthPin::new(pin.to_string()); + + // Context-specific login for this operation + session + .login(UserType::ContextSpecific, Some(&auth_pin)) + .context("Failed to authenticate for signing operation")?; + } + // Determine the mechanism based on key type let key_type_attr = session .get_attributes(private_key_handle, &[AttributeType::KeyType]) @@ -720,4 +903,3 @@ mod tests { assert_eq!(keyid.len(), 7 + 16); // "sha256-" + 16 hex chars } } - diff --git a/src/utils/signing_keys.rs b/src/utils/signing_keys.rs index 9edb398..d06f108 100644 --- a/src/utils/signing_keys.rs +++ b/src/utils/signing_keys.rs @@ -274,10 +274,23 @@ pub fn get_key_entries(key_names: &[String]) -> Result> let mut missing = Vec::new(); for name in key_names { + // Try to find by name first if let Some(entry) = registry.keys.get(name) { entries.push((name.clone(), entry.clone())); } else { - missing.push(name.clone()); + // Try to find by key ID + let mut found = false; + for (key_name, entry) in ®istry.keys { + if entry.keyid == *name { + entries.push((key_name.clone(), entry.clone())); + found = true; + break; + } + } + + if !found { + missing.push(name.clone()); + } } } diff --git a/tests/pkcs11_integration_test.rs b/tests/pkcs11_integration_test.rs index cdaf3ac..0cc16cf 100644 --- a/tests/pkcs11_integration_test.rs +++ b/tests/pkcs11_integration_test.rs @@ -374,7 +374,7 @@ fn test_tpm_signing() { // Sign the hash let signature = - sign_with_pkcs11_device(&session, label, &hash).expect("Failed to sign data with TPM"); + sign_with_pkcs11_device(&session, label, &hash, "").expect("Failed to sign data with TPM"); assert!(!signature.is_empty(), "Signature should not be empty"); println!("TPM signature size: {} bytes", signature.len()); @@ -440,7 +440,7 @@ fn test_end_to_end_tpm_workflow() { assert_eq!(algo_str, "ecdsa-p256"); // Step 3: Find the key we just created - let (found_pubkey, found_keyid, _found_algo) = + let (found_pubkey, found_keyid, _found_algo, _priv_label) = find_existing_key(&session, label).expect("Failed to find existing key"); println!("Step 2: Found existing key: {}", found_keyid); @@ -467,7 +467,7 @@ fn test_end_to_end_tpm_workflow() { let hash = hasher.finalize(); let signature = - sign_with_pkcs11_device(&session, label, &hash).expect("Failed to sign with TPM"); + sign_with_pkcs11_device(&session, label, &hash, "").expect("Failed to sign with TPM"); println!( "Step 4: Signed data, signature size: {} bytes", @@ -516,8 +516,8 @@ fn test_tpm_key_registration_and_removal() { // First, generate the key in TPM directly use avocado_cli::utils::pkcs11_devices::{ - generate_keypair as generate_pkcs11_keypair, init_pkcs11_session, DeviceType, - KeyAlgorithm, Pkcs11AuthMethod, + generate_keypair as generate_pkcs11_keypair, init_pkcs11_session, DeviceType, KeyAlgorithm, + Pkcs11AuthMethod, }; let auth_method = Pkcs11AuthMethod::None; @@ -549,10 +549,8 @@ fn test_tpm_key_registration_and_removal() { println!("Verified key exists in registry"); // Step 4: Remove the key by name - let remove_cmd = SigningKeysRemoveCommand::new(key_name.to_string()); - remove_cmd - .execute() - .expect("Failed to remove key by name"); + let remove_cmd = SigningKeysRemoveCommand::new(key_name.to_string(), false); + remove_cmd.execute().expect("Failed to remove key by name"); println!("Removed key by name"); @@ -589,7 +587,7 @@ fn test_tpm_key_registration_and_removal() { println!("Generated and registered second key with ID: {}", keyid2); // Remove by key ID - let remove_cmd2 = SigningKeysRemoveCommand::new(keyid2.clone()); + let remove_cmd2 = SigningKeysRemoveCommand::new(keyid2.clone(), false); remove_cmd2 .execute() .expect("Failed to remove key by key ID"); @@ -610,4 +608,3 @@ fn test_tpm_key_registration_and_removal() { println!("\n✅ TPM key registration and removal test passed!"); } -