diff --git a/Cargo.lock b/Cargo.lock index d7e7ee5..d7e51b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,6 +160,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -367,6 +373,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -396,6 +414,7 @@ dependencies = [ "cfg-if", "cpufeatures", "curve25519-dalek-derive", + "digest", "fiat-crypto", "rustc_version", "subtle", @@ -453,9 +472,65 @@ dependencies = [ "age", "clap", "flate2", + "rand", + "ssh-key", "tar", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "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", + "sha2", + "subtle", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "errno" version = "0.3.14" @@ -466,6 +541,16 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -641,6 +726,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -664,6 +750,17 @@ dependencies = [ "polyval", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "heck" version = "0.5.0" @@ -927,6 +1024,44 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core", + "sha2", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -960,6 +1095,15 @@ dependencies = [ "hmac", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "pin-project" version = "1.1.12" @@ -1039,6 +1183,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -1118,6 +1271,16 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "rsa" version = "0.9.10" @@ -1132,6 +1295,7 @@ dependencies = [ "pkcs1", "pkcs8", "rand_core", + "sha2", "signature", "spki", "subtle", @@ -1247,6 +1411,20 @@ dependencies = [ "sha2", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "secrecy" version = "0.10.3" @@ -1362,6 +1540,48 @@ dependencies = [ "der", ] +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "cipher", + "ssh-encoding", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "ed25519-dalek", + "p256", + "p384", + "p521", + "rand_core", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "strsim" version = "0.11.1" diff --git a/Cargo.toml b/Cargo.toml index 7f75a1c..7afd348 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,4 +7,6 @@ edition = "2024" age = { version = "0.11.3", features = ["ssh"] } clap = { version = "4.6.1", features = ["derive"] } flate2 = "1.1.9" +rand = "0.8" +ssh-key = { version = "0.6", features = ["ed25519", "getrandom"] } tar = "0.4.45" diff --git a/plans/phase6.md b/plans/phase6.md new file mode 100644 index 0000000..0b923af --- /dev/null +++ b/plans/phase6.md @@ -0,0 +1,12 @@ +# Phase 6 + +For phase 6, the app is almost finished but I want to include a couple quality of life features: + +- If the user runs `drawer open somedrawer.drawer`, this is currently an error because there is no target path given. +If the file basename of the drawer path would be a valid target path (`./somedrawer` in this case) then use that as the +target path, but also print to stdout that that's what it's using. This should still not open the drawer if the target +path exists and is nonempty, unless the user passes `-f` + +- Add a new command, `drawer key ` which will create a new key and store it in the given filename. Before +creating the new key, make sure the filename does not exist and is writable. This will need a good source of entropy to +generate a key, so as part of your plan, recommend one. \ No newline at end of file diff --git a/src/error.rs b/src/error.rs index 103d188..d90000b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -17,6 +17,8 @@ pub enum DrawerError { EncryptFailed, DecryptFailed(PathBuf, PathBuf), UntarFailed, + KeyOutputFailed(PathBuf), + KeyGenerateFailed, } impl fmt::Display for DrawerError { @@ -24,7 +26,7 @@ impl fmt::Display for DrawerError { match self { DrawerError::ParseError(s) => write!(f, "{s}"), DrawerError::NoKeyPath => write!(f, "no key specified: set $DRAWER_KEY or use -i"), - DrawerError::DrawerFileInvalidExtension(p) => write!(f, "drawer file must have a .drawer extension: {}", p.display()), + DrawerError::DrawerFileInvalidExtension(p) => write!(f, "drawer file must end in .drawer: {}", p.display()), DrawerError::DrawerFileNotFound(p) => write!(f, "drawer file not found: {}", p.display()), DrawerError::TargetInvalid(p) => write!(f, "target path does not exist, is not a directory, or is not readable: {}", p.display()), DrawerError::KeyInvalid(p) => write!(f, "key not found or not readable: {}", p.display()), @@ -36,6 +38,8 @@ impl fmt::Display for DrawerError { DrawerError::EncryptFailed => write!(f, "encryption failed"), DrawerError::DecryptFailed(drawer, key) => write!(f, "failed to decrypt {} with key {}", drawer.display(), key.display()), DrawerError::UntarFailed => write!(f, "drawer file does not contain a valid archive"), + DrawerError::KeyOutputFailed(p) => write!(f, "cannot create key file: {} (already exists or directory is not writable)", p.display()), + DrawerError::KeyGenerateFailed => write!(f, "failed to generate key"), } } } diff --git a/src/main.rs b/src/main.rs index 63e622c..906a223 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,7 @@ mod tests { drawer_file: "blah.drawer".into(), key: Path("foo.key".into()), target_path: "blah".into(), + target_inferred: false, force: false, } )) @@ -45,6 +46,22 @@ mod tests { drawer_file: "blah.drawer".into(), key: Path("foo.key".into()), target_path: "blah".into(), + target_inferred: false, + force: false, + } + )) + } + + #[test] + fn test_inferred_open() { + let op = parse_args(vec!["drawer", "open", "blah.drawer", "-i", "foo.key"]); + assert_eq!(op, Ok( + DrawerOperation { + operation_type: Open, + drawer_file: "blah.drawer".into(), + key: Path("foo.key".into()), + target_path: "blah".into(), + target_inferred: true, force: false, } )) diff --git a/src/parsing.rs b/src/parsing.rs index 0df4500..3352f4f 100644 --- a/src/parsing.rs +++ b/src/parsing.rs @@ -6,6 +6,7 @@ use flate2::Compression; use flate2::read::GzDecoder; use flate2::write::GzEncoder; use clap::{Parser, Subcommand}; +use ssh_key::{Algorithm, LineEnding, PrivateKey}; use crate::error::DrawerError; use crate::parsing::KeyType::*; @@ -13,6 +14,7 @@ use crate::parsing::KeyType::*; pub enum OperationType { Open, Close, + Key, } #[derive(Debug, PartialEq)] @@ -22,6 +24,7 @@ pub struct DrawerOperation { pub key: KeyType, pub target_path: PathBuf, pub force: bool, + pub target_inferred: bool, } #[derive(Debug, PartialEq)] @@ -52,6 +55,9 @@ enum Command { #[arg(short = 'i')] key: Option, }, + Key { + filename: PathBuf, + }, } pub fn parse_args(args: I) -> Result @@ -65,15 +71,17 @@ where match cli.command { Command::Open { drawer_file, target_path, key, force } => { let key = resolve_key(key); - let target = target_path.unwrap_or_else(|| { - PathBuf::from(drawer_file.file_stem().unwrap_or_default()) - }); + let (target, target_inferred) = match target_path { + Some(p) => (p, false), + None => (PathBuf::from(drawer_file.file_stem().unwrap_or_default()), true), + }; Ok(DrawerOperation { operation_type: OperationType::Open, drawer_file, key, target_path: target, force, + target_inferred, }) } Command::Close { drawer_file, target_path, key } => { @@ -85,6 +93,17 @@ where key, target_path: target, force: false, + target_inferred: false, + }) + } + Command::Key { filename } => { + Ok(DrawerOperation { + operation_type: OperationType::Key, + drawer_file: filename, + key: KeyType::EnvVar, + target_path: PathBuf::from("."), + force: false, + target_inferred: false, }) } } @@ -103,10 +122,14 @@ impl DrawerOperation { match self.operation_type { OperationType::Open => self.perform_open(), OperationType::Close => self.perform_close(), + OperationType::Key => self.perform_key(), } } fn perform_open(&self) -> Result<(), DrawerError> { + if self.target_inferred { + println!("(assuming into {})", self.target_path.display()); + } let encrypted = std::fs::read(&self.drawer_file) .map_err(|_| DrawerError::DrawerFileNotFound(self.drawer_file.clone()))?; @@ -179,16 +202,39 @@ impl DrawerOperation { } pub fn validate(&self) -> Result<(), DrawerError> { - if self.drawer_file.extension().and_then(|e| e.to_str()) != Some("drawer") { - return Err(DrawerError::DrawerFileInvalidExtension(self.drawer_file.clone())); + if self.operation_type != OperationType::Key { + if self.drawer_file.extension().and_then(|e| e.to_str()) != Some("drawer") { + return Err(DrawerError::DrawerFileInvalidExtension(self.drawer_file.clone())); + } + self.validate_key()?; } - self.validate_key()?; match self.operation_type { OperationType::Open => self.validate_open(), OperationType::Close => self.validate_close(), + OperationType::Key => self.validate_key_gen(), } } + fn validate_key_gen(&self) -> Result<(), DrawerError> { + if self.drawer_file.exists() { + return Err(DrawerError::KeyOutputFailed(self.drawer_file.clone())); + } + let parent = match self.drawer_file.parent() { + None => Path::new("."), + Some(p) => { + if p == Path::new("") { + Path::new(".") + } else { + p + } + }, + }; + if !parent.exists() { + return Err(DrawerError::KeyOutputFailed(self.drawer_file.clone())); + } + Ok(()) + } + fn key_path(&self) -> Result { match &self.key { EnvVar => { @@ -225,6 +271,15 @@ impl DrawerOperation { Ok(()) } + fn perform_key(&self) -> Result<(), DrawerError> { + let private_key = PrivateKey::random(&mut rand::rngs::OsRng, Algorithm::Ed25519) + .map_err(|_| DrawerError::KeyGenerateFailed)?; + let pem = private_key.to_openssh(LineEnding::LF) + .map_err(|_| DrawerError::KeyGenerateFailed)?; + std::fs::write(&self.drawer_file, pem.as_bytes()) + .map_err(|_| DrawerError::KeyOutputFailed(self.drawer_file.clone())) + } + fn validate_close(&self) -> Result<(), DrawerError> { let meta = std::fs::metadata(&self.target_path) .map_err(|_| DrawerError::TargetInvalid(self.target_path.clone()))?;