Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

rage-mount-dir: OverlayFS that transparently decrypts age-encrypted files #233

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
rage-mount-dir: Transparently decrypt files with provided identities
  • Loading branch information
str4d committed Aug 28, 2021
commit 6794269e709853086b060fffdba6b29cb15777a7
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