Compare commits
7 Commits
feature/v0
...
feature/v0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d02a50cc8 | ||
|
|
006e67c361 | ||
|
|
95d1ff833c | ||
|
|
6a1c6d5875 | ||
|
|
efac53d527 | ||
|
|
d539050aec | ||
|
|
8a72b5e192 |
@@ -91,6 +91,7 @@ pub fn store_device_keys(
|
||||
}
|
||||
|
||||
/// Load the signing private key for a device.
|
||||
#[allow(dead_code)]
|
||||
pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
|
||||
let path = device_dir(name)?.join("signing.key");
|
||||
let key = fs::read_to_string(&path)
|
||||
@@ -99,6 +100,7 @@ pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
|
||||
}
|
||||
|
||||
/// Load the deploy private key for a device.
|
||||
#[allow(dead_code)]
|
||||
pub fn load_deploy_key(name: &str) -> Result<Zeroizing<String>> {
|
||||
let path = device_dir(name)?.join("deploy.key");
|
||||
let key = fs::read_to_string(&path)
|
||||
@@ -115,6 +117,7 @@ pub fn load_gitea_key_id(name: &str) -> Result<u64> {
|
||||
}
|
||||
|
||||
/// Delete the local key directory for a device.
|
||||
#[allow(dead_code)]
|
||||
pub fn delete_device_keys(name: &str) -> Result<()> {
|
||||
let dir = device_dir(name)?;
|
||||
if dir.exists() {
|
||||
|
||||
@@ -21,7 +21,9 @@ struct CreateKeyRequest<'a> {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeployKey {
|
||||
pub id: u64,
|
||||
#[allow(dead_code)]
|
||||
pub title: String,
|
||||
#[allow(dead_code)]
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
@@ -89,6 +91,7 @@ impl GiteaClient {
|
||||
}
|
||||
|
||||
/// List all deploy keys.
|
||||
#[allow(dead_code)]
|
||||
pub fn list_deploy_keys(&self) -> Result<Vec<DeployKey>> {
|
||||
let url = format!(
|
||||
"{}/repos/{}/{}/keys",
|
||||
|
||||
@@ -34,6 +34,7 @@ pub fn vault_dir() -> Result<PathBuf> {
|
||||
}
|
||||
|
||||
/// Path to the `.relicario/` configuration directory within the vault.
|
||||
#[allow(dead_code)]
|
||||
pub fn relicario_dir() -> Result<PathBuf> {
|
||||
Ok(vault_dir()?.join(".relicario"))
|
||||
}
|
||||
@@ -88,19 +89,21 @@ fn plural(n: i64) -> &'static str { if n == 1 { "" } else { "s" } }
|
||||
///
|
||||
/// **Plaintext leak:** group names land on disk in cleartext alongside the
|
||||
/// vault directory. This is intentional — the file feeds shell completion,
|
||||
/// which cannot prompt for a passphrase. Set `RELICARIO_NO_GROUPS_CACHE=1`
|
||||
/// to suppress the write.
|
||||
/// which cannot prompt for a passphrase. In debug builds, set
|
||||
/// `RELICARIO_NO_GROUPS_CACHE=1` to suppress the write.
|
||||
pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
|
||||
vault_dir.join(".relicario").join("groups.cache")
|
||||
}
|
||||
|
||||
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
|
||||
/// one name per line. A no-op if `RELICARIO_NO_GROUPS_CACHE` is set.
|
||||
/// one name per line. In debug builds, setting `RELICARIO_NO_GROUPS_CACHE`
|
||||
/// suppresses the write (developer debugging tool). In release builds the env
|
||||
/// var is ignored.
|
||||
pub fn write_groups_cache(
|
||||
vault_dir: &Path,
|
||||
groups: &std::collections::BTreeSet<String>,
|
||||
) -> std::io::Result<()> {
|
||||
if std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() {
|
||||
if cfg!(debug_assertions) && std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let path = groups_cache_path(vault_dir);
|
||||
|
||||
@@ -170,7 +170,7 @@ enum Commands {
|
||||
///
|
||||
/// For `--group <TAB>` autocomplete, the bash/zsh/fish scripts read
|
||||
/// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file,
|
||||
/// which the CLI refreshes on every manifest read. Set
|
||||
/// which the CLI refreshes on every manifest read. In debug builds, set
|
||||
/// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion
|
||||
/// will fall back to no value enumeration).
|
||||
///
|
||||
@@ -540,7 +540,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
||||
};
|
||||
let carrier = fs::read(&image)
|
||||
.with_context(|| format!("failed to read carrier image {}", image.display()))?;
|
||||
let stego = imgsecret::embed(&carrier, &*image_secret)?;
|
||||
let stego = imgsecret::embed(&carrier, &image_secret)?;
|
||||
fs::write(&output, &stego)
|
||||
.with_context(|| format!("failed to write reference image {}", output.display()))?;
|
||||
|
||||
@@ -550,7 +550,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
||||
let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
|
||||
|
||||
// Derive master key, then persist an empty Manifest + default VaultSettings.
|
||||
let master_key = derive_master_key(passphrase.as_bytes(), &*image_secret, &salt, ¶ms)?;
|
||||
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)?;
|
||||
|
||||
fs::create_dir_all(&relicario_dir)?;
|
||||
fs::create_dir_all(root.join("items"))?;
|
||||
@@ -645,6 +645,7 @@ fn cmd_add(kind: AddKind) -> Result<()> {
|
||||
// (for attachment-cap settings + writing the encrypted blob alongside
|
||||
// the item).
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_login_item(
|
||||
title: Option<String>,
|
||||
username: Option<String>,
|
||||
@@ -860,6 +861,7 @@ fn build_document_item(
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_totp_item(
|
||||
title: Option<String>,
|
||||
issuer: Option<String>,
|
||||
@@ -924,7 +926,7 @@ fn prompt_optional(label: &str) -> Result<Option<String>> {
|
||||
|
||||
fn parse_month_year(s: &str) -> Result<relicario_core::MonthYear> {
|
||||
// Accepts MM/YYYY or MM-YYYY or MM/YY.
|
||||
let (m_str, y_str) = s.split_once(|c: char| c == '/' || c == '-')
|
||||
let (m_str, y_str) = s.split_once(['/', '-'])
|
||||
.ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?;
|
||||
let month: u8 = m_str.parse().context("invalid month")?;
|
||||
let year: u16 = if y_str.len() == 2 {
|
||||
@@ -998,12 +1000,12 @@ fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
|
||||
if let Some(u) = &l.url { println!("URL: {u}"); }
|
||||
if let Some(t) = &l.totp {
|
||||
if show {
|
||||
println!("TOTP: {}", data_encoding::BASE32.encode(&*t.secret));
|
||||
println!("TOTP: {}", data_encoding::BASE32.encode(&t.secret));
|
||||
} else {
|
||||
println!("TOTP: **** (use --show to reveal)");
|
||||
}
|
||||
}
|
||||
if let Some(p) = &l.password { Some(p.clone()) } else { None }
|
||||
l.password.clone()
|
||||
}
|
||||
ItemCore::SecureNote(n) => {
|
||||
if show { println!("Body:\n{}", n.body.as_str()); }
|
||||
@@ -1125,8 +1127,8 @@ fn cmd_list(
|
||||
Some(t) => e.r#type == t,
|
||||
None => true,
|
||||
})
|
||||
.filter(|e| group_filter.as_ref().map_or(true, |g| e.group.as_deref() == Some(g.as_str())))
|
||||
.filter(|e| tag_filter.as_ref().map_or(true, |t| e.tags.iter().any(|x| x == t)))
|
||||
.filter(|e| group_filter.as_ref().is_none_or(|g| e.group.as_deref() == Some(g.as_str())))
|
||||
.filter(|e| tag_filter.as_ref().is_none_or(|t| e.tags.iter().any(|x| x == t)))
|
||||
.collect();
|
||||
entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
|
||||
|
||||
@@ -1135,7 +1137,7 @@ fn cmd_list(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("{:<16} {:<14} {:<6} {}", "ID", "TYPE", "FAV", "TITLE");
|
||||
println!("{:<16} {:<14} {:<6} TITLE", "ID", "TYPE", "FAV");
|
||||
for e in entries {
|
||||
let fav = if e.favorite { " *" } else { "" };
|
||||
println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title);
|
||||
@@ -1718,9 +1720,32 @@ fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
|
||||
|
||||
// .git/ history.
|
||||
if let Some(tar_bytes) = &unpacked.git_archive {
|
||||
let mut archive = tar::Archive::new(tar_bytes.as_slice());
|
||||
archive.unpack(target.join(".git"))
|
||||
.with_context(|| "failed to untar .git/")?;
|
||||
// Cap: 100× the compressed bundle size, or 1 GiB, whichever is lower.
|
||||
let cap = std::cmp::min(
|
||||
(tar_bytes.len() as u64).saturating_mul(100),
|
||||
relicario_core::DEFAULT_MAX_UNCOMPRESSED,
|
||||
);
|
||||
let entries = relicario_core::safe_unpack_git_archive(tar_bytes, cap)
|
||||
.with_context(|| "failed to safely unpack .git/ archive")?;
|
||||
let git_dir = target.join(".git");
|
||||
for (rel_path, body) in entries {
|
||||
let dest = git_dir.join(&rel_path);
|
||||
// Paranoid OS-level check even after textual validation in core.
|
||||
if !dest.starts_with(&git_dir) {
|
||||
anyhow::bail!(
|
||||
"tar entry {} resolved outside .git/ (path traversal blocked)",
|
||||
rel_path.display()
|
||||
);
|
||||
}
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!("create parent {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
fs::write(&dest, &body).with_context(|| {
|
||||
format!("write {}", dest.display())
|
||||
})?;
|
||||
}
|
||||
} else {
|
||||
// No history bundled — start a fresh git repo.
|
||||
let status = crate::helpers::git_command(&target, &["init"]).status()?;
|
||||
@@ -1950,7 +1975,7 @@ fn cmd_attachments(query: String) -> Result<()> {
|
||||
let entry = resolve_query(&manifest, &query)?;
|
||||
let item = vault.load_item(&entry.id)?;
|
||||
if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); }
|
||||
println!("{:<17} {:>12} {:<22} {}", "AID", "SIZE", "MIME", "FILENAME");
|
||||
println!("{:<17} {:>12} {:<22} FILENAME", "AID", "SIZE", "MIME");
|
||||
for a in &item.attachments {
|
||||
println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename);
|
||||
}
|
||||
@@ -2518,7 +2543,7 @@ fn cmd_device(action: DeviceAction) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("{:<20} {:<20} {}", "NAME", "ADDED", "SIGNING KEY (prefix)");
|
||||
println!("{:<20} {:<20} SIGNING KEY (prefix)", "NAME", "ADDED");
|
||||
println!("{}", "-".repeat(72));
|
||||
for d in &devices {
|
||||
let marker = if d.name == current { " *" } else { "" };
|
||||
|
||||
@@ -50,7 +50,7 @@ impl UnlockedVault {
|
||||
|
||||
let master_key = derive_master_key(
|
||||
passphrase.as_bytes(),
|
||||
&*image_secret,
|
||||
&image_secret,
|
||||
&salt,
|
||||
¶ms,
|
||||
)?;
|
||||
|
||||
@@ -68,7 +68,7 @@ fn detach_removes_attachment_and_blob() {
|
||||
// Encrypted blob file is gone.
|
||||
let blob_path = v.path()
|
||||
.join("attachments")
|
||||
.join(stdout.lines().nth(1).is_some().then_some("").unwrap_or(""));
|
||||
.join("");
|
||||
let item_attach_dir = std::fs::read_dir(v.path().join("attachments"))
|
||||
.unwrap().next().unwrap().unwrap().path();
|
||||
let blob = item_attach_dir.join(format!("{aid}.enc"));
|
||||
|
||||
@@ -78,6 +78,7 @@ impl TestVault {
|
||||
cmd.output().unwrap()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn run_with_backup_pass(&self, args: &[&str], backup_pass: &str) -> std::process::Output {
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
cmd.current_dir(self.dir.path())
|
||||
@@ -91,6 +92,7 @@ impl TestVault {
|
||||
cmd.output().unwrap()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output {
|
||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||
cmd.current_dir(self.dir.path())
|
||||
|
||||
@@ -408,7 +408,7 @@ mod tests {
|
||||
blob.extend_from_slice(&[0u8; 16]);
|
||||
|
||||
let key = Zeroizing::new([0u8; 32]);
|
||||
let err = decrypt(&*key, &blob).expect_err("v1 blob should fail decrypt");
|
||||
let err = decrypt(&key, &blob).expect_err("v1 blob should fail decrypt");
|
||||
match err {
|
||||
RelicarioError::UnsupportedFormatVersion { found, expected } => {
|
||||
assert_eq!(found, 0x01);
|
||||
|
||||
@@ -106,6 +106,16 @@ pub fn verify(public_key_openssh: &str, data: &[u8], signature_b64: &str) -> Res
|
||||
Ok(verifying_key.verify(data, &signature).is_ok())
|
||||
}
|
||||
|
||||
/// Compute the OpenSSH SHA-256 fingerprint of a public key.
|
||||
/// Output format matches `ssh-keygen -lf` and `git verify-commit --raw`:
|
||||
/// `SHA256:<43-char base64 without padding>`.
|
||||
pub fn fingerprint(public_key_openssh: &str) -> Result<String> {
|
||||
use ssh_key::HashAlg;
|
||||
let public = PublicKey::from_openssh(public_key_openssh)
|
||||
.map_err(|e| RelicarioError::DeviceKey(format!("parse public key: {e}")))?;
|
||||
Ok(public.fingerprint(HashAlg::Sha256).to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -132,4 +142,27 @@ mod tests {
|
||||
let sig = sign(&private, b"hello").unwrap();
|
||||
assert!(!verify(&other_public, b"hello", &sig).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_matches_ssh_keygen_format() {
|
||||
let (_, public) = generate_keypair().unwrap();
|
||||
let fp = fingerprint(&public).unwrap();
|
||||
assert!(fp.starts_with("SHA256:"), "fingerprint should start with SHA256: prefix, got {fp}");
|
||||
let body = fp.strip_prefix("SHA256:").unwrap();
|
||||
assert_eq!(body.len(), 43, "SHA-256 fingerprint body is 43 base64 chars (no padding)");
|
||||
assert!(body.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_is_deterministic() {
|
||||
let (_, public) = generate_keypair().unwrap();
|
||||
assert_eq!(fingerprint(&public).unwrap(), fingerprint(&public).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_differs_per_key() {
|
||||
let (_, p1) = generate_keypair().unwrap();
|
||||
let (_, p2) = generate_keypair().unwrap();
|
||||
assert_ne!(fingerprint(&p1).unwrap(), fingerprint(&p2).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,10 @@ pub enum RelicarioError {
|
||||
#[error("backup envelope schema v{found}; this Relicario reads v{expected}")]
|
||||
BackupSchemaMismatch { found: u32, expected: u32 },
|
||||
|
||||
/// An error during backup restore (e.g., tar safety validation failure).
|
||||
#[error("backup restore: {0}")]
|
||||
BackupRestore(String),
|
||||
|
||||
/// CSV header doesn't match the LastPass column layout.
|
||||
#[error("unrecognized CSV header — expected LastPass export format ({0})")]
|
||||
ImportCsvHeader(String),
|
||||
|
||||
@@ -83,7 +83,7 @@ const BITS_PER_BLOCK: usize = 12; // EMBED_POSITIONS.len()
|
||||
|
||||
/// Number of 8x8 blocks needed to hold one complete copy of the 256-bit secret.
|
||||
/// ceil(256 / 12) = 22 blocks per copy.
|
||||
const BLOCKS_PER_COPY: usize = (SECRET_BITS + BITS_PER_BLOCK - 1) / BITS_PER_BLOCK; // 22
|
||||
const BLOCKS_PER_COPY: usize = SECRET_BITS.div_ceil(BITS_PER_BLOCK); // 22
|
||||
|
||||
/// Mid-frequency DCT coefficient positions for embedding, specified as
|
||||
/// (row, col) indices into the 8x8 DCT coefficient matrix.
|
||||
@@ -302,9 +302,9 @@ fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> {
|
||||
return None;
|
||||
}
|
||||
let mut block = [[0.0f64; 8]; 8];
|
||||
for row in 0..8 {
|
||||
for col in 0..8 {
|
||||
block[row][col] = y.get(px + col, py + row);
|
||||
for (row, block_row) in block.iter_mut().enumerate() {
|
||||
for (col, cell) in block_row.iter_mut().enumerate() {
|
||||
*cell = y.get(px + col, py + row);
|
||||
}
|
||||
}
|
||||
Some(block)
|
||||
@@ -323,9 +323,9 @@ fn read_block(y: &YChannel, bx: usize, by: usize, region: &EmbedRegion) -> [[f64
|
||||
fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, block: &[[f64; 8]; 8]) {
|
||||
let start_x = region.x_offset + bx * BLOCK_SIZE;
|
||||
let start_y = region.y_offset + by * BLOCK_SIZE;
|
||||
for row in 0..8 {
|
||||
for col in 0..8 {
|
||||
y.set(start_x + col, start_y + row, block[row][col]);
|
||||
for (row, block_row) in block.iter().enumerate() {
|
||||
for (col, &cell) in block_row.iter().enumerate() {
|
||||
y.set(start_x + col, start_y + row, cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,17 +349,17 @@ fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, blo
|
||||
/// where c(0) = sqrt(1/8) and c(k) = sqrt(2/8) for k > 0.
|
||||
fn dct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||
let mut output = [0.0f64; 8];
|
||||
for k in 0..8 {
|
||||
for (k, out_k) in output.iter_mut().enumerate() {
|
||||
let ck = if k == 0 {
|
||||
(1.0 / 8.0_f64).sqrt()
|
||||
} else {
|
||||
(2.0 / 8.0_f64).sqrt()
|
||||
};
|
||||
let mut sum = 0.0;
|
||||
for i in 0..8 {
|
||||
sum += input[i] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
||||
for (i, &x) in input.iter().enumerate() {
|
||||
sum += x * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
||||
}
|
||||
output[k] = ck * sum;
|
||||
*out_k = ck * sum;
|
||||
}
|
||||
output
|
||||
}
|
||||
@@ -370,17 +370,17 @@ fn dct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||
/// x[i] = sum_{k=0}^{7} c(k) * X[k] * cos((2i+1)*k*pi/16)
|
||||
fn idct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||
let mut output = [0.0f64; 8];
|
||||
for i in 0..8 {
|
||||
for (i, out_i) in output.iter_mut().enumerate() {
|
||||
let mut sum = 0.0;
|
||||
for k in 0..8 {
|
||||
for (k, &x) in input.iter().enumerate() {
|
||||
let ck = if k == 0 {
|
||||
(1.0 / 8.0_f64).sqrt()
|
||||
} else {
|
||||
(2.0 / 8.0_f64).sqrt()
|
||||
};
|
||||
sum += ck * input[k] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
||||
sum += ck * x * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
||||
}
|
||||
output[i] = sum;
|
||||
*out_i = sum;
|
||||
}
|
||||
output
|
||||
}
|
||||
@@ -501,7 +501,7 @@ fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
|
||||
///
|
||||
/// Pads the last byte with zeros if the bit count is not a multiple of 8.
|
||||
fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = Vec::with_capacity((bits.len() + 7) / 8);
|
||||
let mut bytes = Vec::with_capacity(bits.len().div_ceil(8));
|
||||
for chunk in bits.chunks(8) {
|
||||
let mut byte = 0u8;
|
||||
for (i, &bit) in chunk.iter().enumerate() {
|
||||
|
||||
@@ -52,18 +52,15 @@ pub enum TotpAlgorithm {
|
||||
Sha512,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TotpKind {
|
||||
#[default]
|
||||
Totp,
|
||||
Hotp { counter: u64 },
|
||||
Steam,
|
||||
}
|
||||
|
||||
impl Default for TotpKind {
|
||||
fn default() -> Self { TotpKind::Totp }
|
||||
}
|
||||
|
||||
/// Compute a TOTP/Steam code for `config` at the given Unix timestamp.
|
||||
///
|
||||
/// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`.
|
||||
|
||||
@@ -85,4 +85,7 @@ pub mod import_lastpass;
|
||||
pub use import_lastpass::{parse_lastpass_csv, ImportWarning};
|
||||
|
||||
pub mod device;
|
||||
pub use device::{DeviceEntry, RevokedEntry, generate_keypair, sign, verify};
|
||||
pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign, verify};
|
||||
|
||||
pub mod tar_safe;
|
||||
pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED};
|
||||
|
||||
138
crates/relicario-core/src/tar_safe.rs
Normal file
138
crates/relicario-core/src/tar_safe.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
//! Safe tar unpacking for backup restore.
|
||||
//!
|
||||
//! The standard `tar::Archive::unpack` has no guards against path traversal,
|
||||
//! absolute paths, symlinks, hardlinks, or tar bombs. This module replaces it
|
||||
//! with `safe_unpack_git_archive`, which validates every entry before returning
|
||||
//! `(relative_path, bytes)` pairs to the caller.
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::{Component, PathBuf};
|
||||
|
||||
use tar::EntryType;
|
||||
|
||||
use crate::error::{RelicarioError, Result};
|
||||
|
||||
/// Default cap on total uncompressed bytes extracted in one restore (1 GiB).
|
||||
pub const DEFAULT_MAX_UNCOMPRESSED: u64 = 1024 * 1024 * 1024;
|
||||
|
||||
/// Decode `tar_bytes` and return `(relative_path, file_bytes)` pairs for
|
||||
/// regular files only.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Err(RelicarioError::BackupRestore(...))` if:
|
||||
///
|
||||
/// - Any path component is `..` (`Component::ParentDir`) — "path traversal blocked".
|
||||
/// - Any path starts with `/` (`Component::RootDir`) — "path traversal blocked".
|
||||
/// - Any path has a Windows drive prefix (`Component::Prefix`) — "path traversal blocked".
|
||||
/// - An entry is a symlink or hardlink — "symlink/link rejected".
|
||||
/// - An entry's declared size exceeds `max_uncompressed_bytes` — "size cap exceeded".
|
||||
/// - The running total of all entry sizes exceeds `max_uncompressed_bytes` — "size cap exceeded".
|
||||
/// - An entry has an unexpected type (not regular file, not directory) — "unexpected entry type".
|
||||
pub fn safe_unpack_git_archive(
|
||||
tar_bytes: &[u8],
|
||||
max_uncompressed_bytes: u64,
|
||||
) -> Result<Vec<(PathBuf, Vec<u8>)>> {
|
||||
let mut archive = tar::Archive::new(tar_bytes);
|
||||
let entries = archive
|
||||
.entries()
|
||||
.map_err(|e| RelicarioError::BackupRestore(format!("failed to read tar entries: {e}")))?;
|
||||
|
||||
let mut result: Vec<(PathBuf, Vec<u8>)> = Vec::new();
|
||||
let mut cumulative: u64 = 0;
|
||||
|
||||
for entry in entries {
|
||||
let mut entry = entry.map_err(|e| {
|
||||
RelicarioError::BackupRestore(format!("failed to read tar entry: {e}"))
|
||||
})?;
|
||||
|
||||
let header = entry.header();
|
||||
let entry_type = header.entry_type();
|
||||
|
||||
// Reject symlinks and hardlinks.
|
||||
match entry_type {
|
||||
EntryType::Symlink => {
|
||||
return Err(RelicarioError::BackupRestore(
|
||||
"symlink entry rejected".to_string(),
|
||||
));
|
||||
}
|
||||
EntryType::Link => {
|
||||
return Err(RelicarioError::BackupRestore(
|
||||
"hardlink entry rejected".to_string(),
|
||||
));
|
||||
}
|
||||
EntryType::Directory => {
|
||||
// Directories are implicit — skip without reading body.
|
||||
continue;
|
||||
}
|
||||
EntryType::Regular | EntryType::Continuous | EntryType::GNUSparse => {
|
||||
// These are normal file types; fall through to path checks.
|
||||
}
|
||||
_ => {
|
||||
return Err(RelicarioError::BackupRestore(format!(
|
||||
"unexpected entry type: {:?}",
|
||||
entry_type
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the path.
|
||||
let path = entry.path().map_err(|e| {
|
||||
RelicarioError::BackupRestore(format!("invalid path in tar entry: {e}"))
|
||||
})?;
|
||||
let path = path.into_owned();
|
||||
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::ParentDir => {
|
||||
return Err(RelicarioError::BackupRestore(
|
||||
"path traversal blocked: entry contains '..' component".to_string(),
|
||||
));
|
||||
}
|
||||
Component::RootDir => {
|
||||
return Err(RelicarioError::BackupRestore(
|
||||
"path traversal blocked: entry has absolute path".to_string(),
|
||||
));
|
||||
}
|
||||
Component::Prefix(_) => {
|
||||
return Err(RelicarioError::BackupRestore(
|
||||
"path traversal blocked: entry has Windows drive prefix".to_string(),
|
||||
));
|
||||
}
|
||||
Component::Normal(_) | Component::CurDir => {
|
||||
// Acceptable components.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check declared size before reading body.
|
||||
let claimed = header.size().map_err(|e| {
|
||||
RelicarioError::BackupRestore(format!("could not read entry size: {e}"))
|
||||
})?;
|
||||
|
||||
if claimed > max_uncompressed_bytes {
|
||||
return Err(RelicarioError::BackupRestore(format!(
|
||||
"size cap exceeded: entry claims {claimed} bytes (cap {max_uncompressed_bytes})"
|
||||
)));
|
||||
}
|
||||
|
||||
let new_total = cumulative.saturating_add(claimed);
|
||||
if new_total > max_uncompressed_bytes {
|
||||
return Err(RelicarioError::BackupRestore(format!(
|
||||
"size cap exceeded: cumulative size would reach {new_total} bytes (cap {max_uncompressed_bytes})"
|
||||
)));
|
||||
}
|
||||
|
||||
// Read the file body.
|
||||
let mut body = Vec::with_capacity(claimed as usize);
|
||||
entry.read_to_end(&mut body).map_err(|e| {
|
||||
RelicarioError::BackupRestore(format!("failed to read entry body: {e}"))
|
||||
})?;
|
||||
|
||||
cumulative += body.len() as u64;
|
||||
|
||||
result.push((path, body));
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ impl MonthYear {
|
||||
if !(1..=12).contains(&month) {
|
||||
return Err("month must be 1..=12");
|
||||
}
|
||||
if year < 2000 || year > 2099 {
|
||||
if !(2000..=2099).contains(&year) {
|
||||
return Err("year must be 2000..=2099");
|
||||
}
|
||||
Ok(Self { month, year })
|
||||
|
||||
187
crates/relicario-core/tests/safe_unpack.rs
Normal file
187
crates/relicario-core/tests/safe_unpack.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
use std::path::PathBuf;
|
||||
use tar::{Builder, Header, EntryType};
|
||||
use relicario_core::safe_unpack_git_archive;
|
||||
|
||||
/// Craft a raw POSIX ustar tar with a single entry using the given raw path bytes.
|
||||
/// The tar crate's `Builder` sanitises paths, so we write the 512-byte header
|
||||
/// manually to produce truly malicious archives.
|
||||
fn raw_tar_with_path(raw_path: &[u8], content: &[u8]) -> Vec<u8> {
|
||||
let mut buf = vec![0u8; 512]; // one header block
|
||||
|
||||
// Bytes 0-99: name field (null-padded)
|
||||
let name_len = raw_path.len().min(100);
|
||||
buf[..name_len].copy_from_slice(&raw_path[..name_len]);
|
||||
|
||||
// Bytes 100-107: mode = "0000644\0"
|
||||
buf[100..108].copy_from_slice(b"0000644\0");
|
||||
|
||||
// Bytes 108-115: uid
|
||||
buf[108..116].copy_from_slice(b"0000000\0");
|
||||
|
||||
// Bytes 116-123: gid
|
||||
buf[116..124].copy_from_slice(b"0000000\0");
|
||||
|
||||
// Bytes 124-135: size (octal, 11 digits + null)
|
||||
let size_str = format!("{:011o}\0", content.len());
|
||||
buf[124..136].copy_from_slice(size_str.as_bytes());
|
||||
|
||||
// Bytes 136-147: mtime
|
||||
buf[136..148].copy_from_slice(b"00000000000\0");
|
||||
|
||||
// Bytes 148-155: checksum placeholder (spaces during compute)
|
||||
buf[148..156].copy_from_slice(b" ");
|
||||
|
||||
// Byte 156: typeflag = '0' (regular file)
|
||||
buf[156] = b'0';
|
||||
|
||||
// Bytes 257-262: magic "ustar\0"
|
||||
buf[257..263].copy_from_slice(b"ustar\0");
|
||||
// Bytes 263-264: version "00"
|
||||
buf[263..265].copy_from_slice(b"00");
|
||||
|
||||
// Compute checksum (sum of all bytes, checksum field treated as spaces).
|
||||
let checksum: u32 = buf.iter().map(|&b| b as u32).sum();
|
||||
let cksum_str = format!("{:06o}\0 ", checksum);
|
||||
buf[148..156].copy_from_slice(cksum_str.as_bytes());
|
||||
|
||||
// Append padded content blocks.
|
||||
let mut out = buf;
|
||||
if !content.is_empty() {
|
||||
out.extend_from_slice(content);
|
||||
// Pad to 512-byte boundary.
|
||||
let remainder = content.len() % 512;
|
||||
if remainder != 0 {
|
||||
out.extend(vec![0u8; 512 - remainder]);
|
||||
}
|
||||
}
|
||||
|
||||
// Two zero blocks = end-of-archive.
|
||||
out.extend(vec![0u8; 1024]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a tar with a raw symlink entry (typeflag = '2').
|
||||
fn raw_symlink_tar() -> Vec<u8> {
|
||||
let mut buf = vec![0u8; 512];
|
||||
|
||||
// name
|
||||
buf[..9].copy_from_slice(b"evil_link");
|
||||
// mode
|
||||
buf[100..108].copy_from_slice(b"0000755\0");
|
||||
// uid/gid
|
||||
buf[108..116].copy_from_slice(b"0000000\0");
|
||||
buf[116..124].copy_from_slice(b"0000000\0");
|
||||
// size = 0
|
||||
buf[124..136].copy_from_slice(b"00000000000\0");
|
||||
// mtime
|
||||
buf[136..148].copy_from_slice(b"00000000000\0");
|
||||
// checksum placeholder
|
||||
buf[148..156].copy_from_slice(b" ");
|
||||
// typeflag = '2' (symlink)
|
||||
buf[156] = b'2';
|
||||
// linkname
|
||||
let target = b"/etc/passwd";
|
||||
buf[157..157 + target.len()].copy_from_slice(target);
|
||||
// magic
|
||||
buf[257..263].copy_from_slice(b"ustar\0");
|
||||
buf[263..265].copy_from_slice(b"00");
|
||||
|
||||
// Compute checksum.
|
||||
let checksum: u32 = buf.iter().map(|&b| b as u32).sum();
|
||||
let cksum_str = format!("{:06o}\0 ", checksum);
|
||||
buf[148..156].copy_from_slice(cksum_str.as_bytes());
|
||||
|
||||
let mut out = buf;
|
||||
out.extend(vec![0u8; 1024]); // end-of-archive
|
||||
out
|
||||
}
|
||||
|
||||
fn build_normal_tar() -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut builder = Builder::new(&mut buf);
|
||||
let content = b"hello";
|
||||
let mut header = Header::new_gnu();
|
||||
header.set_entry_type(EntryType::Regular);
|
||||
header.set_size(content.len() as u64);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, "subdir/hello.txt", content.as_ref())
|
||||
.unwrap();
|
||||
builder.finish().unwrap();
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
fn build_oversize_tar() -> Vec<u8> {
|
||||
// Actual 2048-byte body; test will use cap=1024
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut builder = Builder::new(&mut buf);
|
||||
let content = vec![0u8; 2048];
|
||||
let mut header = Header::new_gnu();
|
||||
header.set_entry_type(EntryType::Regular);
|
||||
header.set_size(content.len() as u64);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, "bigfile.bin", content.as_slice())
|
||||
.unwrap();
|
||||
builder.finish().unwrap();
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_rejects_path_traversal() {
|
||||
// Craft a tar with "../../escaped.txt" using raw bytes (Builder sanitises paths).
|
||||
let bytes = raw_tar_with_path(b"../../escaped.txt", b"evil content");
|
||||
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("path traversal") || msg.contains(".."),
|
||||
"got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_rejects_absolute_path() {
|
||||
// Craft a tar with "/etc/escaped.txt" using raw bytes.
|
||||
let bytes = raw_tar_with_path(b"/etc/escaped.txt", b"evil content");
|
||||
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("path traversal") || msg.contains("absolute"),
|
||||
"got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_rejects_symlink() {
|
||||
let bytes = raw_symlink_tar();
|
||||
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("symlink") || msg.contains("link"),
|
||||
"got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_rejects_size_bomb() {
|
||||
let bytes = build_oversize_tar(); // actual 2048-byte entry
|
||||
let err = safe_unpack_git_archive(&bytes, 1024).unwrap_err(); // cap = 1024 bytes
|
||||
let msg = format!("{err:#}");
|
||||
assert!(
|
||||
msg.contains("size") || msg.contains("cap") || msg.contains("too large"),
|
||||
"got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_accepts_normal_files() {
|
||||
let buf = build_normal_tar();
|
||||
let entries = safe_unpack_git_archive(&buf, 1024 * 1024).expect("happy path");
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].0, PathBuf::from("subdir/hello.txt"));
|
||||
assert_eq!(entries[0].1, b"hello");
|
||||
}
|
||||
@@ -9,3 +9,10 @@ anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tempfile = "3"
|
||||
regex = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
predicates = "3"
|
||||
tempfile = "3"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! relicario-server -- pre-receive hook for signature verification.
|
||||
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
@@ -34,49 +35,120 @@ fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
fn verify_commit(commit: &str) -> Result<()> {
|
||||
// Get devices.json at this commit
|
||||
let devices_json = match git_show(commit, ".relicario/devices.json") {
|
||||
Ok(json) => json,
|
||||
Err(_) => {
|
||||
// No devices.json yet -- bootstrap mode, allow unsigned
|
||||
eprintln!("OK: commit {} (bootstrap - no devices.json)", commit);
|
||||
eprintln!("OK: commit {commit} (bootstrap - no devices.json)");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let devices: Vec<DeviceEntry> = serde_json::from_str(&devices_json)
|
||||
.context("parse devices.json")?;
|
||||
|
||||
// Bootstrap: if devices.json is empty, allow unsigned
|
||||
if devices.is_empty() {
|
||||
eprintln!("OK: commit {} (bootstrap - empty devices.json)", commit);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Get revoked.json (may not exist)
|
||||
let revoked: Vec<RevokedEntry> = git_show(commit, ".relicario/revoked.json")
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Get commit signature
|
||||
// True bootstrap: no devices ever registered and none revoked.
|
||||
if devices.is_empty() && revoked.is_empty() {
|
||||
eprintln!("OK: commit {commit} (bootstrap - no devices registered)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Build temp allowed-signers file from registered devices.
|
||||
let tmp = tempfile::tempdir().context("create tempdir")?;
|
||||
let allowed_path = tmp.path().join("allowed_signers");
|
||||
let mut allowed_body = String::new();
|
||||
for d in &devices {
|
||||
allowed_body.push_str("relicario ");
|
||||
allowed_body.push_str(d.public_key.trim());
|
||||
allowed_body.push('\n');
|
||||
}
|
||||
fs::write(&allowed_path, &allowed_body).context("write allowed_signers")?;
|
||||
|
||||
// Run git verify-commit --raw. Capture both exit code and stderr.
|
||||
// NOTE: we do NOT short-circuit on non-zero exit here because even for
|
||||
// unregistered keys git still outputs "Good ... key SHA256:..." on stderr.
|
||||
let output = Command::new("git")
|
||||
.args(["verify-commit", "--raw", commit])
|
||||
.env("GIT_CONFIG_COUNT", "1")
|
||||
.env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
|
||||
.env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
|
||||
.output()
|
||||
.context("git verify-commit")?;
|
||||
|
||||
// Check if signed
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if !stderr.contains("GOODSIG") && !stderr.contains("Good signature") {
|
||||
eprintln!("REJECT: commit {} is not signed by a registered device", commit);
|
||||
|
||||
// Parse the SHA-256 fingerprint from stderr.
|
||||
// SSH signature output: "Good "git" signature ... with ED25519 key SHA256:<base64>"
|
||||
let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").expect("static regex");
|
||||
let signing_fp = match re.captures(&stderr).and_then(|c| c.get(1)) {
|
||||
Some(m) => m.as_str().to_string(),
|
||||
None => {
|
||||
// No fingerprint in stderr = unsigned or completely malformed signature.
|
||||
eprintln!(
|
||||
"REJECT: commit {commit} — no valid signature found (stderr: {})",
|
||||
stderr.trim()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Build fingerprint → entry maps.
|
||||
let mut device_by_fp: std::collections::HashMap<String, &DeviceEntry> =
|
||||
std::collections::HashMap::new();
|
||||
for d in &devices {
|
||||
if let Ok(fp) = relicario_core::device::fingerprint(&d.public_key) {
|
||||
device_by_fp.insert(fp, d);
|
||||
}
|
||||
}
|
||||
|
||||
let mut revoked_by_fp: std::collections::HashMap<String, &RevokedEntry> =
|
||||
std::collections::HashMap::new();
|
||||
for r in &revoked {
|
||||
if let Ok(fp) = relicario_core::device::fingerprint(&r.public_key) {
|
||||
revoked_by_fp.insert(fp, r);
|
||||
}
|
||||
}
|
||||
|
||||
// Get committer date (NOT author date).
|
||||
let ct_out = Command::new("git")
|
||||
.args(["show", "-s", "--format=%ct", commit])
|
||||
.output()
|
||||
.context("git show committer date")?;
|
||||
let committer_ts: i64 = String::from_utf8_lossy(&ct_out.stdout)
|
||||
.trim()
|
||||
.parse()
|
||||
.context("parse committer timestamp")?;
|
||||
|
||||
// Check revocation FIRST (revoked entries may not be in devices anymore).
|
||||
if let Some(r) = revoked_by_fp.get(&signing_fp) {
|
||||
if committer_ts >= r.revoked_at {
|
||||
eprintln!(
|
||||
"REJECT: commit {commit} — signed by revoked device '{}' \
|
||||
(committer ts {committer_ts} >= revoked_at {})",
|
||||
r.name, r.revoked_at
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
// Historical commit: committer_ts < revoked_at → was valid when signed.
|
||||
eprintln!(
|
||||
"OK: commit {commit} — historical commit signed by '{}' before revocation",
|
||||
r.name
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Not revoked — must be in active devices.
|
||||
if !device_by_fp.contains_key(&signing_fp) {
|
||||
eprintln!(
|
||||
"REJECT: commit {commit} — signed by unregistered device (fingerprint {signing_fp})"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Ensure the signing key is not revoked.
|
||||
// The allowed-signers file approach means git verify-commit already checks
|
||||
// against the list; we additionally guard against revoked.json entries.
|
||||
let _ = &revoked; // revoked list is loaded; enforcement via git allowed-signers
|
||||
|
||||
eprintln!("OK: commit {} verified", commit);
|
||||
eprintln!("OK: commit {commit} verified (signed by '{}')", device_by_fp[&signing_fp].name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
230
crates/relicario-server/tests/verify_commit.rs
Normal file
230
crates/relicario-server/tests/verify_commit.rs
Normal file
@@ -0,0 +1,230 @@
|
||||
//! Acceptance tests for `relicario-server verify-commit`.
|
||||
//!
|
||||
//! Four scenarios from audit S1:
|
||||
//! 1. Registered non-revoked key → exit 0
|
||||
//! 2. Unregistered key → exit 1 (stderr contains "unregistered")
|
||||
//! 3. Revoked key, commit AFTER revoked_at → exit 1 (stderr contains "revoked")
|
||||
//! 4. Revoked key, commit BEFORE revoked_at (historical) → exit 0
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use assert_cmd::Command as AssertCommand;
|
||||
use predicates::prelude::*;
|
||||
use relicario_core::device::{generate_keypair, DeviceEntry, RevokedEntry};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_keypair(dir: &Path, name: &str) -> (PathBuf, PathBuf, String) {
|
||||
let (priv_pem, pub_line) = generate_keypair().expect("generate keypair");
|
||||
let priv_path = dir.join(format!("{name}.key"));
|
||||
let pub_path = dir.join(format!("{name}.pub"));
|
||||
fs::write(&priv_path, priv_pem.as_str()).unwrap();
|
||||
fs::write(&pub_path, &pub_line).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).unwrap();
|
||||
}
|
||||
(priv_path, pub_path, pub_line)
|
||||
}
|
||||
|
||||
fn git(repo: &Path, args: &[&str], extra_env: &[(&str, &str)]) {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.current_dir(repo).args(args);
|
||||
for (k, v) in extra_env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
let status = cmd.status().expect("spawn git");
|
||||
assert!(status.success(), "git {args:?} failed");
|
||||
}
|
||||
|
||||
fn init_repo(repo: &Path) {
|
||||
git(repo, &["init", "-q", "-b", "main"], &[]);
|
||||
git(repo, &["config", "user.email", "test@test"], &[]);
|
||||
git(repo, &["config", "user.name", "test"], &[]);
|
||||
git(repo, &["commit", "--allow-empty", "-q", "-m", "init"], &[]);
|
||||
}
|
||||
|
||||
fn sign_commit(
|
||||
repo: &Path,
|
||||
signing_key: &Path,
|
||||
allowed_signers: &Path,
|
||||
committer_unix: i64,
|
||||
msg: &str,
|
||||
file_path: &str,
|
||||
file_content: &str,
|
||||
) -> String {
|
||||
fs::write(repo.join(file_path), file_content).unwrap();
|
||||
git(repo, &["add", file_path], &[]);
|
||||
let date = format!("@{committer_unix} +0000");
|
||||
git(
|
||||
repo,
|
||||
&[
|
||||
"-c", "gpg.format=ssh",
|
||||
"-c", &format!("user.signingkey={}", signing_key.display()),
|
||||
"-c", &format!("gpg.ssh.allowedSignersFile={}", allowed_signers.display()),
|
||||
"commit", "-S", "-q", "-m", msg,
|
||||
],
|
||||
&[
|
||||
("GIT_AUTHOR_DATE", &date),
|
||||
("GIT_COMMITTER_DATE", &date),
|
||||
],
|
||||
);
|
||||
let out = Command::new("git")
|
||||
.current_dir(repo)
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8(out.stdout).unwrap().trim().to_string()
|
||||
}
|
||||
|
||||
fn write_device_files(repo: &Path, devices: &[DeviceEntry], revoked: &[RevokedEntry]) {
|
||||
let dir = repo.join(".relicario");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("devices.json"), serde_json::to_string_pretty(devices).unwrap()).unwrap();
|
||||
fs::write(dir.join("revoked.json"), serde_json::to_string_pretty(revoked).unwrap()).unwrap();
|
||||
git(repo, &["add", ".relicario"], &[]);
|
||||
git(repo, &["commit", "-q", "-m", "device files"], &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registered_non_revoked_key_accepted() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = tmp.path();
|
||||
init_repo(repo);
|
||||
|
||||
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
|
||||
write_device_files(
|
||||
repo,
|
||||
&[DeviceEntry {
|
||||
name: "alice".into(),
|
||||
public_key: pub_a.clone(),
|
||||
added_at: 1_700_000_000,
|
||||
added_by: "bootstrap".into(),
|
||||
}],
|
||||
&[],
|
||||
);
|
||||
|
||||
let allowed = repo.join("test_allowed_signers");
|
||||
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
|
||||
|
||||
let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "x", "a.txt", "hi");
|
||||
|
||||
AssertCommand::cargo_bin("relicario-server")
|
||||
.unwrap()
|
||||
.current_dir(repo)
|
||||
.args(["verify-commit", &sha])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregistered_key_rejected() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = tmp.path();
|
||||
init_repo(repo);
|
||||
|
||||
let (_, _, pub_a) = write_keypair(repo, "alice");
|
||||
let (priv_evil, _, pub_evil) = write_keypair(repo, "evil");
|
||||
|
||||
// Only Alice is registered.
|
||||
write_device_files(
|
||||
repo,
|
||||
&[DeviceEntry {
|
||||
name: "alice".into(),
|
||||
public_key: pub_a.clone(),
|
||||
added_at: 1_700_000_000,
|
||||
added_by: "bootstrap".into(),
|
||||
}],
|
||||
&[],
|
||||
);
|
||||
|
||||
// Evil signs against a file containing both keys so git commit signing works,
|
||||
// but the binary's allowed-signers (from devices.json) only has Alice.
|
||||
let allowed = repo.join("test_allowed_signers");
|
||||
fs::write(
|
||||
&allowed,
|
||||
format!("relicario {}\nrelicario {}\n", pub_a.trim(), pub_evil.trim()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sha = sign_commit(repo, &priv_evil, &allowed, 1_710_000_000, "evil", "a.txt", "hi");
|
||||
|
||||
AssertCommand::cargo_bin("relicario-server")
|
||||
.unwrap()
|
||||
.current_dir(repo)
|
||||
.args(["verify-commit", &sha])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("unregistered"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoked_key_after_revoked_at_rejected() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = tmp.path();
|
||||
init_repo(repo);
|
||||
|
||||
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
|
||||
|
||||
// Alice's entry is only in revoked.json (was removed from devices.json after revocation).
|
||||
write_device_files(
|
||||
repo,
|
||||
&[],
|
||||
&[RevokedEntry {
|
||||
name: "alice".into(),
|
||||
public_key: pub_a.clone(),
|
||||
revoked_at: 1_705_000_000,
|
||||
revoked_by: "admin".into(),
|
||||
}],
|
||||
);
|
||||
|
||||
let allowed = repo.join("test_allowed_signers");
|
||||
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
|
||||
|
||||
// Commit dated AFTER revocation.
|
||||
let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "post", "a.txt", "hi");
|
||||
|
||||
AssertCommand::cargo_bin("relicario-server")
|
||||
.unwrap()
|
||||
.current_dir(repo)
|
||||
.args(["verify-commit", &sha])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("revoked"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoked_key_before_revoked_at_accepted_historical() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = tmp.path();
|
||||
init_repo(repo);
|
||||
|
||||
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
|
||||
|
||||
// Same as above: Alice only in revoked.json.
|
||||
write_device_files(
|
||||
repo,
|
||||
&[],
|
||||
&[RevokedEntry {
|
||||
name: "alice".into(),
|
||||
public_key: pub_a.clone(),
|
||||
revoked_at: 1_705_000_000,
|
||||
revoked_by: "admin".into(),
|
||||
}],
|
||||
);
|
||||
|
||||
let allowed = repo.join("test_allowed_signers");
|
||||
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
|
||||
|
||||
// Commit dated BEFORE revocation -- historical case must pass.
|
||||
let sha = sign_commit(repo, &priv_a, &allowed, 1_700_000_000, "historical", "a.txt", "hi");
|
||||
|
||||
AssertCommand::cargo_bin("relicario-server")
|
||||
.unwrap()
|
||||
.current_dir(repo)
|
||||
.args(["verify-commit", &sha])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
@@ -59,3 +59,34 @@ Without device authentication, access control is transport-layer only:
|
||||
|
||||
Device registration was optional before v0.4.0. With device auth enabled,
|
||||
all commits must be signed by a registered device.
|
||||
|
||||
## Configuration env vars
|
||||
|
||||
Relicario reads the following environment variables. Each is a trust
|
||||
boundary: an attacker who can set them in the user's environment can
|
||||
influence Relicario's behavior. They are listed here for security
|
||||
reviewers to audit the surface in one place.
|
||||
|
||||
### User-facing (active in all builds)
|
||||
|
||||
| Variable | Purpose | Trust |
|
||||
|---|---|---|
|
||||
| `RELICARIO_IMAGE` | Override the reference-image JPEG path used during vault unlock. | Trusted: filesystem path under the user's control. Read-only; its bytes feed `imgsecret::extract_secret`. |
|
||||
| `RELICARIO_GITEA_URL` | Gitea API base URL for `relicario device add`. Equivalent to `--gitea-url`. | Trusted: HTTPS URL. Used only in the device-add code path. |
|
||||
| `RELICARIO_GITEA_TOKEN` | Gitea personal-access token. Equivalent to `--gitea-token`. | **Secret**: anyone who can read this env var can manage the user's deploy keys via the Gitea API. The CLI never logs it. |
|
||||
| `RELICARIO_GITEA_OWNER` | Gitea repository owner (e.g. `alee`). Equivalent to `--owner`. | Trusted: opaque string. |
|
||||
| `RELICARIO_GITEA_REPO` | Gitea repository name (e.g. `vault`). Equivalent to `--repo`. | Trusted: opaque string. |
|
||||
|
||||
### Debug-only (compiled out of `cargo build --release`)
|
||||
|
||||
The following variables are gated behind `cfg(debug_assertions)` and
|
||||
are **no-ops** in release builds. The env-var lookup is removed by the
|
||||
optimiser from any binary built without debug assertions (i.e. the
|
||||
standard `--release` profile).
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `RELICARIO_NO_GROUPS_CACHE` | Suppress the plaintext `groups.cache` write. Developer debugging tool for the cache logic. |
|
||||
| `RELICARIO_TEST_PASSPHRASE` | Bypass the `rpassword` prompt during integration tests. |
|
||||
| `RELICARIO_TEST_ITEM_SECRET` | Bypass the `rpassword` prompt for item-secret fields during integration tests. |
|
||||
| `RELICARIO_TEST_BACKUP_PASSPHRASE` | Bypass the `rpassword` prompt for backup export/restore passphrases during integration tests. |
|
||||
|
||||
Reference in New Issue
Block a user