Skip to content

Commit

Permalink
rage-mount-dir: Transparently decrypt files with provided identities
Browse files Browse the repository at this point in the history
  • Loading branch information
str4d committed Aug 28, 2021
1 parent a63c28c commit 6794269
Show file tree
Hide file tree
Showing 7 changed files with 311 additions and 14 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion age-core/src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ impl<'a> 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,
Expand Down
3 changes: 2 additions & 1 deletion rage/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ rust-embed = "5"
secrecy = "0.8"

# 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.20", optional = true }
Expand All @@ -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"]

Expand Down
62 changes: 61 additions & 1 deletion rage/src/bin/rage-mount-dir/main.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use age::cli_common::read_identities;
use fuse_mt::FilesystemMT;
use gumdrop::Options;
use i18n_embed::{
Expand All @@ -13,7 +14,9 @@ use std::io;
use std::path::PathBuf;

mod overlay;
mod reader;
mod util;
mod wrapper;

#[derive(RustEmbed)]
#[folder = "i18n"]
Expand All @@ -37,14 +40,24 @@ macro_rules! wfl {
};
}

macro_rules! wlnfl {
($f:ident, $message_id:literal) => {
writeln!($f, "{}", fl!($message_id))
};
}

enum Error {
Age(age::DecryptError),
IdentityEncryptedWithoutPassphrase(String),
IdentityNotFound(String),
Io(io::Error),
MissingIdentities,
MissingMountpoint,
MissingSource,
MountpointMustBeDir,
Nix(nix::Error),
SourceMustBeDir,
UnsupportedKey(String, age::ssh::UnsupportedKey),
}

impl From<age::DecryptError> for Error {
Expand Down Expand Up @@ -85,12 +98,37 @@ impl fmt::Debug for Error {
}
_ => write!(f, "{}", e),
},
Error::IdentityEncryptedWithoutPassphrase(filename) => {
write!(
f,
"{}",
i18n_embed_fl::fl!(
LANGUAGE_LOADER,
"err-dec-identity-encrypted-without-passphrase",
filename = filename.as_str()
)
)
}
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"))?;
Expand All @@ -116,6 +154,16 @@ struct AgeMountOptions {

#[options(help = "Print version info and exit.", short = "V")]
version: bool,

#[options(
help = "Maximum work factor to allow for passphrase decryption.",
meta = "WF",
no_short
)]
max_work_factor: Option<u8>,

#[options(help = "Use the identity file at IDENTITY. May be repeated.")]
identity: Vec<String>,
}

fn mount_fs<T: FilesystemMT + Send + Sync + 'static, F>(open: F, mountpoint: PathBuf)
Expand Down Expand Up @@ -185,8 +233,20 @@ fn main() -> Result<(), Error> {
return Err(Error::MountpointMustBeDir);
}

let identities = read_identities(
opts.identity,
opts.max_work_factor,
Error::IdentityNotFound,
Error::IdentityEncryptedWithoutPassphrase,
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(())
Expand Down
90 changes: 79 additions & 11 deletions rage/src/bin/rage-mount-dir/overlay.rs
Original file line number Diff line number Diff line change
@@ -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<Box<dyn Identity + Send + Sync>>,
age_files: Mutex<HashMap<PathBuf, (PathBuf, Option<AgeFile>)>>,
open_dirs: Mutex<HashMap<u64, Dir>>,
open_files: Mutex<HashMap<u64, File>>,
open_files: Mutex<HashMap<u64, OpenedFile>>,
}

impl AgeOverlayFs {
pub fn new(root: PathBuf) -> io::Result<Self> {
pub fn new(
root: PathBuf,
identities: Vec<Box<dyn Identity + Send + Sync>>,
) -> io::Result<Self> {
// 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()),
})
Expand All @@ -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<u64>) -> 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))
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)?;

Expand All @@ -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 })
})
})
Expand Down
70 changes: 70 additions & 0 deletions rage/src/bin/rage-mount-dir/reader.rs
Original file line number Diff line number Diff line change
@@ -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<File>,
handle: u64,
},
}

impl OpenedFile {
pub(crate) fn normal(path: &Path) -> io::Result<Self> {
File::open(path).map(OpenedFile::Normal)
}

pub(crate) fn age(path: &Path, age_file: &AgeFile) -> io::Result<Self> {
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<usize> {
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<u64> {
match self {
OpenedFile::Normal(file) => file.seek(pos),
OpenedFile::Age { reader, .. } => reader.seek(pos),
}
}
}
Loading

0 comments on commit 6794269

Please sign in to comment.