From 943a628e53fb7129a8335c1988540a14c1a3a5c2 Mon Sep 17 00:00:00 2001 From: Jack Grigg Date: Sat, 13 Feb 2021 17:01:44 +0000 Subject: [PATCH] rage-mount-dir: Transparently decrypt files with provided identities --- Cargo.lock | 1 + age-core/src/format.rs | 2 +- rage/Cargo.toml | 3 +- rage/src/bin/rage-mount-dir/main.rs | 41 ++++++++++- rage/src/bin/rage-mount-dir/overlay.rs | 90 +++++++++++++++++++++--- rage/src/bin/rage-mount-dir/reader.rs | 70 +++++++++++++++++++ rage/src/bin/rage-mount-dir/wrapper.rs | 97 ++++++++++++++++++++++++++ 7 files changed, 290 insertions(+), 14 deletions(-) create mode 100644 rage/src/bin/rage-mount-dir/reader.rs create mode 100644 rage/src/bin/rage-mount-dir/wrapper.rs diff --git a/Cargo.lock b/Cargo.lock index f3ef3edd..17fe1113 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1604,6 +1604,7 @@ name = "rage" version = "0.6.0" dependencies = [ "age", + "age-core", "chrono", "clap 3.0.0-beta.2", "clap_generate", diff --git a/age-core/src/format.rs b/age-core/src/format.rs index bb724a8e..956c6c6f 100644 --- a/age-core/src/format.rs +++ b/age-core/src/format.rs @@ -43,7 +43,7 @@ pub struct AgeStanza<'a> { /// recipient. /// /// This is the owned type; see [`AgeStanza`] for the reference type. -#[derive(Debug, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub struct Stanza { /// A tag identifying this stanza type. pub tag: String, diff --git a/rage/Cargo.toml b/rage/Cargo.toml index 683cbea6..1f2fdb89 100644 --- a/rage/Cargo.toml +++ b/rage/Cargo.toml @@ -57,6 +57,7 @@ rust-embed = "5" secrecy = "0.7" # rage-mount dependencies +age-core = { version = "0.6.0", path = "../age-core", optional = true } fuse_mt = { version = "0.5.1", optional = true } libc = { version = "0.2", optional = true } nix = { version = "0.19", optional = true } @@ -72,7 +73,7 @@ man = "0.3" [features] default = ["ssh"] -mount = ["fuse_mt", "libc", "nix", "tar", "time", "zip"] +mount = ["age-core", "fuse_mt", "libc", "nix", "tar", "time", "zip"] ssh = ["age/ssh"] unstable = ["age/unstable"] diff --git a/rage/src/bin/rage-mount-dir/main.rs b/rage/src/bin/rage-mount-dir/main.rs index 752c6623..dfa6f7cf 100644 --- a/rage/src/bin/rage-mount-dir/main.rs +++ b/rage/src/bin/rage-mount-dir/main.rs @@ -1,3 +1,4 @@ +use age::cli_common::read_identities; use fuse_mt::FilesystemMT; use gumdrop::Options; use i18n_embed::{ @@ -13,7 +14,9 @@ use std::io; use std::path::PathBuf; mod overlay; +mod reader; mod util; +mod wrapper; #[derive(RustEmbed)] #[folder = "i18n"] @@ -37,14 +40,23 @@ macro_rules! wfl { }; } +macro_rules! wlnfl { + ($f:ident, $message_id:literal) => { + writeln!($f, "{}", fl!($message_id)) + }; +} + enum Error { Age(age::DecryptError), + IdentityNotFound(String), Io(io::Error), + MissingIdentities, MissingMountpoint, MissingSource, MountpointMustBeDir, Nix(nix::Error), SourceMustBeDir, + UnsupportedKey(String, age::ssh::UnsupportedKey), } impl From for Error { @@ -85,12 +97,26 @@ impl fmt::Debug for Error { } _ => write!(f, "{}", e), }, + Error::IdentityNotFound(filename) => write!( + f, + "{}", + i18n_embed_fl::fl!( + LANGUAGE_LOADER, + "err-dec-identity-not-found", + filename = filename.as_str() + ) + ), Error::Io(e) => write!(f, "{}", e), + Error::MissingIdentities => { + wlnfl!(f, "err-dec-missing-identities")?; + wlnfl!(f, "rec-dec-missing-identities") + } Error::MissingMountpoint => wfl!(f, "err-mnt-missing-mountpoint"), Error::MissingSource => wfl!(f, "err-mnt-missing-source"), Error::MountpointMustBeDir => wfl!(f, "err-mnt-must-be-dir"), Error::Nix(e) => write!(f, "{}", e), Error::SourceMustBeDir => wfl!(f, "err-mnt-source-must-be-dir"), + Error::UnsupportedKey(filename, k) => k.display(f, Some(filename.as_str())), }?; writeln!(f)?; writeln!(f, "[ {} ]", fl!("err-ux-A"))?; @@ -116,6 +142,9 @@ struct AgeMountOptions { #[options(help = "Print version info and exit.", short = "V")] version: bool, + + #[options(help = "Use the identity file at IDENTITY. May be repeated.")] + identity: Vec, } fn mount_fs(open: F, mountpoint: PathBuf) @@ -185,8 +214,18 @@ fn main() -> Result<(), Error> { return Err(Error::MountpointMustBeDir); } + let identities = read_identities( + opts.identity, + Error::IdentityNotFound, + Error::UnsupportedKey, + )?; + + if identities.is_empty() { + return Err(Error::MissingIdentities); + } + mount_fs( - || crate::overlay::AgeOverlayFs::new(directory.into()), + || crate::overlay::AgeOverlayFs::new(directory.into(), identities), mountpoint, ); Ok(()) diff --git a/rage/src/bin/rage-mount-dir/overlay.rs b/rage/src/bin/rage-mount-dir/overlay.rs index 1bc895c5..3dd8ea5b 100644 --- a/rage/src/bin/rage-mount-dir/overlay.rs +++ b/rage/src/bin/rage-mount-dir/overlay.rs @@ -1,26 +1,40 @@ use std::collections::HashMap; use std::ffi::OsStr; -use std::fs::File; use std::io::{self, Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::sync::Mutex; +use age::Identity; use fuse_mt::*; use nix::{dir::Dir, fcntl::OFlag, libc, sys::stat::Mode, unistd::AccessFlags}; use time::Timespec; -use crate::util::*; +use crate::{ + reader::OpenedFile, + util::*, + wrapper::{check_file, AgeFile}, +}; pub struct AgeOverlayFs { root: PathBuf, + identities: Vec>, + age_files: Mutex)>>, open_dirs: Mutex>, - open_files: Mutex>, + open_files: Mutex>, } impl AgeOverlayFs { - pub fn new(root: PathBuf) -> io::Result { + pub fn new( + root: PathBuf, + identities: Vec>, + ) -> io::Result { + // TODO: Scan the directory to find age-encrypted files, and trial-decrypt them. + // We'll do this manually in order to cache the unwrapped FileKeys for X? minutes. + Ok(AgeOverlayFs { root, + identities, + age_files: Mutex::new(HashMap::new()), open_dirs: Mutex::new(HashMap::new()), open_files: Mutex::new(HashMap::new()), }) @@ -29,19 +43,38 @@ impl AgeOverlayFs { fn base_path(&self, path: &Path) -> PathBuf { self.root.join(path.strip_prefix("/").unwrap()) } + + fn age_stat(&self, f: &AgeFile, mut stat: FileAttr) -> FileAttr { + stat.size = f.size; + stat + } } const TTL: Timespec = Timespec { sec: 1, nsec: 0 }; impl FilesystemMT for AgeOverlayFs { fn getattr(&self, _req: RequestInfo, path: &Path, fh: Option) -> ResultEntry { + let age_files = self.age_files.lock().unwrap(); + let base_path = self.base_path(path); + let (query_path, age_file) = match age_files.get(&base_path) { + Some((real_path, Some(f))) => (real_path, Some(f)), + _ => (&base_path, None), + }; + use std::os::unix::io::RawFd; nix_err(if let Some(fd) = fh { nix::sys::stat::fstat(fd as RawFd) } else { - nix::sys::stat::lstat(&self.base_path(path)) + nix::sys::stat::lstat(query_path) }) .map(nix_stat) + .map(|stat| { + if let Some(f) = age_file { + self.age_stat(f, stat) + } else { + stat + } + }) .map(|stat| (TTL, stat)) } @@ -155,10 +188,14 @@ impl FilesystemMT for AgeOverlayFs { } fn open(&self, _req: RequestInfo, path: &Path, _flags: u32) -> ResultOpen { - use std::os::unix::io::AsRawFd; - - let file = File::open(self.base_path(path)).map_err(|e| e.raw_os_error().unwrap_or(0))?; - let fh = file.as_raw_fd() as u64; + let age_files = self.age_files.lock().unwrap(); + let base_path = self.base_path(path); + let file = match age_files.get(&base_path) { + Some((real_path, Some(f))) => OpenedFile::age(real_path, f), + _ => OpenedFile::normal(&base_path), + } + .map_err(|e| e.raw_os_error().unwrap_or(0))?; + let fh = file.handle(); let mut open_files = self.open_files.lock().unwrap(); open_files.insert(fh, file); @@ -233,9 +270,10 @@ impl FilesystemMT for AgeOverlayFs { Ok((fh, 0)) } - fn readdir(&self, _req: RequestInfo, _path: &Path, fh: u64) -> ResultReaddir { + fn readdir(&self, _req: RequestInfo, path: &Path, fh: u64) -> ResultReaddir { use std::os::unix::ffi::OsStrExt; + let mut age_files = self.age_files.lock().unwrap(); let mut open_dirs = self.open_dirs.lock().unwrap(); let dir = open_dirs.get_mut(&fh).ok_or(libc::EBADF)?; @@ -254,7 +292,37 @@ impl FilesystemMT for AgeOverlayFs { .map(|stat| stat.kind), ) })?; - let name = OsStr::from_bytes(entry.file_name().to_bytes()).to_owned(); + let name = Path::new(OsStr::from_bytes(entry.file_name().to_bytes())); + + let name = match name.extension() { + Some(ext) if ext == "age" => { + let path = self.base_path(path).join(name); + match age_files.get(&path.with_extension("")) { + // We can decrypt this; remove the .age from the filename. + Some((_, Some(_))) => name.to_owned().with_extension("").into(), + // We can't decrypt this; leave the name as-is. + Some((_, None)) => name.into(), + // We haven't seen this .age file; test it! + None => { + let (path, file) = check_file(path, &self.identities) + .map_err(|e| e.raw_os_error().unwrap_or(0))?; + let decrypted = file.is_some(); + + // Remember whether we can decrypt this file! + age_files.insert(path.with_extension(""), (path, file)); + + if decrypted { + // Remove the .age from the filename. + name.to_owned().with_extension("").into() + } else { + name.into() + } + } + } + } + _ => name.into(), + }; + Ok(DirectoryEntry { name, kind }) }) }) diff --git a/rage/src/bin/rage-mount-dir/reader.rs b/rage/src/bin/rage-mount-dir/reader.rs new file mode 100644 index 00000000..f4867c0f --- /dev/null +++ b/rage/src/bin/rage-mount-dir/reader.rs @@ -0,0 +1,70 @@ +use std::fs::File; +use std::io; +use std::path::Path; + +use age::stream::StreamReader; + +use crate::wrapper::AgeFile; + +pub(crate) enum OpenedFile { + Normal(File), + Age { + reader: StreamReader, + handle: u64, + }, +} + +impl OpenedFile { + pub(crate) fn normal(path: &Path) -> io::Result { + File::open(path).map(OpenedFile::Normal) + } + + pub(crate) fn age(path: &Path, age_file: &AgeFile) -> io::Result { + let file = File::open(path)?; + + use std::os::unix::io::AsRawFd; + let handle = file.as_raw_fd() as u64; + + let decryptor = match age::Decryptor::new(file).unwrap() { + age::Decryptor::Recipients(d) => d, + _ => unreachable!(), + }; + let reader = decryptor + .decrypt( + Some(&age_file.file_key) + .into_iter() + .map(|i| i as &dyn age::Identity), + ) + .unwrap(); + + Ok(OpenedFile::Age { reader, handle }) + } + + pub(crate) fn handle(&self) -> u64 { + match self { + OpenedFile::Normal(file) => { + use std::os::unix::io::AsRawFd; + file.as_raw_fd() as u64 + } + OpenedFile::Age { handle, .. } => *handle, + } + } +} + +impl io::Read for OpenedFile { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + match self { + OpenedFile::Normal(file) => file.read(buf), + OpenedFile::Age { reader, .. } => reader.read(buf), + } + } +} + +impl io::Seek for OpenedFile { + fn seek(&mut self, pos: io::SeekFrom) -> io::Result { + match self { + OpenedFile::Normal(file) => file.seek(pos), + OpenedFile::Age { reader, .. } => reader.seek(pos), + } + } +} diff --git a/rage/src/bin/rage-mount-dir/wrapper.rs b/rage/src/bin/rage-mount-dir/wrapper.rs new file mode 100644 index 00000000..6af7ebf1 --- /dev/null +++ b/rage/src/bin/rage-mount-dir/wrapper.rs @@ -0,0 +1,97 @@ +use std::{ + cell::RefCell, + fmt, + fs::File, + io::{self, Seek, SeekFrom}, + path::PathBuf, +}; + +use age::{Decryptor, Identity}; +use age_core::format::{FileKey, Stanza}; +use secrecy::ExposeSecret; + +/// A file key we cached. It is bound to the specific stanza it was unwrapped from. +pub(crate) struct CachedFileKey { + stanza: Stanza, + inner: FileKey, +} + +impl fmt::Debug for CachedFileKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.stanza.fmt(f) + } +} + +impl Identity for CachedFileKey { + fn unwrap_stanza(&self, stanza: &Stanza) -> Option> { + // This compares the entire stanza, including the file key ciphertexts, so we can + // be confident that the wrapped file keys are identical, and thus return this + // cached file key. + if stanza == &self.stanza { + Some(Ok(FileKey::from(*self.inner.expose_secret()))) + } else { + None + } + } +} + +/// A pseudo-identity that caches the first successfully-unwrapped file key. +struct FileKeyCacher<'a> { + identities: &'a [Box], + cache: RefCell>, +} + +impl<'a> FileKeyCacher<'a> { + fn new(identities: &'a [Box]) -> Self { + FileKeyCacher { + identities, + cache: RefCell::new(None), + } + } +} + +impl<'a> Identity for FileKeyCacher<'a> { + fn unwrap_stanza(&self, stanza: &Stanza) -> Option> { + self.identities.iter().find_map(|identity| { + if let Some(Ok(file_key)) = identity.unwrap_stanza(stanza) { + *self.cache.borrow_mut() = Some(CachedFileKey { + stanza: stanza.clone(), + inner: FileKey::from(*file_key.expose_secret()), + }); + Some(Ok(file_key)) + } else { + None + } + }) + } +} + +#[derive(Debug)] +pub(crate) struct AgeFile { + pub(crate) file_key: CachedFileKey, + pub(crate) size: u64, +} + +/// Returns: +/// - Ok((path, Some(_))) if this is an age file we can decrypt. +/// - Ok((path, None)) if this is not an age file, or we can't decrypt it. +pub(crate) fn check_file( + path: PathBuf, + identities: &[Box], +) -> io::Result<(PathBuf, Option)> { + let res = if let Ok(Decryptor::Recipients(d)) = Decryptor::new(File::open(&path)?) { + let cacher = FileKeyCacher::new(identities); + if let Ok(mut r) = d.decrypt(Some(&cacher).into_iter().map(|i| i as &dyn Identity)) { + Some(AgeFile { + file_key: cacher.cache.into_inner().unwrap(), + size: r.seek(SeekFrom::End(0))?, + }) + } else { + None + } + } else { + None + }; + + Ok((path, res)) +}