diff --git a/Cargo.lock b/Cargo.lock index f582bfd8..a294bb93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -604,6 +604,16 @@ dependencies = [ "cipher", ] +[[package]] +name = "ctrlc" +version = "3.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b57a92e9749e10f25a171adcebfafe72991d45e7ec2dcb853e8f83d9dafaeb08" +dependencies = [ + "nix", + "winapi", +] + [[package]] name = "curve25519-dalek" version = "3.0.2" @@ -669,7 +679,7 @@ checksum = "0c122a393ea57648015bf06fbd3d372378992e86b9ff5a7a497b076a28c79efe" dependencies = [ "cfg-if 1.0.0", "libc", - "redox_syscall", + "redox_syscall 0.1.57", "winapi", ] @@ -1229,6 +1239,18 @@ dependencies = [ "adler32", ] +[[package]] +name = "nix" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83450fe6a6142ddd95fb064b746083fc4ef1705fe81f64a64e1d4b39f54a1055" +dependencies = [ + "bitflags", + "cc", + "cfg-if 0.1.10", + "libc", +] + [[package]] name = "nom" version = "6.0.1" @@ -1405,7 +1427,7 @@ dependencies = [ "cfg-if 1.0.0", "instant", "libc", - "redox_syscall", + "redox_syscall 0.1.57", "smallvec", "winapi", ] @@ -1607,6 +1629,7 @@ dependencies = [ "clap 3.0.0-beta.2", "clap_generate", "console", + "ctrlc", "env_logger", "flate2", "fuse_mt", @@ -1617,10 +1640,12 @@ dependencies = [ "libc", "log 0.4.13", "man", + "nix", "pinentry", "rust-embed", "secrecy", "tar", + "tempfile", "time", "zip", ] @@ -1633,9 +1658,9 @@ checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" dependencies = [ "getrandom 0.1.16", "libc", - "rand_chacha", + "rand_chacha 0.2.2", "rand_core 0.5.1", - "rand_hc", + "rand_hc 0.2.0", ] [[package]] @@ -1644,7 +1669,10 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c24fcd450d3fa2b592732565aa4f17a27a61c65ece4726353e000939b0edee34" dependencies = [ + "libc", + "rand_chacha 0.3.0", "rand_core 0.6.1", + "rand_hc 0.3.0", ] [[package]] @@ -1657,6 +1685,16 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e12735cf05c9e10bf21534da50a147b924d555dc7a547c42e6bb2d5b6017ae0d" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.1", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -1684,6 +1722,15 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rand_hc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3190ef7066a446f2e7f42e239d161e905420ccab01eb967c9eb27d21b2322a73" +dependencies = [ + "rand_core 0.6.1", +] + [[package]] name = "rayon" version = "1.5.0" @@ -1715,6 +1762,15 @@ version = "0.1.57" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" +[[package]] +name = "redox_syscall" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ec8ca9416c5ea37062b502703cd7fcb207736bc294f6e0cf367ac6fc234570" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.4.3" @@ -1742,6 +1798,15 @@ version = "0.6.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5eb417147ba9860a96cfe72a0b93bf88fee1744b5636ec99ab20c1aa9376581" +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi", +] + [[package]] name = "roff" version = "0.1.0" @@ -2026,10 +2091,24 @@ checksum = "489997b7557e9a43e192c527face4feacc78bfbe6eed67fd55c4c9e381cba290" dependencies = [ "filetime", "libc", - "redox_syscall", + "redox_syscall 0.1.57", "xattr", ] +[[package]] +name = "tempfile" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dac1c663cfc93810f88aed9b8941d48cabf856a1b111c29a40439018d870eb22" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "rand 0.8.1", + "redox_syscall 0.2.4", + "remove_dir_all", + "winapi", +] + [[package]] name = "termcolor" version = "1.1.2" diff --git a/rage/Cargo.toml b/rage/Cargo.toml index 9cb91bca..9489bb2f 100644 --- a/rage/Cargo.toml +++ b/rage/Cargo.toml @@ -57,9 +57,12 @@ rust-embed = "5" secrecy = "0.7" # rage-mount dependencies +ctrlc = { version = "3.1.7", optional = true } fuse_mt = { version = "0.5.1", optional = true } libc = { version = "0.2", optional = true } +nix = { version = "0.18", optional = true } tar = { version = "0.4", optional = true } +tempfile = { version = "3.2", optional = true } time = { version = "0.1", optional = true } zip = { version = "0.5.9", optional = true } @@ -71,7 +74,7 @@ man = "0.3" [features] default = ["ssh"] -mount = ["fuse_mt", "libc", "tar", "time", "zip"] +mount = ["ctrlc", "fuse_mt", "libc", "nix", "tar", "tempfile", "time", "zip"] ssh = ["age/ssh"] unstable = ["age/unstable"] diff --git a/rage/examples/generate-docs.rs b/rage/examples/generate-docs.rs index 602b06a7..20e7e012 100644 --- a/rage/examples/generate-docs.rs +++ b/rage/examples/generate-docs.rs @@ -176,7 +176,7 @@ fn rage_mount_page() { Flag::new() .short("-t") .long("--types") - .help("The type of the filesystem (one of \"tar\", \"zip\")."), + .help("The type of the filesystem (one of \"file\", \"tar\", \"zip\")."), ) .option( Opt::new("IDENTITY") @@ -186,6 +186,11 @@ fn rage_mount_page() { ) .arg(Arg::new("filename")) .arg(Arg::new("mountpoint")) + .example( + Example::new() + .text("Mounting an encrypted file to a recipient") + .command("rage-mount -t file -i key.txt encrypted.txt.age decrypted.txt"), + ) .example( Example::new() .text("Mounting an archive encrypted to a recipient") diff --git a/rage/i18n/en-US/rage.ftl b/rage/i18n/en-US/rage.ftl index a9b278da..8ac39efa 100644 --- a/rage/i18n/en-US/rage.ftl +++ b/rage/i18n/en-US/rage.ftl @@ -134,6 +134,7 @@ info-mounting-as-fuse = Mounting as FUSE filesystem err-mnt-missing-filename = Missing filename. err-mnt-missing-mountpoint = Missing mountpoint. err-mnt-missing-types = Missing {-flag-mnt-types}. +err-mnt-must-be-file = Mountpoint must be a file. err-mnt-unknown-type = Unknown filesystem type "{$fs_type}" ## Unstable features diff --git a/rage/src/bin/rage-mount/file.rs b/rage/src/bin/rage-mount/file.rs new file mode 100644 index 00000000..93190823 --- /dev/null +++ b/rage/src/bin/rage-mount/file.rs @@ -0,0 +1,203 @@ +use age::{armor::ArmoredReader, stream::StreamReader}; +use fuse_mt::*; +use log::error; +use std::ffi::OsString; +use std::fs::{File, Metadata}; +use std::io::{self, BufReader, Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::SystemTime; +use time::Timespec; + +const ROOT_HANDLE: u64 = 0; +const FILE_HANDLE: u64 = 1; + +const ROOT_ATTR: FileAttr = FileAttr { + size: 0, + blocks: 0, + atime: Timespec { sec: 0, nsec: 0 }, + mtime: Timespec { sec: 0, nsec: 0 }, + ctime: Timespec { sec: 0, nsec: 0 }, + crtime: Timespec { sec: 0, nsec: 0 }, + kind: FileType::Directory, + perm: 0o0755, + nlink: 1, + uid: 1000, + gid: 1000, + rdev: 0, + flags: 0, +}; + +pub struct AgeFileFs { + inner: Mutex>>>, + file_name: OsString, + file_attr: FileAttr, +} + +impl AgeFileFs { + pub fn open( + mut stream: StreamReader>>, + metadata: Metadata, + file_name: OsString, + ) -> io::Result { + let size = stream.seek(SeekFrom::End(0))?; + + let timespec = |t: SystemTime| { + t.duration_since(SystemTime::UNIX_EPOCH) + .map(|t| Timespec::new(t.as_secs() as i64, t.subsec_nanos() as i32)) + .unwrap() + }; + + let mtime = metadata.modified().map(timespec)?; + let ctime = metadata.created().map(timespec).unwrap_or(mtime); + let atime = metadata.accessed().map(timespec).unwrap_or(mtime); + + Ok(AgeFileFs { + inner: Mutex::new(stream), + file_name, + file_attr: FileAttr { + size, + blocks: 1, + atime, + mtime, + ctime, + crtime: ctime, + kind: FileType::RegularFile, + perm: 0o0444, + nlink: 1, + uid: 1000, + gid: 1000, + rdev: 0, + flags: 0, + }, + }) + } +} + +const TTL: Timespec = Timespec { sec: 1, nsec: 0 }; + +impl FilesystemMT for AgeFileFs { + fn getattr(&self, _req: RequestInfo, path: &Path, fh: Option) -> ResultEntry { + if let Some(fh) = fh { + match fh { + ROOT_HANDLE => Ok((TTL, ROOT_ATTR)), + FILE_HANDLE => Ok((TTL, self.file_attr)), + _ => Err(libc::EBADF), + } + } else { + let root = Path::new("/"); + if path == root { + Ok((TTL, ROOT_ATTR)) + } else if path == root.join(&self.file_name) { + Ok((TTL, self.file_attr)) + } else { + Err(libc::ENOENT) + } + } + } + + fn opendir(&self, _req: RequestInfo, _path: &Path, _flags: u32) -> ResultOpen { + Ok((ROOT_HANDLE, 0)) + } + + fn readdir(&self, _req: RequestInfo, _path: &Path, fh: u64) -> ResultReaddir { + if fh == ROOT_HANDLE { + Ok(vec![DirectoryEntry { + name: self.file_name.clone(), + kind: FileType::RegularFile, + }]) + } else { + Err(libc::EBADF) + } + } + + fn releasedir(&self, _req: RequestInfo, _path: &Path, _fh: u64, _flags: u32) -> ResultEmpty { + Ok(()) + } + + fn statfs(&self, _req: RequestInfo, _path: &Path) -> ResultStatfs { + Ok(Statfs { + blocks: 1, + bfree: 0, + bavail: 0, + files: 1, + ffree: 0, + bsize: 64 * 1024, + namelen: u32::max_value(), + frsize: 64 * 1024, + }) + } + + fn open(&self, _req: RequestInfo, _path: &Path, _flags: u32) -> ResultOpen { + Ok((FILE_HANDLE, 0)) + } + + fn read( + &self, + _req: RequestInfo, + _path: &Path, + fh: u64, + offset: u64, + size: u32, + callback: impl FnOnce(ResultSlice<'_>) -> CallbackResult, + ) -> CallbackResult { + let mut inner = self.inner.lock().unwrap(); + + if fh == FILE_HANDLE { + if offset > self.file_attr.size { + return callback(Err(libc::EINVAL)); + } + + // Skip to offset + if inner.seek(SeekFrom::Start(offset)).is_err() { + return callback(Err(libc::EIO)); + } + + // Read bytes + let to_read = usize::min(size as usize, (self.file_attr.size - offset) as usize); + let mut buf = vec![]; + buf.resize(to_read, 0); + match inner.read_exact(&mut buf) { + Ok(_) => callback(Ok(&buf)), + Err(_) => callback(Err(libc::EIO)), + } + } else { + callback(Err(libc::EBADF)) + } + } + + fn release( + &self, + _req: RequestInfo, + _path: &Path, + _fh: u64, + _flags: u32, + _lock_owner: u64, + _flush: bool, + ) -> ResultEmpty { + Ok(()) + } +} + +pub struct AgeFileLink { + link: PathBuf, +} + +impl AgeFileLink { + pub fn new(target: &Path, link: PathBuf) -> nix::Result { + nix::unistd::symlinkat(target, None, &link)?; + Ok(AgeFileLink { link }) + } +} + +impl Drop for AgeFileLink { + fn drop(&mut self) { + if let Err(e) = nix::unistd::unlink(&self.link) { + error!( + "Failed to remove symbolic link {}: {}", + self.link.to_string_lossy(), + e + ); + }; + } +} diff --git a/rage/src/bin/rage-mount/main.rs b/rage/src/bin/rage-mount/main.rs index c6819b86..1e703b52 100644 --- a/rage/src/bin/rage-mount/main.rs +++ b/rage/src/bin/rage-mount/main.rs @@ -1,5 +1,3 @@ -#![forbid(unsafe_code)] - use age::{ armor::ArmoredReader, cli_common::{read_identities, read_secret}, @@ -16,9 +14,16 @@ use log::{error, info}; use rust_embed::RustEmbed; use std::ffi::OsStr; use std::fmt; -use std::fs::File; +use std::fs::{File, Metadata}; use std::io; +use std::path::PathBuf; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use std::thread; +mod file; mod tar; mod zip; @@ -58,6 +63,8 @@ enum Error { MissingIdentities, MissingMountpoint, MissingType, + MountpointMustBeFile, + Nix(nix::Error), UnknownType(String), UnsupportedKey(String, age::ssh::UnsupportedKey), } @@ -74,6 +81,12 @@ impl From for Error { } } +impl From for Error { + fn from(e: nix::Error) -> Self { + Error::Nix(e) + } +} + // Rust only supports `fn main() -> Result<(), E: Debug>`, so we implement `Debug` // manually to provide the error output we want. impl fmt::Debug for Error { @@ -111,6 +124,8 @@ impl fmt::Debug for Error { } Error::MissingMountpoint => wfl!(f, "err-mnt-missing-mountpoint"), Error::MissingType => wfl!(f, "err-mnt-missing-types"), + Error::MountpointMustBeFile => wfl!(f, "err-mnt-must-be-file"), + Error::Nix(e) => write!(f, "{}", e), Error::UnknownType(t) => write!( f, "{}", @@ -135,10 +150,10 @@ impl fmt::Debug for Error { #[derive(Debug, Options)] struct AgeMountOptions { - #[options(free, help = "The encrypted filesystem to mount.")] + #[options(free, help = "The encrypted file to mount.")] filename: String, - #[options(free, help = "The directory to mount the filesystem at.")] + #[options(free, help = "The path to mount at.")] mountpoint: String, #[options(help = "Print this help message and exit.")] @@ -147,7 +162,7 @@ struct AgeMountOptions { #[options(help = "Print version info and exit.", short = "V")] version: bool, - #[options(help = "Indicates the filesystem type (one of \"tar\", \"zip\").")] + #[options(help = "Indicates the mount type (one of \"file\", \"tar\", \"zip\").")] types: String, #[options( @@ -161,6 +176,46 @@ struct AgeMountOptions { identity: Vec, } +fn mount_file( + stream: StreamReader>>, + metadata: Metadata, + mountpoint: String, +) -> Result<(), Error> { + let mountpoint = PathBuf::from(mountpoint); + let file_name = mountpoint.file_name().ok_or(Error::MountpointMustBeFile)?; + + // Create a temporary directory for the single-file filesystem. + let tmp_dir = tempfile::tempdir()?; + + info!("{}", fl!("info-mounting-as-fuse")); + let filesystem = crate::file::AgeFileFs::open(stream, metadata, file_name.to_os_string()) + .map(|fs| fuse_mt::FuseMT::new(fs, 1))?; + let options: &[&OsStr] = &[&OsStr::new("-o"), &OsStr::new("ro,auto_unmount")]; + // I don't understand why this is unsafe, given that this seems to be the only way to + // safely unmount the FUSE filesystem on interrupt. + let _session = unsafe { fuse_mt::spawn_mount(filesystem, &tmp_dir.path(), options)? }; + + // Now that FUSE is set up, link the plaintext file to the target. + let _link = crate::file::AgeFileLink::new(&tmp_dir.path().join(file_name), mountpoint); + + // Set up Ctrl+C handling. + let running = Arc::new(AtomicBool::new(true)); + let r = running.clone(); + let t = thread::current(); + ctrlc::set_handler(move || { + r.store(false, Ordering::SeqCst); + t.unpark(); + }) + .expect("Error setting Ctrl-C handler"); + + // Wait for shutdown. + while running.load(Ordering::SeqCst) { + thread::park(); + } + + Ok(()) +} + fn mount_fs(open: F, mountpoint: String) where F: FnOnce() -> io::Result, @@ -182,10 +237,12 @@ where fn mount_stream( stream: StreamReader>>, + metadata: Metadata, types: String, mountpoint: String, ) -> Result<(), Error> { match types.as_str() { + "file" => mount_file(stream, metadata, mountpoint)?, "tar" => mount_fs(|| crate::tar::AgeTarFs::open(stream), mountpoint), "zip" => mount_fs(|| crate::zip::AgeZipFs::open(stream), mountpoint), _ => { @@ -246,6 +303,7 @@ fn main() -> Result<(), Error> { ) ); let file = File::open(opts.filename)?; + let metadata = file.metadata()?; let types = opts.types; let mountpoint = opts.mountpoint; @@ -256,7 +314,7 @@ fn main() -> Result<(), Error> { Ok(passphrase) => decryptor .decrypt(&passphrase, opts.max_work_factor) .map_err(|e| e.into()) - .and_then(|stream| mount_stream(stream, types, mountpoint)), + .and_then(|stream| mount_stream(stream, metadata, types, mountpoint)), Err(_) => Ok(()), } } @@ -274,7 +332,7 @@ fn main() -> Result<(), Error> { decryptor .decrypt(identities.into_iter()) .map_err(|e| e.into()) - .and_then(|stream| mount_stream(stream, types, mountpoint)) + .and_then(|stream| mount_stream(stream, metadata, types, mountpoint)) } } }