diff --git a/PLANS.md b/PLANS.md index 273f2b1..575b1dd 100644 --- a/PLANS.md +++ b/PLANS.md @@ -22,6 +22,7 @@ CLI tool for kylix-pqc post-quantum cryptography library. | Review Findings Fix | HIGH | [H-1] Windows permission warning, [H-3] Zeroize sk_encoded, [M-1] SLH-DSA ambiguous detection error, [L-1] Zeroizing wrappers, [L-4] BTreeMap for deterministic output, [M-5] SLH-DSA integration tests | | Bench Release Build Fix | HIGH | `--release` missing from external-tool-compare CI job, causing ~100x slowdown vs liboqs | | [H-2] Shared Secret Output Control | HIGH | `--secret-file` option for encaps/decaps to write shared secret to file instead of console | +| [M-2] Split main.rs into Modules | MEDIUM | Split 1256-line main.rs into cli.rs, io.rs, macros.rs, and commands/ directory | --- @@ -33,7 +34,6 @@ CLI tool for kylix-pqc post-quantum cryptography library. | OpenSSL Dedup | LOW | Extract common logic from KEM/SIG benchmark functions | | liboqs Parsing | LOW | Parse column headers instead of hardcoded indices | | wolfSSL Support | LOW | Add wolfSSL as external benchmark tool | -| [M-2] Split main.rs into Modules | MEDIUM | Separate cmd_* functions, algorithm definitions, and I/O helpers into modules | | [M-3] Input Format Disambiguation | MEDIUM | Improve hex vs base64 auto-detection for edge cases | | [M-4] Deep Zeroization in encode/decode | MEDIUM | Zeroize intermediate strings in `encode_output`/`decode_input` (PEM wrapping, base64) | | [L-5] Windows ACL for Secret Keys | LOW | Enforce restrictive ACLs on secret key files on Windows (e.g. `windows-acl` crate) | diff --git a/kylix-cli/src/bench.rs b/kylix-cli/src/bench.rs index 3e97890..4c52f93 100644 --- a/kylix-cli/src/bench.rs +++ b/kylix-cli/src/bench.rs @@ -17,7 +17,7 @@ use std::path::PathBuf; use std::process::Command; use std::time::{Duration, Instant}; -use crate::Algorithm; +use crate::cli::Algorithm; // liboqs benchmark duration estimation constants // diff --git a/kylix-cli/src/cli.rs b/kylix-cli/src/cli.rs new file mode 100644 index 0000000..8a6d86c --- /dev/null +++ b/kylix-cli/src/cli.rs @@ -0,0 +1,456 @@ +use anyhow::{bail, Result}; +use clap::{Parser, Subcommand, ValueEnum}; +use clap_complete::Shell; +use kylix_pqc::ml_dsa::{MlDsa44, MlDsa65, MlDsa87, Signer}; +use kylix_pqc::ml_kem::{Kem, MlKem1024, MlKem512, MlKem768}; +use kylix_pqc::slh_dsa::{ + SlhDsaShake128f, SlhDsaShake128s, SlhDsaShake192f, SlhDsaShake192s, SlhDsaShake256f, + SlhDsaShake256s, +}; +use std::path::PathBuf; + +/// Post-quantum cryptography CLI tool +#[derive(Parser)] +#[command(name = "kylix")] +#[command(author, version, about, long_about = None)] +pub(crate) struct Cli { + /// Enable verbose output + #[arg(short, long, global = true)] + pub verbose: bool, + + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +pub(crate) enum Commands { + /// Generate a new key pair + Keygen { + /// Algorithm to use + #[arg(short, long, value_enum, default_value = "ml-kem-768")] + algo: Algorithm, + + /// Output file prefix (creates `.pub` and `.sec`) + #[arg(short, long)] + output: String, + + /// Output format + #[arg(short, long, value_enum, default_value = "hex")] + format: OutputFormat, + }, + + /// Encapsulate a shared secret using a public key + Encaps { + /// Path to the public key file + #[arg(long = "pub")] + pubkey: PathBuf, + + /// Output file for ciphertext (writes to stdout if not specified) + #[arg(short, long)] + output: Option, + + /// Write shared secret to file instead of printing to console + #[arg(long = "secret-file")] + secret_file: Option, + + /// Output format + #[arg(short, long, value_enum, default_value = "hex")] + format: OutputFormat, + }, + + /// Decapsulate a shared secret using a secret key + Decaps { + /// Path to the secret key file + #[arg(long = "key")] + key: PathBuf, + + /// Path to the ciphertext file (reads from stdin if not specified) + #[arg(short, long)] + input: Option, + + /// Write shared secret to file instead of printing to console + #[arg(long = "secret-file")] + secret_file: Option, + + /// Output format for shared secret + #[arg(short, long, value_enum, default_value = "hex")] + format: OutputFormat, + }, + + /// Sign a file using ML-DSA or SLH-DSA + Sign { + /// Path to the signing key file + #[arg(long = "key")] + key: PathBuf, + + /// Input file to sign + #[arg(short, long)] + input: PathBuf, + + /// Output file for signature + #[arg(short, long)] + output: PathBuf, + + /// Output format + #[arg(short, long, value_enum, default_value = "hex")] + format: OutputFormat, + + /// Algorithm (required for SLH-DSA to distinguish -s/-f variants) + #[arg(long, value_enum)] + algo: Option, + }, + + /// Verify a signature using ML-DSA or SLH-DSA + Verify { + /// Path to the verification (public) key file + #[arg(long = "pub")] + pubkey: PathBuf, + + /// Input file that was signed + #[arg(short, long)] + input: PathBuf, + + /// Signature file + #[arg(short, long)] + signature: PathBuf, + + /// Input format for key and signature files + #[arg(short, long, value_enum, default_value = "hex")] + format: OutputFormat, + + /// Algorithm (required for SLH-DSA to distinguish -s/-f variants) + #[arg(long, value_enum)] + algo: Option, + }, + + /// Display information about supported algorithms + Info, + + /// Generate shell completion scripts + Completions { + /// Shell to generate completions for + #[arg(value_enum)] + shell: Shell, + }, + + /// Run performance benchmarks + #[cfg(feature = "bench")] + Bench { + /// Algorithm to benchmark (defaults to all if not specified) + #[arg(short, long, value_enum)] + algo: Option, + + /// Number of iterations + #[arg(short, long, default_value = "1000")] + iterations: u64, + + /// Output file for results (stdout if not specified) + #[arg(short, long)] + output: Option, + + /// Output format + #[arg(long, value_enum, default_value = "text")] + report: crate::bench::ReportFormat, + + /// Compare with external PQC implementations (OpenSSL, liboqs) + #[arg(long)] + compare: bool, + + /// Specific tools to compare with (comma-separated: openssl,liboqs) + #[arg(long, value_delimiter = ',')] + with: Option>, + }, +} + +/// Supported post-quantum cryptographic algorithms for CLI operations. +/// +/// This enum is used across all CLI subcommands (keygen, encaps, decaps, sign, verify, bench) +/// to specify which algorithm variant to use. +#[derive(Copy, Clone, PartialEq, Eq, ValueEnum)] +pub enum Algorithm { + /// ML-KEM-512 (NIST Security Level 1, 128-bit) + #[value(name = "ml-kem-512")] + MlKem512, + /// ML-KEM-768 (NIST Security Level 3, 192-bit) + #[value(name = "ml-kem-768")] + MlKem768, + /// ML-KEM-1024 (NIST Security Level 5, 256-bit) + #[value(name = "ml-kem-1024")] + MlKem1024, + /// ML-DSA-44 (NIST Security Level 2, 128-bit) + #[value(name = "ml-dsa-44")] + MlDsa44, + /// ML-DSA-65 (NIST Security Level 3, 192-bit) + #[value(name = "ml-dsa-65")] + MlDsa65, + /// ML-DSA-87 (NIST Security Level 5, 256-bit) + #[value(name = "ml-dsa-87")] + MlDsa87, + /// SLH-DSA-SHAKE-128s (NIST Security Level 1, small signatures) + #[value(name = "slh-dsa-shake-128s")] + SlhDsaShake128s, + /// SLH-DSA-SHAKE-128f (NIST Security Level 1, fast signing) + #[value(name = "slh-dsa-shake-128f")] + SlhDsaShake128f, + /// SLH-DSA-SHAKE-192s (NIST Security Level 3, small signatures) + #[value(name = "slh-dsa-shake-192s")] + SlhDsaShake192s, + /// SLH-DSA-SHAKE-192f (NIST Security Level 3, fast signing) + #[value(name = "slh-dsa-shake-192f")] + SlhDsaShake192f, + /// SLH-DSA-SHAKE-256s (NIST Security Level 5, small signatures) + #[value(name = "slh-dsa-shake-256s")] + SlhDsaShake256s, + /// SLH-DSA-SHAKE-256f (NIST Security Level 5, fast signing) + #[value(name = "slh-dsa-shake-256f")] + SlhDsaShake256f, +} + +impl Algorithm { + /// Returns true if this is a KEM algorithm + pub(crate) fn is_kem(&self) -> bool { + matches!( + self, + Algorithm::MlKem512 | Algorithm::MlKem768 | Algorithm::MlKem1024 + ) + } + + /// Returns true if this is an SLH-DSA algorithm + pub(crate) fn is_slh_dsa(&self) -> bool { + matches!( + self, + Algorithm::SlhDsaShake128s + | Algorithm::SlhDsaShake128f + | Algorithm::SlhDsaShake192s + | Algorithm::SlhDsaShake192f + | Algorithm::SlhDsaShake256s + | Algorithm::SlhDsaShake256f + ) + } +} + +impl std::fmt::Display for Algorithm { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Algorithm::MlKem512 => write!(f, "ML-KEM-512"), + Algorithm::MlKem768 => write!(f, "ML-KEM-768"), + Algorithm::MlKem1024 => write!(f, "ML-KEM-1024"), + Algorithm::MlDsa44 => write!(f, "ML-DSA-44"), + Algorithm::MlDsa65 => write!(f, "ML-DSA-65"), + Algorithm::MlDsa87 => write!(f, "ML-DSA-87"), + Algorithm::SlhDsaShake128s => write!(f, "SLH-DSA-SHAKE-128s"), + Algorithm::SlhDsaShake128f => write!(f, "SLH-DSA-SHAKE-128f"), + Algorithm::SlhDsaShake192s => write!(f, "SLH-DSA-SHAKE-192s"), + Algorithm::SlhDsaShake192f => write!(f, "SLH-DSA-SHAKE-192f"), + Algorithm::SlhDsaShake256s => write!(f, "SLH-DSA-SHAKE-256s"), + Algorithm::SlhDsaShake256f => write!(f, "SLH-DSA-SHAKE-256f"), + } + } +} + +/// Algorithm metadata for size detection and display. +pub struct AlgorithmInfo { + pub pub_key_size: usize, + pub sec_key_size: usize, + pub output_size: usize, // ciphertext for KEM, signature for DSA + pub pub_label: &'static str, + pub sec_label: &'static str, +} + +impl Algorithm { + /// Get algorithm metadata. + pub const fn info(&self) -> AlgorithmInfo { + match self { + Algorithm::MlKem512 => AlgorithmInfo { + pub_key_size: MlKem512::ENCAPSULATION_KEY_SIZE, + sec_key_size: MlKem512::DECAPSULATION_KEY_SIZE, + output_size: MlKem512::CIPHERTEXT_SIZE, + pub_label: "ML-KEM PUBLIC KEY", + sec_label: "ML-KEM SECRET KEY", + }, + Algorithm::MlKem768 => AlgorithmInfo { + pub_key_size: MlKem768::ENCAPSULATION_KEY_SIZE, + sec_key_size: MlKem768::DECAPSULATION_KEY_SIZE, + output_size: MlKem768::CIPHERTEXT_SIZE, + pub_label: "ML-KEM PUBLIC KEY", + sec_label: "ML-KEM SECRET KEY", + }, + Algorithm::MlKem1024 => AlgorithmInfo { + pub_key_size: MlKem1024::ENCAPSULATION_KEY_SIZE, + sec_key_size: MlKem1024::DECAPSULATION_KEY_SIZE, + output_size: MlKem1024::CIPHERTEXT_SIZE, + pub_label: "ML-KEM PUBLIC KEY", + sec_label: "ML-KEM SECRET KEY", + }, + Algorithm::MlDsa44 => AlgorithmInfo { + pub_key_size: MlDsa44::VERIFICATION_KEY_SIZE, + sec_key_size: MlDsa44::SIGNING_KEY_SIZE, + output_size: MlDsa44::SIGNATURE_SIZE, + pub_label: "ML-DSA PUBLIC KEY", + sec_label: "ML-DSA SECRET KEY", + }, + Algorithm::MlDsa65 => AlgorithmInfo { + pub_key_size: MlDsa65::VERIFICATION_KEY_SIZE, + sec_key_size: MlDsa65::SIGNING_KEY_SIZE, + output_size: MlDsa65::SIGNATURE_SIZE, + pub_label: "ML-DSA PUBLIC KEY", + sec_label: "ML-DSA SECRET KEY", + }, + Algorithm::MlDsa87 => AlgorithmInfo { + pub_key_size: MlDsa87::VERIFICATION_KEY_SIZE, + sec_key_size: MlDsa87::SIGNING_KEY_SIZE, + output_size: MlDsa87::SIGNATURE_SIZE, + pub_label: "ML-DSA PUBLIC KEY", + sec_label: "ML-DSA SECRET KEY", + }, + Algorithm::SlhDsaShake128s => AlgorithmInfo { + pub_key_size: SlhDsaShake128s::VERIFICATION_KEY_SIZE, + sec_key_size: SlhDsaShake128s::SIGNING_KEY_SIZE, + output_size: SlhDsaShake128s::SIGNATURE_SIZE, + pub_label: "SLH-DSA PUBLIC KEY", + sec_label: "SLH-DSA SECRET KEY", + }, + Algorithm::SlhDsaShake128f => AlgorithmInfo { + pub_key_size: SlhDsaShake128f::VERIFICATION_KEY_SIZE, + sec_key_size: SlhDsaShake128f::SIGNING_KEY_SIZE, + output_size: SlhDsaShake128f::SIGNATURE_SIZE, + pub_label: "SLH-DSA PUBLIC KEY", + sec_label: "SLH-DSA SECRET KEY", + }, + Algorithm::SlhDsaShake192s => AlgorithmInfo { + pub_key_size: SlhDsaShake192s::VERIFICATION_KEY_SIZE, + sec_key_size: SlhDsaShake192s::SIGNING_KEY_SIZE, + output_size: SlhDsaShake192s::SIGNATURE_SIZE, + pub_label: "SLH-DSA PUBLIC KEY", + sec_label: "SLH-DSA SECRET KEY", + }, + Algorithm::SlhDsaShake192f => AlgorithmInfo { + pub_key_size: SlhDsaShake192f::VERIFICATION_KEY_SIZE, + sec_key_size: SlhDsaShake192f::SIGNING_KEY_SIZE, + output_size: SlhDsaShake192f::SIGNATURE_SIZE, + pub_label: "SLH-DSA PUBLIC KEY", + sec_label: "SLH-DSA SECRET KEY", + }, + Algorithm::SlhDsaShake256s => AlgorithmInfo { + pub_key_size: SlhDsaShake256s::VERIFICATION_KEY_SIZE, + sec_key_size: SlhDsaShake256s::SIGNING_KEY_SIZE, + output_size: SlhDsaShake256s::SIGNATURE_SIZE, + pub_label: "SLH-DSA PUBLIC KEY", + sec_label: "SLH-DSA SECRET KEY", + }, + Algorithm::SlhDsaShake256f => AlgorithmInfo { + pub_key_size: SlhDsaShake256f::VERIFICATION_KEY_SIZE, + sec_key_size: SlhDsaShake256f::SIGNING_KEY_SIZE, + output_size: SlhDsaShake256f::SIGNATURE_SIZE, + pub_label: "SLH-DSA PUBLIC KEY", + sec_label: "SLH-DSA SECRET KEY", + }, + } + } + + /// Detect KEM algorithm from public key size. + pub(crate) fn detect_kem_from_pub_key(size: usize) -> Result { + match size { + MlKem512::ENCAPSULATION_KEY_SIZE => Ok(Algorithm::MlKem512), + MlKem768::ENCAPSULATION_KEY_SIZE => Ok(Algorithm::MlKem768), + MlKem1024::ENCAPSULATION_KEY_SIZE => Ok(Algorithm::MlKem1024), + _ => bail!( + "Unknown public key size: {} bytes. Expected {}, {}, or {}.", + size, + MlKem512::ENCAPSULATION_KEY_SIZE, + MlKem768::ENCAPSULATION_KEY_SIZE, + MlKem1024::ENCAPSULATION_KEY_SIZE + ), + } + } + + /// Detect KEM algorithm from secret key size. + pub(crate) fn detect_kem_from_sec_key(size: usize) -> Result { + match size { + MlKem512::DECAPSULATION_KEY_SIZE => Ok(Algorithm::MlKem512), + MlKem768::DECAPSULATION_KEY_SIZE => Ok(Algorithm::MlKem768), + MlKem1024::DECAPSULATION_KEY_SIZE => Ok(Algorithm::MlKem1024), + _ => bail!( + "Unknown secret key size: {} bytes. Expected {}, {}, or {}.", + size, + MlKem512::DECAPSULATION_KEY_SIZE, + MlKem768::DECAPSULATION_KEY_SIZE, + MlKem1024::DECAPSULATION_KEY_SIZE + ), + } + } + + /// Detect DSA algorithm from signing key size. + /// SLH-DSA small/fast variants share key sizes, so auto-detection is ambiguous. + pub(crate) fn detect_dsa_from_signing_key(size: usize) -> Result { + match size { + MlDsa44::SIGNING_KEY_SIZE => Ok(Algorithm::MlDsa44), + MlDsa65::SIGNING_KEY_SIZE => Ok(Algorithm::MlDsa65), + MlDsa87::SIGNING_KEY_SIZE => Ok(Algorithm::MlDsa87), + SlhDsaShake128f::SIGNING_KEY_SIZE => bail!( + "SLH-DSA key detected ({} bytes) but small vs fast variant is ambiguous. \ + Please specify the algorithm explicitly with --algo \ + (e.g. --algo slh-dsa-shake-128s or slh-dsa-shake-128f).", + size + ), + SlhDsaShake192f::SIGNING_KEY_SIZE => bail!( + "SLH-DSA key detected ({} bytes) but small vs fast variant is ambiguous. \ + Please specify the algorithm explicitly with --algo \ + (e.g. --algo slh-dsa-shake-192s or slh-dsa-shake-192f).", + size + ), + SlhDsaShake256f::SIGNING_KEY_SIZE => bail!( + "SLH-DSA key detected ({} bytes) but small vs fast variant is ambiguous. \ + Please specify the algorithm explicitly with --algo \ + (e.g. --algo slh-dsa-shake-256s or slh-dsa-shake-256f).", + size + ), + _ => bail!( + "Unknown signing key size: {} bytes. Expected ML-DSA (2560/4032/4896) or SLH-DSA (64/96/128).", + size + ), + } + } + + /// Detect DSA algorithm from verification key size. + /// SLH-DSA small/fast variants share key sizes, so auto-detection is ambiguous. + pub(crate) fn detect_dsa_from_verification_key(size: usize) -> Result { + match size { + MlDsa44::VERIFICATION_KEY_SIZE => Ok(Algorithm::MlDsa44), + MlDsa65::VERIFICATION_KEY_SIZE => Ok(Algorithm::MlDsa65), + MlDsa87::VERIFICATION_KEY_SIZE => Ok(Algorithm::MlDsa87), + SlhDsaShake128f::VERIFICATION_KEY_SIZE => bail!( + "SLH-DSA key detected ({} bytes) but small vs fast variant is ambiguous. \ + Please specify the algorithm explicitly with --algo \ + (e.g. --algo slh-dsa-shake-128s or slh-dsa-shake-128f).", + size + ), + SlhDsaShake192f::VERIFICATION_KEY_SIZE => bail!( + "SLH-DSA key detected ({} bytes) but small vs fast variant is ambiguous. \ + Please specify the algorithm explicitly with --algo \ + (e.g. --algo slh-dsa-shake-192s or slh-dsa-shake-192f).", + size + ), + SlhDsaShake256f::VERIFICATION_KEY_SIZE => bail!( + "SLH-DSA key detected ({} bytes) but small vs fast variant is ambiguous. \ + Please specify the algorithm explicitly with --algo \ + (e.g. --algo slh-dsa-shake-256s or slh-dsa-shake-256f).", + size + ), + _ => bail!( + "Unknown verification key size: {} bytes. Expected ML-DSA (1312/1952/2592) or SLH-DSA (32/48/64).", + size + ), + } + } +} + +#[derive(Copy, Clone, PartialEq, Eq, ValueEnum)] +pub(crate) enum OutputFormat { + /// Hexadecimal encoding + Hex, + /// Base64 encoding + Base64, + /// PEM format + Pem, +} diff --git a/kylix-cli/src/commands/completions.rs b/kylix-cli/src/commands/completions.rs new file mode 100644 index 0000000..02615c7 --- /dev/null +++ b/kylix-cli/src/commands/completions.rs @@ -0,0 +1,11 @@ +use clap::CommandFactory; +use clap_complete::{generate, Shell}; +use std::io; + +use crate::cli::Cli; + +/// Generate shell completions +pub(crate) fn cmd_completions(shell: Shell) { + let mut cmd = Cli::command(); + generate(shell, &mut cmd, "kylix", &mut io::stdout()); +} diff --git a/kylix-cli/src/commands/decaps.rs b/kylix-cli/src/commands/decaps.rs new file mode 100644 index 0000000..2e342f7 --- /dev/null +++ b/kylix-cli/src/commands/decaps.rs @@ -0,0 +1,80 @@ +use anyhow::{Context, Result}; +use kylix_pqc::ml_kem::{self, Kem}; +use std::fs; +use std::io::{self, Read}; +use std::path::PathBuf; +use zeroize::Zeroizing; + +use crate::cli::{Algorithm, OutputFormat}; +use crate::io::{decode_input, encode_output, write_secret_file}; + +/// Decapsulate a shared secret +pub(crate) fn cmd_decaps( + key: &PathBuf, + input: Option<&PathBuf>, + secret_file: Option<&PathBuf>, + format: OutputFormat, + verbose: bool, +) -> Result<()> { + let sk_data = + Zeroizing::new(fs::read_to_string(key).context("Failed to read secret key file")?); + let sk_bytes = Zeroizing::new(decode_input(&sk_data, format)?); + drop(sk_data); // zeroize raw key string immediately after decoding + + let algo = Algorithm::detect_kem_from_sec_key(sk_bytes.len())?; + + if verbose { + eprintln!("Detected algorithm: {}", algo); + eprintln!("Secret key size: {} bytes", sk_bytes.len()); + } + + let ct_data = if let Some(ct_path) = input { + fs::read_to_string(ct_path).context("Failed to read ciphertext file")? + } else { + let mut buf = String::new(); + io::stdin() + .read_to_string(&mut buf) + .context("Failed to read ciphertext from stdin")?; + buf + }; + let ct_bytes = decode_input(&ct_data, format)?; + + if verbose { + eprintln!("Ciphertext size: {} bytes", ct_bytes.len()); + } + + let ss_bytes_raw: Vec = match algo { + Algorithm::MlKem512 => { + kem_decaps!(ml_kem::ml_kem_512, ml_kem::MlKem512, sk_bytes, ct_bytes) + } + Algorithm::MlKem768 => { + kem_decaps!(ml_kem::ml_kem_768, ml_kem::MlKem768, sk_bytes, ct_bytes) + } + Algorithm::MlKem1024 => { + kem_decaps!(ml_kem::ml_kem_1024, ml_kem::MlKem1024, sk_bytes, ct_bytes) + } + // detect_kem_from_sec_key only returns ML-KEM variants, so this is unreachable + _ => unreachable!(), + }; + let ss_bytes = Zeroizing::new(ss_bytes_raw); + drop(sk_bytes); // zeroize secret key bytes immediately after decapsulation + + let ss_len = ss_bytes.len(); + let ss_encoded = Zeroizing::new(encode_output(&ss_bytes, format, "SHARED SECRET")); + drop(ss_bytes); // zeroize shared secret bytes immediately after encoding + if let Some(sf_path) = secret_file { + write_secret_file(sf_path, &ss_encoded)?; + if verbose { + eprintln!("Shared secret written to: {}", sf_path.display()); + } + } else { + println!("{}", &*ss_encoded); + } + drop(ss_encoded); // zeroize encoded shared secret immediately after output + + if verbose { + eprintln!("Shared secret size: {} bytes", ss_len); + } + + Ok(()) +} diff --git a/kylix-cli/src/commands/encaps.rs b/kylix-cli/src/commands/encaps.rs new file mode 100644 index 0000000..7bc238e --- /dev/null +++ b/kylix-cli/src/commands/encaps.rs @@ -0,0 +1,69 @@ +use anyhow::{Context, Result}; +use kylix_pqc::ml_kem::{self, Kem}; +use std::fs; +use std::path::PathBuf; +use zeroize::Zeroizing; + +use crate::cli::{Algorithm, OutputFormat}; +use crate::io::{decode_input, encode_output, write_secret_file}; + +/// Encapsulate a shared secret +pub(crate) fn cmd_encaps( + pubkey: &PathBuf, + output: Option<&PathBuf>, + secret_file: Option<&PathBuf>, + format: OutputFormat, + verbose: bool, +) -> Result<()> { + let pk_data = fs::read_to_string(pubkey).context("Failed to read public key file")?; + let pk_bytes = decode_input(&pk_data, format)?; + + let algo = Algorithm::detect_kem_from_pub_key(pk_bytes.len())?; + + if verbose { + eprintln!("Detected algorithm: {}", algo); + eprintln!("Public key size: {} bytes", pk_bytes.len()); + } + + let (ct_bytes, ss_bytes_raw): (Vec, Vec) = match algo { + Algorithm::MlKem512 => kem_encaps!(ml_kem::ml_kem_512, ml_kem::MlKem512, pk_bytes), + Algorithm::MlKem768 => kem_encaps!(ml_kem::ml_kem_768, ml_kem::MlKem768, pk_bytes), + Algorithm::MlKem1024 => kem_encaps!(ml_kem::ml_kem_1024, ml_kem::MlKem1024, pk_bytes), + // detect_kem_from_pub_key only returns ML-KEM variants, so DSA variants are unreachable + _ => unreachable!(), + }; + let ss_bytes = Zeroizing::new(ss_bytes_raw); + + let ct_encoded = encode_output(&ct_bytes, format, "ML-KEM CIPHERTEXT"); + + if let Some(out_path) = output { + fs::write(out_path, &ct_encoded).context("Failed to write ciphertext")?; + if verbose { + eprintln!("Ciphertext written to: {}", out_path.display()); + eprintln!("Ciphertext size: {} bytes", ct_bytes.len()); + } + } else { + println!("{}", ct_encoded); + } + + let ss_len = ss_bytes.len(); + let ss_encoded = Zeroizing::new(encode_output(&ss_bytes, format, "SHARED SECRET")); + drop(ss_bytes); // zeroize shared secret bytes immediately after encoding + if let Some(sf_path) = secret_file { + write_secret_file(sf_path, &ss_encoded)?; + if verbose { + eprintln!("Shared secret written to: {}", sf_path.display()); + } + } else if output.is_some() { + println!("Shared secret: {}", &*ss_encoded); + } else { + eprintln!("Shared secret: {}", &*ss_encoded); + } + drop(ss_encoded); // zeroize encoded shared secret immediately after output + + if verbose { + eprintln!("Shared secret size: {} bytes", ss_len); + } + + Ok(()) +} diff --git a/kylix-cli/src/commands/info.rs b/kylix-cli/src/commands/info.rs new file mode 100644 index 0000000..4fd166b --- /dev/null +++ b/kylix-cli/src/commands/info.rs @@ -0,0 +1,74 @@ +use crate::cli::Algorithm; + +/// Display information about supported algorithms +pub(crate) fn cmd_info() { + println!("Kylix - Post-Quantum Cryptography Library"); + println!(); + println!("Supported algorithms:"); + println!(); + + // ML-KEM algorithms + println!(" ML-KEM (FIPS 203) - Key Encapsulation Mechanism"); + for (algo, level) in [ + (Algorithm::MlKem512, "Security Level 1 (128-bit)"), + (Algorithm::MlKem768, "Security Level 3 (192-bit)"), + (Algorithm::MlKem1024, "Security Level 5 (256-bit)"), + ] { + let info = algo.info(); + println!( + " {:<12} {} PK: {}B SK: {}B CT: {}B", + format!("{}", algo).to_lowercase(), + level, + info.pub_key_size, + info.sec_key_size, + info.output_size + ); + } + println!(); + + // ML-DSA algorithms + println!(" ML-DSA (FIPS 204) - Digital Signature Algorithm"); + for (algo, level) in [ + (Algorithm::MlDsa44, "Security Level 2 (128-bit)"), + (Algorithm::MlDsa65, "Security Level 3 (192-bit)"), + (Algorithm::MlDsa87, "Security Level 5 (256-bit)"), + ] { + let info = algo.info(); + println!( + " {:<12} {} PK: {}B SK: {}B SIG: {}B", + format!("{}", algo).to_lowercase(), + level, + info.pub_key_size, + info.sec_key_size, + info.output_size + ); + } + println!(); + + // SLH-DSA algorithms + println!(" SLH-DSA (FIPS 205) - Stateless Hash-Based Digital Signature Algorithm"); + for (algo, level) in [ + (Algorithm::SlhDsaShake128s, "Security Level 1 (small)"), + (Algorithm::SlhDsaShake128f, "Security Level 1 (fast)"), + (Algorithm::SlhDsaShake192s, "Security Level 3 (small)"), + (Algorithm::SlhDsaShake192f, "Security Level 3 (fast)"), + (Algorithm::SlhDsaShake256s, "Security Level 5 (small)"), + (Algorithm::SlhDsaShake256f, "Security Level 5 (fast)"), + ] { + let info = algo.info(); + println!( + " {:<20} {} PK: {}B SK: {}B SIG: {}B", + format!("{}", algo).to_lowercase(), + level, + info.pub_key_size, + info.sec_key_size, + info.output_size + ); + } + println!(); + + println!("Output formats:"); + println!(" hex - Hexadecimal encoding (default)"); + println!(" base64 - Base64 encoding"); + println!(" pem - PEM format with headers"); +} diff --git a/kylix-cli/src/commands/keygen.rs b/kylix-cli/src/commands/keygen.rs new file mode 100644 index 0000000..3899822 --- /dev/null +++ b/kylix-cli/src/commands/keygen.rs @@ -0,0 +1,65 @@ +use anyhow::{Context, Result}; +use kylix_pqc::ml_dsa::{self, Signer}; +use kylix_pqc::ml_kem::{self, Kem}; +use kylix_pqc::slh_dsa; +use std::fs; +use zeroize::Zeroizing; + +use crate::cli::{Algorithm, OutputFormat}; +use crate::io::{encode_output, write_secret_file}; + +/// Generate a key pair for the specified algorithm +pub(crate) fn cmd_keygen( + algo: Algorithm, + output: &str, + format: OutputFormat, + verbose: bool, +) -> Result<()> { + if verbose { + eprintln!("Generating {} key pair...", algo); + } + + let info = algo.info(); + let (pk_label, sk_label) = (info.pub_label, info.sec_label); + + let (pk_bytes, sk_bytes): (Vec, Zeroizing>) = match algo { + Algorithm::MlKem512 => kem_keygen!(ml_kem::MlKem512), + Algorithm::MlKem768 => kem_keygen!(ml_kem::MlKem768), + Algorithm::MlKem1024 => kem_keygen!(ml_kem::MlKem1024), + Algorithm::MlDsa44 => dsa_keygen!(ml_dsa::MlDsa44), + Algorithm::MlDsa65 => dsa_keygen!(ml_dsa::MlDsa65), + Algorithm::MlDsa87 => dsa_keygen!(ml_dsa::MlDsa87), + Algorithm::SlhDsaShake128s => dsa_keygen!(slh_dsa::SlhDsaShake128s), + Algorithm::SlhDsaShake128f => dsa_keygen!(slh_dsa::SlhDsaShake128f), + Algorithm::SlhDsaShake192s => dsa_keygen!(slh_dsa::SlhDsaShake192s), + Algorithm::SlhDsaShake192f => dsa_keygen!(slh_dsa::SlhDsaShake192f), + Algorithm::SlhDsaShake256s => dsa_keygen!(slh_dsa::SlhDsaShake256s), + Algorithm::SlhDsaShake256f => dsa_keygen!(slh_dsa::SlhDsaShake256f), + }; + + // Store sizes before zeroization for verbose output + let pk_size = pk_bytes.len(); + let sk_size = sk_bytes.len(); + + let pk_encoded = encode_output(&pk_bytes, format, pk_label); + let sk_encoded = Zeroizing::new(encode_output(&sk_bytes, format, sk_label)); + // sk_bytes is Zeroizing>, automatically zeroized on drop + + let pub_path = format!("{}.pub", output); + let sec_path = format!("{}.sec", output); + + fs::write(&pub_path, &pk_encoded).context("Failed to write public key")?; + // Use restrictive permissions (0o600) for secret key on Unix + write_secret_file(sec_path.as_ref(), &sk_encoded)?; + drop(sk_encoded); // zeroize encoded secret key immediately after writing + + if verbose { + eprintln!("Public key size: {} bytes", pk_size); + eprintln!("Secret key size: {} bytes", sk_size); + } + + println!("Public key written to: {}", pub_path); + println!("Secret key written to: {}", sec_path); + + Ok(()) +} diff --git a/kylix-cli/src/commands/mod.rs b/kylix-cli/src/commands/mod.rs new file mode 100644 index 0000000..ae78bb2 --- /dev/null +++ b/kylix-cli/src/commands/mod.rs @@ -0,0 +1,15 @@ +mod completions; +mod decaps; +mod encaps; +mod info; +mod keygen; +mod sign; +mod verify; + +pub(crate) use completions::cmd_completions; +pub(crate) use decaps::cmd_decaps; +pub(crate) use encaps::cmd_encaps; +pub(crate) use info::cmd_info; +pub(crate) use keygen::cmd_keygen; +pub(crate) use sign::cmd_sign; +pub(crate) use verify::cmd_verify; diff --git a/kylix-cli/src/commands/sign.rs b/kylix-cli/src/commands/sign.rs new file mode 100644 index 0000000..1bbafed --- /dev/null +++ b/kylix-cli/src/commands/sign.rs @@ -0,0 +1,127 @@ +use anyhow::{bail, Context, Result}; +use kylix_pqc::ml_dsa::{self, Signer}; +use kylix_pqc::slh_dsa; +use std::fs; +use std::path::PathBuf; +use zeroize::Zeroizing; + +use crate::cli::{Algorithm, OutputFormat}; +use crate::io::{decode_input, encode_output}; + +/// Sign a file with ML-DSA or SLH-DSA +pub(crate) fn cmd_sign( + key: &PathBuf, + input: &PathBuf, + output: &PathBuf, + format: OutputFormat, + explicit_algo: Option, + verbose: bool, +) -> Result<()> { + let sk_data = + Zeroizing::new(fs::read_to_string(key).context("Failed to read signing key file")?); + let sk_bytes = Zeroizing::new(decode_input(&sk_data, format)?); + drop(sk_data); // zeroize raw key string immediately after decoding + + // Use explicit algorithm if provided, otherwise detect from key size + let algo = if let Some(a) = explicit_algo { + // Validate key size matches the explicit algorithm + if a.is_kem() { + bail!("Algorithm {} is not a signature algorithm", a); + } + let expected_size = a.info().sec_key_size; + if sk_bytes.len() != expected_size { + bail!( + "Key size {} bytes does not match algorithm {} (expected {} bytes)", + sk_bytes.len(), + a, + expected_size + ); + } + a + } else { + Algorithm::detect_dsa_from_signing_key(sk_bytes.len())? + }; + + if verbose { + eprintln!("Detected algorithm: {}", algo); + eprintln!("Signing key size: {} bytes", sk_bytes.len()); + } + + let message = fs::read(input).context("Failed to read input file")?; + + if verbose { + eprintln!("Message size: {} bytes", message.len()); + } + + let sig_bytes: Vec = match algo { + Algorithm::MlDsa44 => dsa_sign!(ml_dsa::dsa44, ml_dsa::MlDsa44, sk_bytes, message), + Algorithm::MlDsa65 => dsa_sign!(ml_dsa::dsa65, ml_dsa::MlDsa65, sk_bytes, message), + Algorithm::MlDsa87 => dsa_sign!(ml_dsa::dsa87, ml_dsa::MlDsa87, sk_bytes, message), + Algorithm::SlhDsaShake128s => { + dsa_sign!( + slh_dsa::slh_dsa_shake_128s, + slh_dsa::SlhDsaShake128s, + sk_bytes, + message + ) + } + Algorithm::SlhDsaShake128f => { + dsa_sign!( + slh_dsa::slh_dsa_shake_128f, + slh_dsa::SlhDsaShake128f, + sk_bytes, + message + ) + } + Algorithm::SlhDsaShake192s => { + dsa_sign!( + slh_dsa::slh_dsa_shake_192s, + slh_dsa::SlhDsaShake192s, + sk_bytes, + message + ) + } + Algorithm::SlhDsaShake192f => { + dsa_sign!( + slh_dsa::slh_dsa_shake_192f, + slh_dsa::SlhDsaShake192f, + sk_bytes, + message + ) + } + Algorithm::SlhDsaShake256s => { + dsa_sign!( + slh_dsa::slh_dsa_shake_256s, + slh_dsa::SlhDsaShake256s, + sk_bytes, + message + ) + } + Algorithm::SlhDsaShake256f => { + dsa_sign!( + slh_dsa::slh_dsa_shake_256f, + slh_dsa::SlhDsaShake256f, + sk_bytes, + message + ) + } + _ => bail!("Algorithm {} does not support signing", algo), + }; + drop(sk_bytes); // zeroize signing key bytes immediately after signing + + let sig_label = if algo.is_slh_dsa() { + "SLH-DSA SIGNATURE" + } else { + "ML-DSA SIGNATURE" + }; + let sig_encoded = encode_output(&sig_bytes, format, sig_label); + fs::write(output, &sig_encoded).context("Failed to write signature")?; + + if verbose { + eprintln!("Signature size: {} bytes", sig_bytes.len()); + } + + println!("Signature written to: {}", output.display()); + + Ok(()) +} diff --git a/kylix-cli/src/commands/verify.rs b/kylix-cli/src/commands/verify.rs new file mode 100644 index 0000000..ef7da63 --- /dev/null +++ b/kylix-cli/src/commands/verify.rs @@ -0,0 +1,132 @@ +use anyhow::{bail, Context, Result}; +use kylix_pqc::ml_dsa::{self, Signer}; +use kylix_pqc::slh_dsa; +use std::fs; +use std::path::PathBuf; + +use crate::cli::{Algorithm, OutputFormat}; +use crate::io::decode_input; + +/// Verify a signature with ML-DSA or SLH-DSA +pub(crate) fn cmd_verify( + pubkey: &PathBuf, + input: &PathBuf, + signature: &PathBuf, + format: OutputFormat, + explicit_algo: Option, + verbose: bool, +) -> Result<()> { + let pk_data = fs::read_to_string(pubkey).context("Failed to read public key file")?; + let pk_bytes = decode_input(&pk_data, format)?; + + // Use explicit algorithm if provided, otherwise detect from key size + let algo = if let Some(a) = explicit_algo { + // Validate key size matches the explicit algorithm + if a.is_kem() { + bail!("Algorithm {} is not a signature algorithm", a); + } + let expected_size = a.info().pub_key_size; + if pk_bytes.len() != expected_size { + bail!( + "Key size {} bytes does not match algorithm {} (expected {} bytes)", + pk_bytes.len(), + a, + expected_size + ); + } + a + } else { + Algorithm::detect_dsa_from_verification_key(pk_bytes.len())? + }; + + if verbose { + eprintln!("Detected algorithm: {}", algo); + eprintln!("Verification key size: {} bytes", pk_bytes.len()); + } + + let message = fs::read(input).context("Failed to read input file")?; + let sig_data = fs::read_to_string(signature).context("Failed to read signature file")?; + let sig_bytes = decode_input(&sig_data, format)?; + + if verbose { + eprintln!("Message size: {} bytes", message.len()); + eprintln!("Signature size: {} bytes", sig_bytes.len()); + } + + let result = match algo { + Algorithm::MlDsa44 => { + dsa_verify!(ml_dsa::dsa44, ml_dsa::MlDsa44, pk_bytes, sig_bytes, message) + } + Algorithm::MlDsa65 => { + dsa_verify!(ml_dsa::dsa65, ml_dsa::MlDsa65, pk_bytes, sig_bytes, message) + } + Algorithm::MlDsa87 => { + dsa_verify!(ml_dsa::dsa87, ml_dsa::MlDsa87, pk_bytes, sig_bytes, message) + } + Algorithm::SlhDsaShake128s => { + dsa_verify!( + slh_dsa::slh_dsa_shake_128s, + slh_dsa::SlhDsaShake128s, + pk_bytes, + sig_bytes, + message + ) + } + Algorithm::SlhDsaShake128f => { + dsa_verify!( + slh_dsa::slh_dsa_shake_128f, + slh_dsa::SlhDsaShake128f, + pk_bytes, + sig_bytes, + message + ) + } + Algorithm::SlhDsaShake192s => { + dsa_verify!( + slh_dsa::slh_dsa_shake_192s, + slh_dsa::SlhDsaShake192s, + pk_bytes, + sig_bytes, + message + ) + } + Algorithm::SlhDsaShake192f => { + dsa_verify!( + slh_dsa::slh_dsa_shake_192f, + slh_dsa::SlhDsaShake192f, + pk_bytes, + sig_bytes, + message + ) + } + Algorithm::SlhDsaShake256s => { + dsa_verify!( + slh_dsa::slh_dsa_shake_256s, + slh_dsa::SlhDsaShake256s, + pk_bytes, + sig_bytes, + message + ) + } + Algorithm::SlhDsaShake256f => { + dsa_verify!( + slh_dsa::slh_dsa_shake_256f, + slh_dsa::SlhDsaShake256f, + pk_bytes, + sig_bytes, + message + ) + } + _ => bail!("Algorithm {} does not support verification", algo), + }; + + match result { + Ok(()) => { + println!("Signature is valid."); + Ok(()) + } + Err(_) => { + bail!("Signature verification failed.") + } + } +} diff --git a/kylix-cli/src/io.rs b/kylix-cli/src/io.rs new file mode 100644 index 0000000..bf5ee07 --- /dev/null +++ b/kylix-cli/src/io.rs @@ -0,0 +1,157 @@ +use anyhow::{bail, Context, Result}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use std::fs; +#[cfg(unix)] +use std::fs::OpenOptions; +#[cfg(unix)] +use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; + +use crate::cli::OutputFormat; + +/// Encode bytes to the specified format +pub(crate) fn encode_output(data: &[u8], format: OutputFormat, label: &str) -> String { + match format { + OutputFormat::Hex => hex::encode(data), + OutputFormat::Base64 => BASE64.encode(data), + OutputFormat::Pem => { + let b64 = BASE64.encode(data); + let wrapped: String = b64 + .as_bytes() + .chunks(64) + .map(|chunk| std::str::from_utf8(chunk).expect("BASE64 output is valid ASCII")) + .collect::>() + .join("\n"); + format!( + "-----BEGIN {}-----\n{}\n-----END {}-----", + label, wrapped, label + ) + } + } +} + +/// Check if a string is valid hexadecimal +fn is_hex(s: &str) -> bool { + !s.is_empty() && s.chars().all(|c| c.is_ascii_hexdigit()) +} + +/// Decode bytes with auto-detection of format. +/// Detection order: PEM (by header) -> Hex (if all hex chars) -> Base64. +/// The format parameter is ignored for input; it only affects output encoding. +pub(crate) fn decode_input(data: &str, _format: OutputFormat) -> Result> { + let data = data.trim(); + + // Auto-detect PEM format + if data.starts_with("-----BEGIN") { + let lines: Vec<&str> = data.lines().collect(); + if lines.len() < 3 { + bail!("Invalid PEM format"); + } + let b64: String = lines[1..lines.len() - 1].join(""); + return BASE64 + .decode(&b64) + .context("Failed to decode PEM base64 content"); + } + + // Auto-detect hex vs base64 + // Hex: only 0-9, a-f, A-F (and must have even length for valid bytes) + // Base64: may contain +, /, = which are not valid hex + if is_hex(data) && data.len() % 2 == 0 { + return hex::decode(data).context("Failed to decode hex"); + } + + // Try base64 + BASE64.decode(data).context("Failed to decode base64") +} + +/// Write a file with restricted permissions (0o600) on Unix systems. +/// On non-Unix systems, falls back to standard [`fs::write`]. +/// +/// # Parameters +/// +/// - `path`: Filesystem path where the secret data will be written. +/// - `content`: UTF-8 string contents to write to the secret file. +/// +/// # Errors +/// +/// Returns an error if the file cannot be created or written. On Unix systems, +/// this includes failures when setting file permissions; on non-Unix systems, +/// this includes any error returned by [`fs::write`]. +pub(crate) fn write_secret_file(path: &Path, content: &str) -> Result<()> { + #[cfg(unix)] + { + let parent = path.parent().ok_or_else(|| { + anyhow::anyhow!( + "Cannot determine parent directory for secret file path: {}", + path.display() + ) + })?; + + let filename = path.file_name().ok_or_else(|| { + anyhow::anyhow!("Path does not contain a valid filename: {}", path.display()) + })?; + + // Use random suffix to prevent attackers from pre-creating predictable temp files + let random_suffix: u64 = rand::random(); + let mut temp_path = parent.to_path_buf(); + temp_path.push(format!( + ".{}.{:016x}.tmp", + filename.to_string_lossy(), + random_suffix + )); + + // Create temp file with restrictive permissions (0o600) BEFORE writing secret data + // Use create_new to prevent race conditions with attacker-created files + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&temp_path) + .with_context(|| { + format!( + "Failed to create temp file for secret file: {}", + temp_path.display() + ) + })?; + file.write_all(content.as_bytes()).with_context(|| { + format!( + "Failed to write temp file for secret file: {}", + temp_path.display() + ) + })?; + // Flush to disk before rename to avoid partial writes on crash + file.sync_all().with_context(|| { + format!( + "Failed to sync temp file for secret file: {}", + temp_path.display() + ) + })?; + + // Atomic rename to target path (works because same filesystem) + // Clean up temp file on failure to avoid leaving secret material on disk + let rename_result = fs::rename(&temp_path, path); + if rename_result.is_err() { + let _ = fs::remove_file(&temp_path); + } + rename_result.with_context(|| { + format!( + "Failed to rename temp file to secret file: {}", + path.display() + ) + })?; + + Ok(()) + } + #[cfg(not(unix))] + { + eprintln!( + "Warning: file permissions cannot be restricted on this platform. Secret file '{}' may be readable by other users.", + path.display() + ); + fs::write(path, content) + .with_context(|| format!("Failed to write secret file: {}", path.display()))?; + Ok(()) + } +} diff --git a/kylix-cli/src/macros.rs b/kylix-cli/src/macros.rs new file mode 100644 index 0000000..09825f3 --- /dev/null +++ b/kylix-cli/src/macros.rs @@ -0,0 +1,65 @@ +// Algorithm dispatch macros — eliminate per-variant boilerplate in cmd_* functions. + +macro_rules! kem_keygen { + ($algo:ty) => {{ + let (dk, ek) = <$algo>::keygen(&mut ::rand::rng()) + .map_err(|e| ::anyhow::anyhow!("Key generation failed: {:?}", e))?; + ( + ek.as_bytes().to_vec(), + ::zeroize::Zeroizing::new(dk.as_bytes().to_vec()), + ) + }}; +} + +macro_rules! dsa_keygen { + ($algo:ty) => {{ + let (sk, pk) = <$algo>::keygen(&mut ::rand::rng()) + .map_err(|e| ::anyhow::anyhow!("Key generation failed: {:?}", e))?; + ( + pk.as_bytes().to_vec(), + ::zeroize::Zeroizing::new(sk.as_bytes().to_vec()), + ) + }}; +} + +macro_rules! kem_encaps { + ($crate_:ident :: $submod:ident, $algo:ty, $pk_bytes:expr) => {{ + let ek = $crate_::$submod::EncapsulationKey::from_bytes(&$pk_bytes) + .map_err(|e| ::anyhow::anyhow!("Invalid public key: {:?}", e))?; + let (ct, ss) = <$algo>::encaps(&ek, &mut ::rand::rng()) + .map_err(|e| ::anyhow::anyhow!("Encapsulation failed: {:?}", e))?; + (ct.as_bytes().to_vec(), ss.as_ref().to_vec()) + }}; +} + +macro_rules! kem_decaps { + ($crate_:ident :: $submod:ident, $algo:ty, $sk_bytes:expr, $ct_bytes:expr) => {{ + let dk = $crate_::$submod::DecapsulationKey::from_bytes(&$sk_bytes) + .map_err(|e| ::anyhow::anyhow!("Invalid secret key: {:?}", e))?; + let ct = $crate_::$submod::Ciphertext::from_bytes(&$ct_bytes) + .map_err(|e| ::anyhow::anyhow!("Invalid ciphertext: {:?}", e))?; + let ss = <$algo>::decaps(&dk, &ct) + .map_err(|e| ::anyhow::anyhow!("Decapsulation failed: {:?}", e))?; + ss.as_ref().to_vec() + }}; +} + +macro_rules! dsa_sign { + ($crate_:ident :: $submod:ident, $algo:ty, $sk_bytes:expr, $message:expr) => {{ + let sk = $crate_::$submod::SigningKey::from_bytes(&$sk_bytes) + .map_err(|e| ::anyhow::anyhow!("Invalid signing key: {:?}", e))?; + let sig = <$algo>::sign(&sk, &$message) + .map_err(|e| ::anyhow::anyhow!("Signing failed: {:?}", e))?; + sig.as_bytes().to_vec() + }}; +} + +macro_rules! dsa_verify { + ($crate_:ident :: $submod:ident, $algo:ty, $pk_bytes:expr, $sig_bytes:expr, $message:expr) => {{ + let pk = $crate_::$submod::VerificationKey::from_bytes(&$pk_bytes) + .map_err(|e| ::anyhow::anyhow!("Invalid verification key: {:?}", e))?; + let sig = $crate_::$submod::Signature::from_bytes(&$sig_bytes) + .map_err(|e| ::anyhow::anyhow!("Invalid signature: {:?}", e))?; + <$algo>::verify(&pk, &$message, &sig) + }}; +} diff --git a/kylix-cli/src/main.rs b/kylix-cli/src/main.rs index b854e64..e306f39 100644 --- a/kylix-cli/src/main.rs +++ b/kylix-cli/src/main.rs @@ -1,1176 +1,21 @@ //! Kylix CLI - Post-quantum cryptography command-line tool. -use anyhow::{anyhow, bail, Context, Result}; -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use clap::{CommandFactory, Parser, Subcommand, ValueEnum}; -use clap_complete::{generate, Shell}; -use kylix_pqc::ml_dsa::{self, MlDsa44, MlDsa65, MlDsa87, Signer}; -use kylix_pqc::ml_kem::{self, Kem, MlKem1024, MlKem512, MlKem768}; -use kylix_pqc::slh_dsa::{ - self, SlhDsaShake128f, SlhDsaShake128s, SlhDsaShake192f, SlhDsaShake192s, SlhDsaShake256f, - SlhDsaShake256s, -}; -use rand::rng; -use std::fs; -#[cfg(unix)] -use std::fs::OpenOptions; -#[cfg(unix)] -use std::io::Write; -use std::io::{self, Read}; -#[cfg(unix)] -use std::os::unix::fs::OpenOptionsExt; -use std::path::PathBuf; -use zeroize::Zeroizing; - -// Algorithm dispatch macros — eliminate per-variant boilerplate in cmd_* functions. - -macro_rules! kem_keygen { - ($algo:ty) => {{ - let (dk, ek) = - <$algo>::keygen(&mut rng()).map_err(|e| anyhow!("Key generation failed: {:?}", e))?; - ( - ek.as_bytes().to_vec(), - Zeroizing::new(dk.as_bytes().to_vec()), - ) - }}; -} - -macro_rules! dsa_keygen { - ($algo:ty) => {{ - let (sk, pk) = - <$algo>::keygen(&mut rng()).map_err(|e| anyhow!("Key generation failed: {:?}", e))?; - ( - pk.as_bytes().to_vec(), - Zeroizing::new(sk.as_bytes().to_vec()), - ) - }}; -} - -macro_rules! kem_encaps { - ($crate_:ident :: $submod:ident, $algo:ty, $pk_bytes:expr) => {{ - let ek = $crate_::$submod::EncapsulationKey::from_bytes(&$pk_bytes) - .map_err(|e| anyhow!("Invalid public key: {:?}", e))?; - let (ct, ss) = <$algo>::encaps(&ek, &mut rng()) - .map_err(|e| anyhow!("Encapsulation failed: {:?}", e))?; - (ct.as_bytes().to_vec(), ss.as_ref().to_vec()) - }}; -} - -macro_rules! kem_decaps { - ($crate_:ident :: $submod:ident, $algo:ty, $sk_bytes:expr, $ct_bytes:expr) => {{ - let dk = $crate_::$submod::DecapsulationKey::from_bytes(&$sk_bytes) - .map_err(|e| anyhow!("Invalid secret key: {:?}", e))?; - let ct = $crate_::$submod::Ciphertext::from_bytes(&$ct_bytes) - .map_err(|e| anyhow!("Invalid ciphertext: {:?}", e))?; - let ss = <$algo>::decaps(&dk, &ct).map_err(|e| anyhow!("Decapsulation failed: {:?}", e))?; - ss.as_ref().to_vec() - }}; -} - -macro_rules! dsa_sign { - ($crate_:ident :: $submod:ident, $algo:ty, $sk_bytes:expr, $message:expr) => {{ - let sk = $crate_::$submod::SigningKey::from_bytes(&$sk_bytes) - .map_err(|e| anyhow!("Invalid signing key: {:?}", e))?; - let sig = <$algo>::sign(&sk, &$message).map_err(|e| anyhow!("Signing failed: {:?}", e))?; - sig.as_bytes().to_vec() - }}; -} - -macro_rules! dsa_verify { - ($crate_:ident :: $submod:ident, $algo:ty, $pk_bytes:expr, $sig_bytes:expr, $message:expr) => {{ - let pk = $crate_::$submod::VerificationKey::from_bytes(&$pk_bytes) - .map_err(|e| anyhow!("Invalid verification key: {:?}", e))?; - let sig = $crate_::$submod::Signature::from_bytes(&$sig_bytes) - .map_err(|e| anyhow!("Invalid signature: {:?}", e))?; - <$algo>::verify(&pk, &$message, &sig) - }}; -} +#[macro_use] +mod macros; +mod cli; +mod commands; +mod io; #[cfg(feature = "bench")] mod bench; -/// Post-quantum cryptography CLI tool -#[derive(Parser)] -#[command(name = "kylix")] -#[command(author, version, about, long_about = None)] -struct Cli { - /// Enable verbose output - #[arg(short, long, global = true)] - verbose: bool, - - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand)] -enum Commands { - /// Generate a new key pair - Keygen { - /// Algorithm to use - #[arg(short, long, value_enum, default_value = "ml-kem-768")] - algo: Algorithm, - - /// Output file prefix (creates `.pub` and `.sec`) - #[arg(short, long)] - output: String, - - /// Output format - #[arg(short, long, value_enum, default_value = "hex")] - format: OutputFormat, - }, - - /// Encapsulate a shared secret using a public key - Encaps { - /// Path to the public key file - #[arg(long = "pub")] - pubkey: PathBuf, - - /// Output file for ciphertext (writes to stdout if not specified) - #[arg(short, long)] - output: Option, - - /// Write shared secret to file instead of printing to console - #[arg(long = "secret-file")] - secret_file: Option, - - /// Output format - #[arg(short, long, value_enum, default_value = "hex")] - format: OutputFormat, - }, - - /// Decapsulate a shared secret using a secret key - Decaps { - /// Path to the secret key file - #[arg(long = "key")] - key: PathBuf, - - /// Path to the ciphertext file (reads from stdin if not specified) - #[arg(short, long)] - input: Option, - - /// Write shared secret to file instead of printing to console - #[arg(long = "secret-file")] - secret_file: Option, - - /// Output format for shared secret - #[arg(short, long, value_enum, default_value = "hex")] - format: OutputFormat, - }, - - /// Sign a file using ML-DSA or SLH-DSA - Sign { - /// Path to the signing key file - #[arg(long = "key")] - key: PathBuf, - - /// Input file to sign - #[arg(short, long)] - input: PathBuf, - - /// Output file for signature - #[arg(short, long)] - output: PathBuf, - - /// Output format - #[arg(short, long, value_enum, default_value = "hex")] - format: OutputFormat, - - /// Algorithm (required for SLH-DSA to distinguish -s/-f variants) - #[arg(long, value_enum)] - algo: Option, - }, - - /// Verify a signature using ML-DSA or SLH-DSA - Verify { - /// Path to the verification (public) key file - #[arg(long = "pub")] - pubkey: PathBuf, - - /// Input file that was signed - #[arg(short, long)] - input: PathBuf, - - /// Signature file - #[arg(short, long)] - signature: PathBuf, - - /// Input format for key and signature files - #[arg(short, long, value_enum, default_value = "hex")] - format: OutputFormat, - - /// Algorithm (required for SLH-DSA to distinguish -s/-f variants) - #[arg(long, value_enum)] - algo: Option, - }, - - /// Display information about supported algorithms - Info, - - /// Generate shell completion scripts - Completions { - /// Shell to generate completions for - #[arg(value_enum)] - shell: Shell, - }, - - /// Run performance benchmarks - #[cfg(feature = "bench")] - Bench { - /// Algorithm to benchmark (defaults to all if not specified) - #[arg(short, long, value_enum)] - algo: Option, - - /// Number of iterations - #[arg(short, long, default_value = "1000")] - iterations: u64, - - /// Output file for results (stdout if not specified) - #[arg(short, long)] - output: Option, - - /// Output format - #[arg(long, value_enum, default_value = "text")] - report: bench::ReportFormat, - - /// Compare with external PQC implementations (OpenSSL, liboqs) - #[arg(long)] - compare: bool, - - /// Specific tools to compare with (comma-separated: openssl,liboqs) - #[arg(long, value_delimiter = ',')] - with: Option>, - }, -} - -/// Supported post-quantum cryptographic algorithms for CLI operations. -/// -/// This enum is used across all CLI subcommands (keygen, encaps, decaps, sign, verify, bench) -/// to specify which algorithm variant to use. -#[derive(Copy, Clone, PartialEq, Eq, ValueEnum)] -pub enum Algorithm { - /// ML-KEM-512 (NIST Security Level 1, 128-bit) - #[value(name = "ml-kem-512")] - MlKem512, - /// ML-KEM-768 (NIST Security Level 3, 192-bit) - #[value(name = "ml-kem-768")] - MlKem768, - /// ML-KEM-1024 (NIST Security Level 5, 256-bit) - #[value(name = "ml-kem-1024")] - MlKem1024, - /// ML-DSA-44 (NIST Security Level 2, 128-bit) - #[value(name = "ml-dsa-44")] - MlDsa44, - /// ML-DSA-65 (NIST Security Level 3, 192-bit) - #[value(name = "ml-dsa-65")] - MlDsa65, - /// ML-DSA-87 (NIST Security Level 5, 256-bit) - #[value(name = "ml-dsa-87")] - MlDsa87, - /// SLH-DSA-SHAKE-128s (NIST Security Level 1, small signatures) - #[value(name = "slh-dsa-shake-128s")] - SlhDsaShake128s, - /// SLH-DSA-SHAKE-128f (NIST Security Level 1, fast signing) - #[value(name = "slh-dsa-shake-128f")] - SlhDsaShake128f, - /// SLH-DSA-SHAKE-192s (NIST Security Level 3, small signatures) - #[value(name = "slh-dsa-shake-192s")] - SlhDsaShake192s, - /// SLH-DSA-SHAKE-192f (NIST Security Level 3, fast signing) - #[value(name = "slh-dsa-shake-192f")] - SlhDsaShake192f, - /// SLH-DSA-SHAKE-256s (NIST Security Level 5, small signatures) - #[value(name = "slh-dsa-shake-256s")] - SlhDsaShake256s, - /// SLH-DSA-SHAKE-256f (NIST Security Level 5, fast signing) - #[value(name = "slh-dsa-shake-256f")] - SlhDsaShake256f, -} - -impl Algorithm { - /// Returns true if this is a KEM algorithm - fn is_kem(&self) -> bool { - matches!( - self, - Algorithm::MlKem512 | Algorithm::MlKem768 | Algorithm::MlKem1024 - ) - } - - /// Returns true if this is an SLH-DSA algorithm - fn is_slh_dsa(&self) -> bool { - matches!( - self, - Algorithm::SlhDsaShake128s - | Algorithm::SlhDsaShake128f - | Algorithm::SlhDsaShake192s - | Algorithm::SlhDsaShake192f - | Algorithm::SlhDsaShake256s - | Algorithm::SlhDsaShake256f - ) - } -} - -impl std::fmt::Display for Algorithm { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Algorithm::MlKem512 => write!(f, "ML-KEM-512"), - Algorithm::MlKem768 => write!(f, "ML-KEM-768"), - Algorithm::MlKem1024 => write!(f, "ML-KEM-1024"), - Algorithm::MlDsa44 => write!(f, "ML-DSA-44"), - Algorithm::MlDsa65 => write!(f, "ML-DSA-65"), - Algorithm::MlDsa87 => write!(f, "ML-DSA-87"), - Algorithm::SlhDsaShake128s => write!(f, "SLH-DSA-SHAKE-128s"), - Algorithm::SlhDsaShake128f => write!(f, "SLH-DSA-SHAKE-128f"), - Algorithm::SlhDsaShake192s => write!(f, "SLH-DSA-SHAKE-192s"), - Algorithm::SlhDsaShake192f => write!(f, "SLH-DSA-SHAKE-192f"), - Algorithm::SlhDsaShake256s => write!(f, "SLH-DSA-SHAKE-256s"), - Algorithm::SlhDsaShake256f => write!(f, "SLH-DSA-SHAKE-256f"), - } - } -} - -/// Algorithm metadata for size detection and display. -pub struct AlgorithmInfo { - pub pub_key_size: usize, - pub sec_key_size: usize, - pub output_size: usize, // ciphertext for KEM, signature for DSA - pub pub_label: &'static str, - pub sec_label: &'static str, -} - -impl Algorithm { - /// Get algorithm metadata. - pub const fn info(&self) -> AlgorithmInfo { - match self { - Algorithm::MlKem512 => AlgorithmInfo { - pub_key_size: MlKem512::ENCAPSULATION_KEY_SIZE, - sec_key_size: MlKem512::DECAPSULATION_KEY_SIZE, - output_size: MlKem512::CIPHERTEXT_SIZE, - pub_label: "ML-KEM PUBLIC KEY", - sec_label: "ML-KEM SECRET KEY", - }, - Algorithm::MlKem768 => AlgorithmInfo { - pub_key_size: MlKem768::ENCAPSULATION_KEY_SIZE, - sec_key_size: MlKem768::DECAPSULATION_KEY_SIZE, - output_size: MlKem768::CIPHERTEXT_SIZE, - pub_label: "ML-KEM PUBLIC KEY", - sec_label: "ML-KEM SECRET KEY", - }, - Algorithm::MlKem1024 => AlgorithmInfo { - pub_key_size: MlKem1024::ENCAPSULATION_KEY_SIZE, - sec_key_size: MlKem1024::DECAPSULATION_KEY_SIZE, - output_size: MlKem1024::CIPHERTEXT_SIZE, - pub_label: "ML-KEM PUBLIC KEY", - sec_label: "ML-KEM SECRET KEY", - }, - Algorithm::MlDsa44 => AlgorithmInfo { - pub_key_size: MlDsa44::VERIFICATION_KEY_SIZE, - sec_key_size: MlDsa44::SIGNING_KEY_SIZE, - output_size: MlDsa44::SIGNATURE_SIZE, - pub_label: "ML-DSA PUBLIC KEY", - sec_label: "ML-DSA SECRET KEY", - }, - Algorithm::MlDsa65 => AlgorithmInfo { - pub_key_size: MlDsa65::VERIFICATION_KEY_SIZE, - sec_key_size: MlDsa65::SIGNING_KEY_SIZE, - output_size: MlDsa65::SIGNATURE_SIZE, - pub_label: "ML-DSA PUBLIC KEY", - sec_label: "ML-DSA SECRET KEY", - }, - Algorithm::MlDsa87 => AlgorithmInfo { - pub_key_size: MlDsa87::VERIFICATION_KEY_SIZE, - sec_key_size: MlDsa87::SIGNING_KEY_SIZE, - output_size: MlDsa87::SIGNATURE_SIZE, - pub_label: "ML-DSA PUBLIC KEY", - sec_label: "ML-DSA SECRET KEY", - }, - Algorithm::SlhDsaShake128s => AlgorithmInfo { - pub_key_size: SlhDsaShake128s::VERIFICATION_KEY_SIZE, - sec_key_size: SlhDsaShake128s::SIGNING_KEY_SIZE, - output_size: SlhDsaShake128s::SIGNATURE_SIZE, - pub_label: "SLH-DSA PUBLIC KEY", - sec_label: "SLH-DSA SECRET KEY", - }, - Algorithm::SlhDsaShake128f => AlgorithmInfo { - pub_key_size: SlhDsaShake128f::VERIFICATION_KEY_SIZE, - sec_key_size: SlhDsaShake128f::SIGNING_KEY_SIZE, - output_size: SlhDsaShake128f::SIGNATURE_SIZE, - pub_label: "SLH-DSA PUBLIC KEY", - sec_label: "SLH-DSA SECRET KEY", - }, - Algorithm::SlhDsaShake192s => AlgorithmInfo { - pub_key_size: SlhDsaShake192s::VERIFICATION_KEY_SIZE, - sec_key_size: SlhDsaShake192s::SIGNING_KEY_SIZE, - output_size: SlhDsaShake192s::SIGNATURE_SIZE, - pub_label: "SLH-DSA PUBLIC KEY", - sec_label: "SLH-DSA SECRET KEY", - }, - Algorithm::SlhDsaShake192f => AlgorithmInfo { - pub_key_size: SlhDsaShake192f::VERIFICATION_KEY_SIZE, - sec_key_size: SlhDsaShake192f::SIGNING_KEY_SIZE, - output_size: SlhDsaShake192f::SIGNATURE_SIZE, - pub_label: "SLH-DSA PUBLIC KEY", - sec_label: "SLH-DSA SECRET KEY", - }, - Algorithm::SlhDsaShake256s => AlgorithmInfo { - pub_key_size: SlhDsaShake256s::VERIFICATION_KEY_SIZE, - sec_key_size: SlhDsaShake256s::SIGNING_KEY_SIZE, - output_size: SlhDsaShake256s::SIGNATURE_SIZE, - pub_label: "SLH-DSA PUBLIC KEY", - sec_label: "SLH-DSA SECRET KEY", - }, - Algorithm::SlhDsaShake256f => AlgorithmInfo { - pub_key_size: SlhDsaShake256f::VERIFICATION_KEY_SIZE, - sec_key_size: SlhDsaShake256f::SIGNING_KEY_SIZE, - output_size: SlhDsaShake256f::SIGNATURE_SIZE, - pub_label: "SLH-DSA PUBLIC KEY", - sec_label: "SLH-DSA SECRET KEY", - }, - } - } - - /// Detect KEM algorithm from public key size. - fn detect_kem_from_pub_key(size: usize) -> Result { - match size { - MlKem512::ENCAPSULATION_KEY_SIZE => Ok(Algorithm::MlKem512), - MlKem768::ENCAPSULATION_KEY_SIZE => Ok(Algorithm::MlKem768), - MlKem1024::ENCAPSULATION_KEY_SIZE => Ok(Algorithm::MlKem1024), - _ => bail!( - "Unknown public key size: {} bytes. Expected {}, {}, or {}.", - size, - MlKem512::ENCAPSULATION_KEY_SIZE, - MlKem768::ENCAPSULATION_KEY_SIZE, - MlKem1024::ENCAPSULATION_KEY_SIZE - ), - } - } - - /// Detect KEM algorithm from secret key size. - fn detect_kem_from_sec_key(size: usize) -> Result { - match size { - MlKem512::DECAPSULATION_KEY_SIZE => Ok(Algorithm::MlKem512), - MlKem768::DECAPSULATION_KEY_SIZE => Ok(Algorithm::MlKem768), - MlKem1024::DECAPSULATION_KEY_SIZE => Ok(Algorithm::MlKem1024), - _ => bail!( - "Unknown secret key size: {} bytes. Expected {}, {}, or {}.", - size, - MlKem512::DECAPSULATION_KEY_SIZE, - MlKem768::DECAPSULATION_KEY_SIZE, - MlKem1024::DECAPSULATION_KEY_SIZE - ), - } - } - - /// Detect DSA algorithm from signing key size. - /// SLH-DSA small/fast variants share key sizes, so auto-detection is ambiguous. - fn detect_dsa_from_signing_key(size: usize) -> Result { - match size { - MlDsa44::SIGNING_KEY_SIZE => Ok(Algorithm::MlDsa44), - MlDsa65::SIGNING_KEY_SIZE => Ok(Algorithm::MlDsa65), - MlDsa87::SIGNING_KEY_SIZE => Ok(Algorithm::MlDsa87), - SlhDsaShake128f::SIGNING_KEY_SIZE => bail!( - "SLH-DSA key detected ({} bytes) but small vs fast variant is ambiguous. \ - Please specify the algorithm explicitly with --algo \ - (e.g. --algo slh-dsa-shake-128s or slh-dsa-shake-128f).", - size - ), - SlhDsaShake192f::SIGNING_KEY_SIZE => bail!( - "SLH-DSA key detected ({} bytes) but small vs fast variant is ambiguous. \ - Please specify the algorithm explicitly with --algo \ - (e.g. --algo slh-dsa-shake-192s or slh-dsa-shake-192f).", - size - ), - SlhDsaShake256f::SIGNING_KEY_SIZE => bail!( - "SLH-DSA key detected ({} bytes) but small vs fast variant is ambiguous. \ - Please specify the algorithm explicitly with --algo \ - (e.g. --algo slh-dsa-shake-256s or slh-dsa-shake-256f).", - size - ), - _ => bail!( - "Unknown signing key size: {} bytes. Expected ML-DSA (2560/4032/4896) or SLH-DSA (64/96/128).", - size - ), - } - } - - /// Detect DSA algorithm from verification key size. - /// SLH-DSA small/fast variants share key sizes, so auto-detection is ambiguous. - fn detect_dsa_from_verification_key(size: usize) -> Result { - match size { - MlDsa44::VERIFICATION_KEY_SIZE => Ok(Algorithm::MlDsa44), - MlDsa65::VERIFICATION_KEY_SIZE => Ok(Algorithm::MlDsa65), - MlDsa87::VERIFICATION_KEY_SIZE => Ok(Algorithm::MlDsa87), - SlhDsaShake128f::VERIFICATION_KEY_SIZE => bail!( - "SLH-DSA key detected ({} bytes) but small vs fast variant is ambiguous. \ - Please specify the algorithm explicitly with --algo \ - (e.g. --algo slh-dsa-shake-128s or slh-dsa-shake-128f).", - size - ), - SlhDsaShake192f::VERIFICATION_KEY_SIZE => bail!( - "SLH-DSA key detected ({} bytes) but small vs fast variant is ambiguous. \ - Please specify the algorithm explicitly with --algo \ - (e.g. --algo slh-dsa-shake-192s or slh-dsa-shake-192f).", - size - ), - SlhDsaShake256f::VERIFICATION_KEY_SIZE => bail!( - "SLH-DSA key detected ({} bytes) but small vs fast variant is ambiguous. \ - Please specify the algorithm explicitly with --algo \ - (e.g. --algo slh-dsa-shake-256s or slh-dsa-shake-256f).", - size - ), - _ => bail!( - "Unknown verification key size: {} bytes. Expected ML-DSA (1312/1952/2592) or SLH-DSA (32/48/64).", - size - ), - } - } -} - -#[derive(Copy, Clone, PartialEq, Eq, ValueEnum)] -enum OutputFormat { - /// Hexadecimal encoding - Hex, - /// Base64 encoding - Base64, - /// PEM format - Pem, -} - -/// Encode bytes to the specified format -fn encode_output(data: &[u8], format: OutputFormat, label: &str) -> String { - match format { - OutputFormat::Hex => hex::encode(data), - OutputFormat::Base64 => BASE64.encode(data), - OutputFormat::Pem => { - let b64 = BASE64.encode(data); - let wrapped: String = b64 - .as_bytes() - .chunks(64) - .map(|chunk| std::str::from_utf8(chunk).expect("BASE64 output is valid ASCII")) - .collect::>() - .join("\n"); - format!( - "-----BEGIN {}-----\n{}\n-----END {}-----", - label, wrapped, label - ) - } - } -} - -/// Check if a string is valid hexadecimal -fn is_hex(s: &str) -> bool { - !s.is_empty() && s.chars().all(|c| c.is_ascii_hexdigit()) -} - -/// Decode bytes with auto-detection of format. -/// Detection order: PEM (by header) -> Hex (if all hex chars) -> Base64. -/// The format parameter is ignored for input; it only affects output encoding. -fn decode_input(data: &str, _format: OutputFormat) -> Result> { - let data = data.trim(); - - // Auto-detect PEM format - if data.starts_with("-----BEGIN") { - let lines: Vec<&str> = data.lines().collect(); - if lines.len() < 3 { - bail!("Invalid PEM format"); - } - let b64: String = lines[1..lines.len() - 1].join(""); - return BASE64 - .decode(&b64) - .context("Failed to decode PEM base64 content"); - } - - // Auto-detect hex vs base64 - // Hex: only 0-9, a-f, A-F (and must have even length for valid bytes) - // Base64: may contain +, /, = which are not valid hex - if is_hex(data) && data.len() % 2 == 0 { - return hex::decode(data).context("Failed to decode hex"); - } - - // Try base64 - BASE64.decode(data).context("Failed to decode base64") -} - -/// Write a file with restricted permissions (0o600) on Unix systems. -/// On non-Unix systems, falls back to standard [`fs::write`]. -/// -/// # Parameters -/// -/// - `path`: Filesystem path where the secret data will be written. -/// - `content`: UTF-8 string contents to write to the secret file. -/// -/// # Errors -/// -/// Returns an error if the file cannot be created or written. On Unix systems, -/// this includes failures when setting file permissions; on non-Unix systems, -/// this includes any error returned by [`fs::write`]. -fn write_secret_file(path: &std::path::Path, content: &str) -> Result<()> { - #[cfg(unix)] - { - let parent = path.parent().ok_or_else(|| { - anyhow!( - "Cannot determine parent directory for secret file path: {}", - path.display() - ) - })?; - - let filename = path - .file_name() - .ok_or_else(|| anyhow!("Path does not contain a valid filename: {}", path.display()))?; - - // Use random suffix to prevent attackers from pre-creating predictable temp files - let random_suffix: u64 = rand::random(); - let mut temp_path = parent.to_path_buf(); - temp_path.push(format!( - ".{}.{:016x}.tmp", - filename.to_string_lossy(), - random_suffix - )); - - // Create temp file with restrictive permissions (0o600) BEFORE writing secret data - // Use create_new to prevent race conditions with attacker-created files - let mut file = OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&temp_path) - .with_context(|| { - format!( - "Failed to create temp file for secret file: {}", - temp_path.display() - ) - })?; - file.write_all(content.as_bytes()).with_context(|| { - format!( - "Failed to write temp file for secret file: {}", - temp_path.display() - ) - })?; - - // Atomic rename to target path (works because same filesystem) - fs::rename(&temp_path, path).with_context(|| { - format!( - "Failed to rename temp file to secret file: {}", - path.display() - ) - })?; - - Ok(()) - } - #[cfg(not(unix))] - { - eprintln!( - "Warning: file permissions cannot be restricted on this platform. Secret file '{}' may be readable by other users.", - path.display() - ); - fs::write(path, content) - .with_context(|| format!("Failed to write secret file: {}", path.display()))?; - Ok(()) - } -} - -/// Generate a key pair for the specified algorithm -fn cmd_keygen(algo: Algorithm, output: &str, format: OutputFormat, verbose: bool) -> Result<()> { - if verbose { - eprintln!("Generating {} key pair...", algo); - } - - let info = algo.info(); - let (pk_label, sk_label) = (info.pub_label, info.sec_label); +use anyhow::Result; +use clap::Parser; - let (pk_bytes, sk_bytes): (Vec, Zeroizing>) = match algo { - Algorithm::MlKem512 => kem_keygen!(ml_kem::MlKem512), - Algorithm::MlKem768 => kem_keygen!(ml_kem::MlKem768), - Algorithm::MlKem1024 => kem_keygen!(ml_kem::MlKem1024), - Algorithm::MlDsa44 => dsa_keygen!(ml_dsa::MlDsa44), - Algorithm::MlDsa65 => dsa_keygen!(ml_dsa::MlDsa65), - Algorithm::MlDsa87 => dsa_keygen!(ml_dsa::MlDsa87), - Algorithm::SlhDsaShake128s => dsa_keygen!(slh_dsa::SlhDsaShake128s), - Algorithm::SlhDsaShake128f => dsa_keygen!(slh_dsa::SlhDsaShake128f), - Algorithm::SlhDsaShake192s => dsa_keygen!(slh_dsa::SlhDsaShake192s), - Algorithm::SlhDsaShake192f => dsa_keygen!(slh_dsa::SlhDsaShake192f), - Algorithm::SlhDsaShake256s => dsa_keygen!(slh_dsa::SlhDsaShake256s), - Algorithm::SlhDsaShake256f => dsa_keygen!(slh_dsa::SlhDsaShake256f), - }; - - // Store sizes before zeroization for verbose output - let pk_size = pk_bytes.len(); - let sk_size = sk_bytes.len(); - - let pk_encoded = encode_output(&pk_bytes, format, pk_label); - let sk_encoded = Zeroizing::new(encode_output(&sk_bytes, format, sk_label)); - // sk_bytes is Zeroizing>, automatically zeroized on drop - - let pub_path = format!("{}.pub", output); - let sec_path = format!("{}.sec", output); - - fs::write(&pub_path, &pk_encoded).context("Failed to write public key")?; - // Use restrictive permissions (0o600) for secret key on Unix - write_secret_file(sec_path.as_ref(), &sk_encoded)?; - drop(sk_encoded); // zeroize encoded secret key immediately after writing - - if verbose { - eprintln!("Public key size: {} bytes", pk_size); - eprintln!("Secret key size: {} bytes", sk_size); - } - - println!("Public key written to: {}", pub_path); - println!("Secret key written to: {}", sec_path); - - Ok(()) -} - -/// Encapsulate a shared secret -fn cmd_encaps( - pubkey: &PathBuf, - output: Option<&PathBuf>, - secret_file: Option<&PathBuf>, - format: OutputFormat, - verbose: bool, -) -> Result<()> { - let pk_data = fs::read_to_string(pubkey).context("Failed to read public key file")?; - let pk_bytes = decode_input(&pk_data, format)?; - - let algo = Algorithm::detect_kem_from_pub_key(pk_bytes.len())?; - - if verbose { - eprintln!("Detected algorithm: {}", algo); - eprintln!("Public key size: {} bytes", pk_bytes.len()); - } - - let (ct_bytes, ss_bytes_raw): (Vec, Vec) = match algo { - Algorithm::MlKem512 => kem_encaps!(ml_kem::ml_kem_512, ml_kem::MlKem512, pk_bytes), - Algorithm::MlKem768 => kem_encaps!(ml_kem::ml_kem_768, ml_kem::MlKem768, pk_bytes), - Algorithm::MlKem1024 => kem_encaps!(ml_kem::ml_kem_1024, ml_kem::MlKem1024, pk_bytes), - // detect_kem_algorithm only returns ML-KEM variants, so DSA variants are unreachable - _ => unreachable!(), - }; - let ss_bytes = Zeroizing::new(ss_bytes_raw); - - let ct_encoded = encode_output(&ct_bytes, format, "ML-KEM CIPHERTEXT"); - - if let Some(out_path) = output { - fs::write(out_path, &ct_encoded).context("Failed to write ciphertext")?; - if verbose { - eprintln!("Ciphertext written to: {}", out_path.display()); - eprintln!("Ciphertext size: {} bytes", ct_bytes.len()); - } - } else { - println!("{}", ct_encoded); - } - - let ss_len = ss_bytes.len(); - let ss_encoded = Zeroizing::new(encode_output(&ss_bytes, format, "SHARED SECRET")); - drop(ss_bytes); // zeroize shared secret bytes immediately after encoding - if let Some(sf_path) = secret_file { - write_secret_file(sf_path, &ss_encoded)?; - if verbose { - eprintln!("Shared secret written to: {}", sf_path.display()); - } - } else if output.is_some() { - println!("Shared secret: {}", &*ss_encoded); - } else { - eprintln!("Shared secret: {}", &*ss_encoded); - } - drop(ss_encoded); // zeroize encoded shared secret immediately after output - - if verbose { - eprintln!("Shared secret size: {} bytes", ss_len); - } - - Ok(()) -} - -/// Decapsulate a shared secret -fn cmd_decaps( - key: &PathBuf, - input: Option<&PathBuf>, - secret_file: Option<&PathBuf>, - format: OutputFormat, - verbose: bool, -) -> Result<()> { - let sk_data = - Zeroizing::new(fs::read_to_string(key).context("Failed to read secret key file")?); - let sk_bytes = Zeroizing::new(decode_input(&sk_data, format)?); - drop(sk_data); // zeroize raw key string immediately after decoding - - let algo = Algorithm::detect_kem_from_sec_key(sk_bytes.len())?; - - if verbose { - eprintln!("Detected algorithm: {}", algo); - eprintln!("Secret key size: {} bytes", sk_bytes.len()); - } - - let ct_data = if let Some(ct_path) = input { - fs::read_to_string(ct_path).context("Failed to read ciphertext file")? - } else { - let mut buf = String::new(); - io::stdin() - .read_to_string(&mut buf) - .context("Failed to read ciphertext from stdin")?; - buf - }; - let ct_bytes = decode_input(&ct_data, format)?; - - if verbose { - eprintln!("Ciphertext size: {} bytes", ct_bytes.len()); - } - - let ss_bytes_raw: Vec = match algo { - Algorithm::MlKem512 => { - kem_decaps!(ml_kem::ml_kem_512, ml_kem::MlKem512, sk_bytes, ct_bytes) - } - Algorithm::MlKem768 => { - kem_decaps!(ml_kem::ml_kem_768, ml_kem::MlKem768, sk_bytes, ct_bytes) - } - Algorithm::MlKem1024 => { - kem_decaps!(ml_kem::ml_kem_1024, ml_kem::MlKem1024, sk_bytes, ct_bytes) - } - // detect_kem_algorithm only returns ML-KEM variants, so this is unreachable - _ => unreachable!(), - }; - let ss_bytes = Zeroizing::new(ss_bytes_raw); - drop(sk_bytes); // zeroize secret key bytes immediately after decapsulation - - let ss_len = ss_bytes.len(); - let ss_encoded = Zeroizing::new(encode_output(&ss_bytes, format, "SHARED SECRET")); - drop(ss_bytes); // zeroize shared secret bytes immediately after encoding - if let Some(sf_path) = secret_file { - write_secret_file(sf_path, &ss_encoded)?; - if verbose { - eprintln!("Shared secret written to: {}", sf_path.display()); - } - } else { - println!("{}", &*ss_encoded); - } - drop(ss_encoded); // zeroize encoded shared secret immediately after output - - if verbose { - eprintln!("Shared secret size: {} bytes", ss_len); - } - - Ok(()) -} - -/// Sign a file with ML-DSA or SLH-DSA -fn cmd_sign( - key: &PathBuf, - input: &PathBuf, - output: &PathBuf, - format: OutputFormat, - explicit_algo: Option, - verbose: bool, -) -> Result<()> { - let sk_data = - Zeroizing::new(fs::read_to_string(key).context("Failed to read signing key file")?); - let sk_bytes = Zeroizing::new(decode_input(&sk_data, format)?); - drop(sk_data); // zeroize raw key string immediately after decoding - - // Use explicit algorithm if provided, otherwise detect from key size - let algo = if let Some(a) = explicit_algo { - // Validate key size matches the explicit algorithm - if a.is_kem() { - bail!("Algorithm {} is not a signature algorithm", a); - } - let expected_size = a.info().sec_key_size; - if sk_bytes.len() != expected_size { - bail!( - "Key size {} bytes does not match algorithm {} (expected {} bytes)", - sk_bytes.len(), - a, - expected_size - ); - } - a - } else { - Algorithm::detect_dsa_from_signing_key(sk_bytes.len())? - }; - - if verbose { - eprintln!("Detected algorithm: {}", algo); - eprintln!("Signing key size: {} bytes", sk_bytes.len()); - } - - let message = fs::read(input).context("Failed to read input file")?; - - if verbose { - eprintln!("Message size: {} bytes", message.len()); - } - - let sig_bytes: Vec = match algo { - Algorithm::MlDsa44 => dsa_sign!(ml_dsa::dsa44, ml_dsa::MlDsa44, sk_bytes, message), - Algorithm::MlDsa65 => dsa_sign!(ml_dsa::dsa65, ml_dsa::MlDsa65, sk_bytes, message), - Algorithm::MlDsa87 => dsa_sign!(ml_dsa::dsa87, ml_dsa::MlDsa87, sk_bytes, message), - Algorithm::SlhDsaShake128s => { - dsa_sign!( - slh_dsa::slh_dsa_shake_128s, - slh_dsa::SlhDsaShake128s, - sk_bytes, - message - ) - } - Algorithm::SlhDsaShake128f => { - dsa_sign!( - slh_dsa::slh_dsa_shake_128f, - slh_dsa::SlhDsaShake128f, - sk_bytes, - message - ) - } - Algorithm::SlhDsaShake192s => { - dsa_sign!( - slh_dsa::slh_dsa_shake_192s, - slh_dsa::SlhDsaShake192s, - sk_bytes, - message - ) - } - Algorithm::SlhDsaShake192f => { - dsa_sign!( - slh_dsa::slh_dsa_shake_192f, - slh_dsa::SlhDsaShake192f, - sk_bytes, - message - ) - } - Algorithm::SlhDsaShake256s => { - dsa_sign!( - slh_dsa::slh_dsa_shake_256s, - slh_dsa::SlhDsaShake256s, - sk_bytes, - message - ) - } - Algorithm::SlhDsaShake256f => { - dsa_sign!( - slh_dsa::slh_dsa_shake_256f, - slh_dsa::SlhDsaShake256f, - sk_bytes, - message - ) - } - _ => bail!("Algorithm {} does not support signing", algo), - }; - drop(sk_bytes); // zeroize signing key bytes immediately after signing - - let sig_label = if algo.is_slh_dsa() { - "SLH-DSA SIGNATURE" - } else { - "ML-DSA SIGNATURE" - }; - let sig_encoded = encode_output(&sig_bytes, format, sig_label); - fs::write(output, &sig_encoded).context("Failed to write signature")?; - - if verbose { - eprintln!("Signature size: {} bytes", sig_bytes.len()); - } - - println!("Signature written to: {}", output.display()); - - Ok(()) -} - -/// Verify a signature with ML-DSA or SLH-DSA -fn cmd_verify( - pubkey: &PathBuf, - input: &PathBuf, - signature: &PathBuf, - format: OutputFormat, - explicit_algo: Option, - verbose: bool, -) -> Result<()> { - let pk_data = fs::read_to_string(pubkey).context("Failed to read public key file")?; - let pk_bytes = decode_input(&pk_data, format)?; - - // Use explicit algorithm if provided, otherwise detect from key size - let algo = if let Some(a) = explicit_algo { - // Validate key size matches the explicit algorithm - if a.is_kem() { - bail!("Algorithm {} is not a signature algorithm", a); - } - let expected_size = a.info().pub_key_size; - if pk_bytes.len() != expected_size { - bail!( - "Key size {} bytes does not match algorithm {} (expected {} bytes)", - pk_bytes.len(), - a, - expected_size - ); - } - a - } else { - Algorithm::detect_dsa_from_verification_key(pk_bytes.len())? - }; - - if verbose { - eprintln!("Detected algorithm: {}", algo); - eprintln!("Verification key size: {} bytes", pk_bytes.len()); - } - - let message = fs::read(input).context("Failed to read input file")?; - let sig_data = fs::read_to_string(signature).context("Failed to read signature file")?; - let sig_bytes = decode_input(&sig_data, format)?; - - if verbose { - eprintln!("Message size: {} bytes", message.len()); - eprintln!("Signature size: {} bytes", sig_bytes.len()); - } - - let result = match algo { - Algorithm::MlDsa44 => { - dsa_verify!(ml_dsa::dsa44, ml_dsa::MlDsa44, pk_bytes, sig_bytes, message) - } - Algorithm::MlDsa65 => { - dsa_verify!(ml_dsa::dsa65, ml_dsa::MlDsa65, pk_bytes, sig_bytes, message) - } - Algorithm::MlDsa87 => { - dsa_verify!(ml_dsa::dsa87, ml_dsa::MlDsa87, pk_bytes, sig_bytes, message) - } - Algorithm::SlhDsaShake128s => { - dsa_verify!( - slh_dsa::slh_dsa_shake_128s, - slh_dsa::SlhDsaShake128s, - pk_bytes, - sig_bytes, - message - ) - } - Algorithm::SlhDsaShake128f => { - dsa_verify!( - slh_dsa::slh_dsa_shake_128f, - slh_dsa::SlhDsaShake128f, - pk_bytes, - sig_bytes, - message - ) - } - Algorithm::SlhDsaShake192s => { - dsa_verify!( - slh_dsa::slh_dsa_shake_192s, - slh_dsa::SlhDsaShake192s, - pk_bytes, - sig_bytes, - message - ) - } - Algorithm::SlhDsaShake192f => { - dsa_verify!( - slh_dsa::slh_dsa_shake_192f, - slh_dsa::SlhDsaShake192f, - pk_bytes, - sig_bytes, - message - ) - } - Algorithm::SlhDsaShake256s => { - dsa_verify!( - slh_dsa::slh_dsa_shake_256s, - slh_dsa::SlhDsaShake256s, - pk_bytes, - sig_bytes, - message - ) - } - Algorithm::SlhDsaShake256f => { - dsa_verify!( - slh_dsa::slh_dsa_shake_256f, - slh_dsa::SlhDsaShake256f, - pk_bytes, - sig_bytes, - message - ) - } - _ => bail!("Algorithm {} does not support verification", algo), - }; - - match result { - Ok(()) => { - println!("Signature is valid."); - Ok(()) - } - Err(_) => { - bail!("Signature verification failed.") - } - } -} - -/// Display information about supported algorithms -fn cmd_info() { - println!("Kylix - Post-Quantum Cryptography Library"); - println!(); - println!("Supported algorithms:"); - println!(); - - // ML-KEM algorithms - println!(" ML-KEM (FIPS 203) - Key Encapsulation Mechanism"); - for (algo, level) in [ - (Algorithm::MlKem512, "Security Level 1 (128-bit)"), - (Algorithm::MlKem768, "Security Level 3 (192-bit)"), - (Algorithm::MlKem1024, "Security Level 5 (256-bit)"), - ] { - let info = algo.info(); - println!( - " {:<12} {} PK: {}B SK: {}B CT: {}B", - format!("{}", algo).to_lowercase(), - level, - info.pub_key_size, - info.sec_key_size, - info.output_size - ); - } - println!(); - - // ML-DSA algorithms - println!(" ML-DSA (FIPS 204) - Digital Signature Algorithm"); - for (algo, level) in [ - (Algorithm::MlDsa44, "Security Level 2 (128-bit)"), - (Algorithm::MlDsa65, "Security Level 3 (192-bit)"), - (Algorithm::MlDsa87, "Security Level 5 (256-bit)"), - ] { - let info = algo.info(); - println!( - " {:<12} {} PK: {}B SK: {}B SIG: {}B", - format!("{}", algo).to_lowercase(), - level, - info.pub_key_size, - info.sec_key_size, - info.output_size - ); - } - println!(); - - // SLH-DSA algorithms - println!(" SLH-DSA (FIPS 205) - Stateless Hash-Based Digital Signature Algorithm"); - for (algo, level) in [ - (Algorithm::SlhDsaShake128s, "Security Level 1 (small)"), - (Algorithm::SlhDsaShake128f, "Security Level 1 (fast)"), - (Algorithm::SlhDsaShake192s, "Security Level 3 (small)"), - (Algorithm::SlhDsaShake192f, "Security Level 3 (fast)"), - (Algorithm::SlhDsaShake256s, "Security Level 5 (small)"), - (Algorithm::SlhDsaShake256f, "Security Level 5 (fast)"), - ] { - let info = algo.info(); - println!( - " {:<20} {} PK: {}B SK: {}B SIG: {}B", - format!("{}", algo).to_lowercase(), - level, - info.pub_key_size, - info.sec_key_size, - info.output_size - ); - } - println!(); - - println!("Output formats:"); - println!(" hex - Hexadecimal encoding (default)"); - println!(" base64 - Base64 encoding"); - println!(" pem - PEM format with headers"); -} - -/// Generate shell completions -fn cmd_completions(shell: Shell) { - let mut cmd = Cli::command(); - generate(shell, &mut cmd, "kylix", &mut io::stdout()); -} +use cli::{Cli, Commands}; +use commands::{ + cmd_completions, cmd_decaps, cmd_encaps, cmd_info, cmd_keygen, cmd_sign, cmd_verify, +}; fn main() -> Result<()> { let cli = Cli::parse();