Skip to content

Commit 6794269

Browse files
committed
rage-mount-dir: Transparently decrypt files with provided identities
1 parent a63c28c commit 6794269

File tree

7 files changed

+311
-14
lines changed

7 files changed

+311
-14
lines changed

Cargo.lock

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

age-core/src/format.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ impl<'a> AgeStanza<'a> {
6666
/// recipient.
6767
///
6868
/// This is the owned type; see [`AgeStanza`] for the reference type.
69-
#[derive(Debug, PartialEq)]
69+
#[derive(Debug, Clone, PartialEq)]
7070
pub struct Stanza {
7171
/// A tag identifying this stanza type.
7272
pub tag: String,

rage/Cargo.toml

+2-1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ rust-embed = "5"
5757
secrecy = "0.8"
5858

5959
# rage-mount dependencies
60+
age-core = { version = "0.6.0", path = "../age-core", optional = true }
6061
fuse_mt = { version = "0.5.1", optional = true }
6162
libc = { version = "0.2", optional = true }
6263
nix = { version = "0.20", optional = true }
@@ -72,7 +73,7 @@ man = "0.3"
7273

7374
[features]
7475
default = ["ssh"]
75-
mount = ["fuse_mt", "libc", "nix", "tar", "time", "zip"]
76+
mount = ["age-core", "fuse_mt", "libc", "nix", "tar", "time", "zip"]
7677
ssh = ["age/ssh"]
7778
unstable = ["age/unstable"]
7879

rage/src/bin/rage-mount-dir/main.rs

+61-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use age::cli_common::read_identities;
12
use fuse_mt::FilesystemMT;
23
use gumdrop::Options;
34
use i18n_embed::{
@@ -13,7 +14,9 @@ use std::io;
1314
use std::path::PathBuf;
1415

1516
mod overlay;
17+
mod reader;
1618
mod util;
19+
mod wrapper;
1720

1821
#[derive(RustEmbed)]
1922
#[folder = "i18n"]
@@ -37,14 +40,24 @@ macro_rules! wfl {
3740
};
3841
}
3942

43+
macro_rules! wlnfl {
44+
($f:ident, $message_id:literal) => {
45+
writeln!($f, "{}", fl!($message_id))
46+
};
47+
}
48+
4049
enum Error {
4150
Age(age::DecryptError),
51+
IdentityEncryptedWithoutPassphrase(String),
52+
IdentityNotFound(String),
4253
Io(io::Error),
54+
MissingIdentities,
4355
MissingMountpoint,
4456
MissingSource,
4557
MountpointMustBeDir,
4658
Nix(nix::Error),
4759
SourceMustBeDir,
60+
UnsupportedKey(String, age::ssh::UnsupportedKey),
4861
}
4962

5063
impl From<age::DecryptError> for Error {
@@ -85,12 +98,37 @@ impl fmt::Debug for Error {
8598
}
8699
_ => write!(f, "{}", e),
87100
},
101+
Error::IdentityEncryptedWithoutPassphrase(filename) => {
102+
write!(
103+
f,
104+
"{}",
105+
i18n_embed_fl::fl!(
106+
LANGUAGE_LOADER,
107+
"err-dec-identity-encrypted-without-passphrase",
108+
filename = filename.as_str()
109+
)
110+
)
111+
}
112+
Error::IdentityNotFound(filename) => write!(
113+
f,
114+
"{}",
115+
i18n_embed_fl::fl!(
116+
LANGUAGE_LOADER,
117+
"err-dec-identity-not-found",
118+
filename = filename.as_str()
119+
)
120+
),
88121
Error::Io(e) => write!(f, "{}", e),
122+
Error::MissingIdentities => {
123+
wlnfl!(f, "err-dec-missing-identities")?;
124+
wlnfl!(f, "rec-dec-missing-identities")
125+
}
89126
Error::MissingMountpoint => wfl!(f, "err-mnt-missing-mountpoint"),
90127
Error::MissingSource => wfl!(f, "err-mnt-missing-source"),
91128
Error::MountpointMustBeDir => wfl!(f, "err-mnt-must-be-dir"),
92129
Error::Nix(e) => write!(f, "{}", e),
93130
Error::SourceMustBeDir => wfl!(f, "err-mnt-source-must-be-dir"),
131+
Error::UnsupportedKey(filename, k) => k.display(f, Some(filename.as_str())),
94132
}?;
95133
writeln!(f)?;
96134
writeln!(f, "[ {} ]", fl!("err-ux-A"))?;
@@ -116,6 +154,16 @@ struct AgeMountOptions {
116154

117155
#[options(help = "Print version info and exit.", short = "V")]
118156
version: bool,
157+
158+
#[options(
159+
help = "Maximum work factor to allow for passphrase decryption.",
160+
meta = "WF",
161+
no_short
162+
)]
163+
max_work_factor: Option<u8>,
164+
165+
#[options(help = "Use the identity file at IDENTITY. May be repeated.")]
166+
identity: Vec<String>,
119167
}
120168

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

236+
let identities = read_identities(
237+
opts.identity,
238+
opts.max_work_factor,
239+
Error::IdentityNotFound,
240+
Error::IdentityEncryptedWithoutPassphrase,
241+
Error::UnsupportedKey,
242+
)?;
243+
244+
if identities.is_empty() {
245+
return Err(Error::MissingIdentities);
246+
}
247+
188248
mount_fs(
189-
|| crate::overlay::AgeOverlayFs::new(directory.into()),
249+
|| crate::overlay::AgeOverlayFs::new(directory.into(), identities),
190250
mountpoint,
191251
);
192252
Ok(())

rage/src/bin/rage-mount-dir/overlay.rs

+79-11
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,40 @@
11
use std::collections::HashMap;
22
use std::ffi::OsStr;
3-
use std::fs::File;
43
use std::io::{self, Read, Seek, SeekFrom};
54
use std::path::{Path, PathBuf};
65
use std::sync::Mutex;
76

7+
use age::Identity;
88
use fuse_mt::*;
99
use nix::{dir::Dir, fcntl::OFlag, libc, sys::stat::Mode, unistd::AccessFlags};
1010
use time::Timespec;
1111

12-
use crate::util::*;
12+
use crate::{
13+
reader::OpenedFile,
14+
util::*,
15+
wrapper::{check_file, AgeFile},
16+
};
1317

1418
pub struct AgeOverlayFs {
1519
root: PathBuf,
20+
identities: Vec<Box<dyn Identity + Send + Sync>>,
21+
age_files: Mutex<HashMap<PathBuf, (PathBuf, Option<AgeFile>)>>,
1622
open_dirs: Mutex<HashMap<u64, Dir>>,
17-
open_files: Mutex<HashMap<u64, File>>,
23+
open_files: Mutex<HashMap<u64, OpenedFile>>,
1824
}
1925

2026
impl AgeOverlayFs {
21-
pub fn new(root: PathBuf) -> io::Result<Self> {
27+
pub fn new(
28+
root: PathBuf,
29+
identities: Vec<Box<dyn Identity + Send + Sync>>,
30+
) -> io::Result<Self> {
31+
// TODO: Scan the directory to find age-encrypted files, and trial-decrypt them.
32+
// We'll do this manually in order to cache the unwrapped FileKeys for X? minutes.
33+
2234
Ok(AgeOverlayFs {
2335
root,
36+
identities,
37+
age_files: Mutex::new(HashMap::new()),
2438
open_dirs: Mutex::new(HashMap::new()),
2539
open_files: Mutex::new(HashMap::new()),
2640
})
@@ -29,19 +43,38 @@ impl AgeOverlayFs {
2943
fn base_path(&self, path: &Path) -> PathBuf {
3044
self.root.join(path.strip_prefix("/").unwrap())
3145
}
46+
47+
fn age_stat(&self, f: &AgeFile, mut stat: FileAttr) -> FileAttr {
48+
stat.size = f.size;
49+
stat
50+
}
3251
}
3352

3453
const TTL: Timespec = Timespec { sec: 1, nsec: 0 };
3554

3655
impl FilesystemMT for AgeOverlayFs {
3756
fn getattr(&self, _req: RequestInfo, path: &Path, fh: Option<u64>) -> ResultEntry {
57+
let age_files = self.age_files.lock().unwrap();
58+
let base_path = self.base_path(path);
59+
let (query_path, age_file) = match age_files.get(&base_path) {
60+
Some((real_path, Some(f))) => (real_path, Some(f)),
61+
_ => (&base_path, None),
62+
};
63+
3864
use std::os::unix::io::RawFd;
3965
nix_err(if let Some(fd) = fh {
4066
nix::sys::stat::fstat(fd as RawFd)
4167
} else {
42-
nix::sys::stat::lstat(&self.base_path(path))
68+
nix::sys::stat::lstat(query_path)
4369
})
4470
.map(nix_stat)
71+
.map(|stat| {
72+
if let Some(f) = age_file {
73+
self.age_stat(f, stat)
74+
} else {
75+
stat
76+
}
77+
})
4578
.map(|stat| (TTL, stat))
4679
}
4780

@@ -155,10 +188,14 @@ impl FilesystemMT for AgeOverlayFs {
155188
}
156189

157190
fn open(&self, _req: RequestInfo, path: &Path, _flags: u32) -> ResultOpen {
158-
use std::os::unix::io::AsRawFd;
159-
160-
let file = File::open(self.base_path(path)).map_err(|e| e.raw_os_error().unwrap_or(0))?;
161-
let fh = file.as_raw_fd() as u64;
191+
let age_files = self.age_files.lock().unwrap();
192+
let base_path = self.base_path(path);
193+
let file = match age_files.get(&base_path) {
194+
Some((real_path, Some(f))) => OpenedFile::age(real_path, f),
195+
_ => OpenedFile::normal(&base_path),
196+
}
197+
.map_err(|e| e.raw_os_error().unwrap_or(0))?;
198+
let fh = file.handle();
162199

163200
let mut open_files = self.open_files.lock().unwrap();
164201
open_files.insert(fh, file);
@@ -233,9 +270,10 @@ impl FilesystemMT for AgeOverlayFs {
233270
Ok((fh, 0))
234271
}
235272

236-
fn readdir(&self, _req: RequestInfo, _path: &Path, fh: u64) -> ResultReaddir {
273+
fn readdir(&self, _req: RequestInfo, path: &Path, fh: u64) -> ResultReaddir {
237274
use std::os::unix::ffi::OsStrExt;
238275

276+
let mut age_files = self.age_files.lock().unwrap();
239277
let mut open_dirs = self.open_dirs.lock().unwrap();
240278
let dir = open_dirs.get_mut(&fh).ok_or(libc::EBADF)?;
241279

@@ -254,7 +292,37 @@ impl FilesystemMT for AgeOverlayFs {
254292
.map(|stat| stat.kind),
255293
)
256294
})?;
257-
let name = OsStr::from_bytes(entry.file_name().to_bytes()).to_owned();
295+
let name = Path::new(OsStr::from_bytes(entry.file_name().to_bytes()));
296+
297+
let name = match name.extension() {
298+
Some(ext) if ext == "age" => {
299+
let path = self.base_path(path).join(name);
300+
match age_files.get(&path.with_extension("")) {
301+
// We can decrypt this; remove the .age from the filename.
302+
Some((_, Some(_))) => name.to_owned().with_extension("").into(),
303+
// We can't decrypt this; leave the name as-is.
304+
Some((_, None)) => name.into(),
305+
// We haven't seen this .age file; test it!
306+
None => {
307+
let (path, file) = check_file(path, &self.identities)
308+
.map_err(|e| e.raw_os_error().unwrap_or(0))?;
309+
let decrypted = file.is_some();
310+
311+
// Remember whether we can decrypt this file!
312+
age_files.insert(path.with_extension(""), (path, file));
313+
314+
if decrypted {
315+
// Remove the .age from the filename.
316+
name.to_owned().with_extension("").into()
317+
} else {
318+
name.into()
319+
}
320+
}
321+
}
322+
}
323+
_ => name.into(),
324+
};
325+
258326
Ok(DirectoryEntry { name, kind })
259327
})
260328
})

rage/src/bin/rage-mount-dir/reader.rs

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
use std::fs::File;
2+
use std::io;
3+
use std::path::Path;
4+
5+
use age::stream::StreamReader;
6+
7+
use crate::wrapper::AgeFile;
8+
9+
pub(crate) enum OpenedFile {
10+
Normal(File),
11+
Age {
12+
reader: StreamReader<File>,
13+
handle: u64,
14+
},
15+
}
16+
17+
impl OpenedFile {
18+
pub(crate) fn normal(path: &Path) -> io::Result<Self> {
19+
File::open(path).map(OpenedFile::Normal)
20+
}
21+
22+
pub(crate) fn age(path: &Path, age_file: &AgeFile) -> io::Result<Self> {
23+
let file = File::open(path)?;
24+
25+
use std::os::unix::io::AsRawFd;
26+
let handle = file.as_raw_fd() as u64;
27+
28+
let decryptor = match age::Decryptor::new(file).unwrap() {
29+
age::Decryptor::Recipients(d) => d,
30+
_ => unreachable!(),
31+
};
32+
let reader = decryptor
33+
.decrypt(
34+
Some(&age_file.file_key)
35+
.into_iter()
36+
.map(|i| i as &dyn age::Identity),
37+
)
38+
.unwrap();
39+
40+
Ok(OpenedFile::Age { reader, handle })
41+
}
42+
43+
pub(crate) fn handle(&self) -> u64 {
44+
match self {
45+
OpenedFile::Normal(file) => {
46+
use std::os::unix::io::AsRawFd;
47+
file.as_raw_fd() as u64
48+
}
49+
OpenedFile::Age { handle, .. } => *handle,
50+
}
51+
}
52+
}
53+
54+
impl io::Read for OpenedFile {
55+
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
56+
match self {
57+
OpenedFile::Normal(file) => file.read(buf),
58+
OpenedFile::Age { reader, .. } => reader.read(buf),
59+
}
60+
}
61+
}
62+
63+
impl io::Seek for OpenedFile {
64+
fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
65+
match self {
66+
OpenedFile::Normal(file) => file.seek(pos),
67+
OpenedFile::Age { reader, .. } => reader.seek(pos),
68+
}
69+
}
70+
}

0 commit comments

Comments
 (0)