Plan A (Rust + docs): S1 pre-receive hook fix, S2 tar path-traversal hardening, S3 RELICARIO_* env-var audit, C1 stale branch cleanup. ~9 tasks, ~50 steps. Plan B (extension UX): P4 error-copy centralization (subsumes B2), B1 strength-meter regenerate fix, P1 password coloring (inlined), P3 form-layout envelope, P2 setup → fullscreen tab. ~15 tasks, ~85 steps. Doc audit: 14 findings, 6 fixed inline (README, ARCHITECTURE, overview), 8 proposed for v0.5.0 release prep. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
60 KiB
Plan A — v0.5.0 Polish + Harden — Security + Cleanup (Rust + Docs)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking. TDD discipline throughout: write a failing test, run to confirm fail, implement, run to confirm pass, then commit.
Goal: Land the four Rust + docs items in the v0.5.0 polish-harden bundle: fix the broken relicario-server pre-receive hook (S1, the security anchor), harden the backup-restore tar unpack against path-traversal/symlink/tar-bomb attacks (S2), audit and document RELICARIO_* env vars while moving the dev-only one under cfg(debug_assertions) (S3), and prune merged local feature branches with per-branch user confirmation (C1). Plan A's scope is bounded — items B1, B2, P1-P4 belong to Plan B (Extension UX) and are explicitly out of scope here.
Architecture: S1 rewrites verify_commit to load devices.json and revoked.json at the commit, build a temporary gpg.ssh.allowedSignersFile, run git verify-commit --raw with GIT_CONFIG_* env to point at that file, parse the signing-key SHA256 fingerprint out of the Good "git" signature for relicario with ED25519 key SHA256:... line on stderr, and accept only when the fingerprint matches a registered, non-revoked device (using committer-date for the historical-commit revocation window). S2 swaps tar::Archive::unpack for an explicit per-entry loop with .. / absolute / drive-letter rejection, symlink and hardlink rejection, a 100×-or-1-GiB size cap, and a final canonicalised starts_with(target) check. S3 documents every RELICARIO_* env var in docs/SECURITY.md and the relevant clap doc-comments and cfg(debug_assertions)-gates RELICARIO_NO_GROUPS_CACHE. C1 walks the five known-merged feature branches one-by-one, confirms each is in git branch --merged main output, and prompts the user before each git branch -D.
Tech Stack: Rust (relicario-core, relicario-cli, relicario-server), ssh-key crate (already a relicario-core dependency) for fingerprint computation, tar crate (already a relicario-cli dependency) iterated by hand, tempfile (already a relicario-cli dev-dependency, mirror to relicario-server), assert_cmd (already in relicario-cli's dev-dependencies, mirror to relicario-server).
File Structure
| File | Change | Purpose |
|---|---|---|
crates/relicario-core/src/device.rs |
Modify | Add pub fn fingerprint(public_key_openssh: &str) -> Result<String> returning SHA256:<base64> (S1 needs this; keep in core so server + tests share it) |
crates/relicario-core/src/lib.rs |
Modify | Re-export device::fingerprint |
crates/relicario-server/Cargo.toml |
Modify | Add tempfile, assert_cmd, predicates to [dev-dependencies] |
crates/relicario-server/src/main.rs |
Rewrite verify_commit (S1) |
Allowed-signers temp file, fingerprint extraction, devices/revoked check |
crates/relicario-server/tests/verify_commit.rs |
Create | 4 acceptance integration tests for S1 |
crates/relicario-cli/src/main.rs |
Modify (S2) | Replace archive.unpack(...) at line 1722 with safe_unpack_git_archive call; add the helper above cmd_backup_restore |
crates/relicario-cli/src/helpers.rs |
Modify (S3) | cfg(debug_assertions)-gate the RELICARIO_NO_GROUPS_CACHE check; document |
crates/relicario-cli/src/main.rs |
Modify (S3) | Update the doc-comment on cmd_groups/CLI long-help mentioning the env var to note it's debug-only |
crates/relicario-cli/tests/backup.rs (or new crates/relicario-cli/tests/restore_hardening.rs) |
Modify/Create | 3 S2 acceptance tests + happy-path stays green |
docs/SECURITY.md |
Modify (S3) | Add a "Configuration env vars" section enumerating every RELICARIO_* var with purpose + trust assumption |
| local git branches | Delete (C1) | Five merged feature branches, interactive |
Task 1: S1 — Add device::fingerprint helper to relicario-core
Files:
- Modify:
crates/relicario-core/src/device.rs - Modify:
crates/relicario-core/src/lib.rs
The relicario-server verify_commit rewrite needs to compute SHA256 fingerprints from OpenSSH public keys (so it can match git verify-commit --raw stderr output against devices.json / revoked.json entries). The ssh-key crate already exposes this via PublicKey::fingerprint(HashAlg::Sha256). We add it to core so the server crate (and unit tests) share the same implementation.
- Step 1: Write failing unit test for
fingerprint
Add to crates/relicario-core/src/device.rs tests module:
#[test]
fn fingerprint_matches_ssh_keygen_format() {
// Generate a key, compute fingerprint, and confirm format.
let (_, public) = generate_keypair().unwrap();
let fp = fingerprint(&public).unwrap();
// Format from ssh-keygen -l: "SHA256:<43 chars of url-safe base64 without padding>"
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());
}
- Step 2: Run test to confirm fail
Run: cargo test -p relicario-core device::tests::fingerprint
Expected: compilation error — fingerprint is not defined.
- Step 3: Implement
fingerprint
Add to crates/relicario-core/src/device.rs:
/// Compute the OpenSSH SHA-256 fingerprint of a public key, formatted exactly as
/// `ssh-keygen -lf` and as `git verify-commit --raw` reports it: `SHA256:<base64>`
/// (standard 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())
}
- Step 4: Re-export from lib.rs
In crates/relicario-core/src/lib.rs, find the existing pub use device::... line and add fingerprint:
pub use device::{fingerprint, generate_keypair, sign, verify, DeviceEntry, RevokedEntry};
- Step 5: Run tests
Run: cargo test -p relicario-core device
Expected: all device tests pass including the three new fingerprint tests.
- Step 6: Cross-check fingerprint format against ssh-keygen
Sanity-check that the format matches what git verify-commit --raw actually emits (we captured this during planning — see crates/relicario-server/tests/verify_commit.rs Step 2 below where it gets exercised end-to-end). If the ssh-key crate's Fingerprint::Display emits something different from the SHA256:NX7cg... form, this is the place to find out. Format note for the engineer: ssh-keygen omits trailing = padding from base64, exactly 43 chars for SHA-256.
- Step 7: Commit
git add crates/relicario-core/src/device.rs crates/relicario-core/src/lib.rs
git commit -m "$(cat <<'EOF'
feat(core): add device::fingerprint helper for SSH SHA256 fingerprints
Wraps `ssh-key`'s `PublicKey::fingerprint(HashAlg::Sha256)`. Output
format matches `ssh-keygen -lf` and `git verify-commit --raw` stderr
("SHA256:<43-char base64>"). Used by the upcoming relicario-server
verify-commit rewrite (audit S1).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
Task 2: S1 — Add dev-dependencies to relicario-server
Files:
- Modify:
crates/relicario-server/Cargo.toml
The S1 acceptance tests need assert_cmd to spawn the binary and tempfile to build throwaway repos. Both are already used in relicario-cli — mirror the versions to keep workspace dependency drift minimal.
- Step 1: Confirm versions used by
relicario-cli
Read crates/relicario-cli/Cargo.toml [dev-dependencies]. As of writing: assert_cmd = "2", predicates = "3", tempfile = "3". If any have changed, use the values currently in that file.
- Step 2: Add to relicario-server Cargo.toml
Edit crates/relicario-server/Cargo.toml. Append (or merge if section exists):
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"
- Step 3: Verify it builds
Run: cargo build -p relicario-server --tests
Expected: succeeds (no tests yet, just confirms manifest is valid).
- Step 4: Commit
git add crates/relicario-server/Cargo.toml
git commit -m "$(cat <<'EOF'
chore(server): add assert_cmd/predicates/tempfile dev-deps
Needed for the upcoming verify-commit acceptance suite (audit S1).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
Task 3: S1 — Write the four failing acceptance tests
Files:
- Create:
crates/relicario-server/tests/verify_commit.rs
These tests drive the implementation. They build a real on-disk git repo, generate two ed25519 keypairs, write .relicario/devices.json and .relicario/revoked.json, sign commits with git -c gpg.format=ssh ..., then invoke the relicario-server verify-commit <sha> binary via assert_cmd and assert exit code + stderr.
Reference output formats (captured from a real git -c gpg.format=ssh ... repo during planning):
- Signed by allowed key, exit 0:
Good "git" signature for relicario with ED25519 key SHA256:NX7cgViq2JvCRDYWVun/kiGYDbl5yyNKApg/L5e/ixU - Signed by non-allowed key (still has GOODSIG line!), exit 1:
Good "git" signature with ED25519 key SHA256:BMyEafi4tdXsSdaOwCqdLXq25Xe3I/LzZWG63WCvfBE
No principal matched. - Unsigned commit: empty stderr, exit non-zero from
git verify-commit.
The current code's stderr.contains("Good signature") check is doubly wrong: SSH signatures emit Good "git" signature ..., AND that line shows up even for unknown keys. We need fingerprint matching against devices.json.
- Step 1: Create the test file with helpers
Write crates/relicario-server/tests/verify_commit.rs:
//! Acceptance tests for `relicario-server verify-commit`.
//!
//! These build real on-disk git repos with SSH-signed commits and invoke the
//! binary via `assert_cmd`. They cover the four scenarios from the audit S1
//! acceptance criteria:
//!
//! 1. Revoked key, commit dated AFTER `revoked_at` -> exit 1
//! 2. Unregistered key -> exit 1
//! 3. Registered, non-revoked key -> exit 0
//! 4. Revoked key, commit dated 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 relicario_core::device::{generate_keypair, DeviceEntry, RevokedEntry};
use tempfile::TempDir;
/// Write a private OpenSSH key + matching .pub file into `dir`, returning
/// (private_path, public_path, public_openssh_string).
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)
}
/// Run `git` in `repo` with the given args; panic on non-zero exit.
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");
}
/// Initialise a git repo with an empty initial commit (unsigned, bootstrap).
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"], &[]);
}
/// Sign a commit with `signing_key` against `allowed_signers` and a fixed
/// committer date (unix timestamp). Returns the new commit SHA.
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()
}
/// Write `.relicario/devices.json` and `.relicario/revoked.json`, commit, return SHA.
/// The commit itself is unsigned/bootstrap so we don't recurse into verification.
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_path_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(),
}],
&[],
);
// Allowed-signers file used only by the test fixture's git invocation
// (the binary builds its own at verify time).
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 (_priv_a, _, 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 an allowed-signers file containing both keys (so
// git verify-commit emits a "Good" line); the binary should still reject
// because evil isn't in devices.json.
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(predicates::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 was registered, then revoked at t=1_705_000_000.
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(predicates::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");
// Devices.json (current state) lists Alice as revoked.
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-commit case.
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();
}
- Step 2: Run the suite to confirm all four fail
Run: cargo test -p relicario-server --test verify_commit
Expected: all four tests fail. The current verify_commit accepts everything that has a "Good signature" or "GOODSIG" string — unregistered_key_rejected and revoked_key_after_revoked_at_rejected will pass on a passing-everything implementation (which is the bug), but they assert specific stderr substrings that the current code never emits, so they will fail. registered_non_revoked_key_accepted and revoked_key_before_revoked_at_accepted_historical may or may not pass coincidentally — both should be checked under the new implementation.
NOTE: do not commit yet — these are paired with the implementation in Task 4.
Task 4: S1 — Implement verify_commit
Files:
- Modify:
crates/relicario-server/src/main.rs
Replace the body of verify_commit with the real check. The flow:
- Load
devices.jsonat the commit (existing logic). - Bootstrap: empty/missing -> accept.
- Load
revoked.jsonat the commit (existing logic, unwrap_or_default). - Build a temp file
<tmp>/allowed_signerscontaining one line per device entry:relicario <pub_key_openssh>(newline-terminated). - Spawn
git verify-commit --raw <commit>with envGIT_CONFIG_COUNT=1,GIT_CONFIG_KEY_0=gpg.ssh.allowedSignersFile,GIT_CONFIG_VALUE_0=<tmp>/allowed_signers. Capture stderr. - If exit code is non-zero -> reject ("git verify-commit failed: ").
- Parse the SHA256 fingerprint out of stderr. The line we want is
Good "git" signature for relicario with ED25519 key SHA256:<base64>. Match a regex likekey (SHA256:[A-Za-z0-9+/]+)— first capture group. - Build a fingerprint-to-name map from
devicesviarelicario_core::device::fingerprint(skip entries that fail to parse, with a warning). - If the parsed fingerprint isn't in the map -> reject ("signed by unregistered device ").
- Get committer date:
git show -s --format=%ct <commit>, parse asi64. - Build a fingerprint ->
revoked_atmap fromrevokedviafingerprint. If the parsed fingerprint is in this map ANDcommitter_ts >= revoked_at-> reject ("signed by revoked device ''"). - Else -> accept.
The let _ = &revoked; dead code goes away. The devices.contains check is the new gate.
- Step 1: Add
tempfileto runtime deps (not just dev)
tempfile is already a dev-dependency (Task 2) but the binary needs it at runtime. Edit crates/relicario-server/Cargo.toml [dependencies]:
tempfile = "3"
regex = "1"
(regex is needed for fingerprint extraction; alternative is hand-rolled string slicing — regex is clearer.)
- Step 2: Replace
verify_commitbody
Edit crates/relicario-server/src/main.rs. Replace the entire verify_commit function (lines 36-81) with:
fn verify_commit(commit: &str) -> Result<()> {
// 1. Load devices.json at this commit (bootstrap-tolerant).
let devices_json = match git_show(commit, ".relicario/devices.json") {
Ok(json) => json,
Err(_) => {
eprintln!("OK: commit {commit} (bootstrap - no devices.json)");
return Ok(());
}
};
let devices: Vec<DeviceEntry> = serde_json::from_str(&devices_json)
.context("parse devices.json")?;
// 2. Empty devices.json => bootstrap mode.
if devices.is_empty() {
eprintln!("OK: commit {commit} (bootstrap - empty devices.json)");
return Ok(());
}
// 3. Load 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();
// 4. Build temp allowed-signers file (one entry per registered device).
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 {
// Format per ssh-keygen(1): principal[,principal...] [options] keytype base64key
// We use "relicario" as the single principal (matches what the CLI configures).
allowed_body.push_str("relicario ");
allowed_body.push_str(d.public_key.trim());
allowed_body.push('\n');
}
std::fs::write(&allowed_path, allowed_body).context("write allowed_signers")?;
// 5. Run git verify-commit --raw with our allowed-signers (NOT mutating
// global git config -- use GIT_CONFIG_COUNT/KEY/VALUE override).
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")?;
let stderr = String::from_utf8_lossy(&output.stderr);
// 6. Non-zero exit means: unsigned, bad signature, or signed by a key
// not in our allowed-signers file (which is exactly the registered
// device set). All three are rejection cases.
if !output.status.success() {
eprintln!("REJECT: commit {commit} — git verify-commit failed: {}", stderr.trim());
std::process::exit(1);
}
// 7. Parse the SHA-256 fingerprint of the signing key from stderr.
// SSH signature line format:
// Good "git" signature for relicario with ED25519 key SHA256:<base64>
// (capture the SHA256:... token; it's exactly what device::fingerprint emits.)
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 => {
eprintln!(
"REJECT: commit {commit} — could not parse signing key fingerprint from: {}",
stderr.trim()
);
std::process::exit(1);
}
};
// 8. Build device fingerprint maps.
let mut device_by_fp: std::collections::HashMap<String, &DeviceEntry> =
std::collections::HashMap::new();
for d in &devices {
match relicario_core::device::fingerprint(&d.public_key) {
Ok(fp) => { device_by_fp.insert(fp, d); }
Err(e) => eprintln!(
"warning: skipping unparseable device entry '{}': {e}",
d.name
),
}
}
// 9. Reject if signing key isn't a registered device.
if !device_by_fp.contains_key(&signing_fp) {
eprintln!(
"REJECT: commit {commit} — signed by unregistered device (fingerprint {signing_fp})"
);
std::process::exit(1);
}
// 10. Get committer date (NOT author date — committer date is when the
// signature was applied; author date can be backdated by an attacker).
let ct_out = Command::new("git")
.args(["show", "-s", "--format=%ct", commit])
.output()
.context("git show committer date")?;
if !ct_out.status.success() {
anyhow::bail!("git show committer date failed for {commit}");
}
let committer_ts: i64 = String::from_utf8_lossy(&ct_out.stdout)
.trim()
.parse()
.context("parse committer timestamp")?;
// 11. Reject if signing key is revoked AND commit is at-or-after revocation.
// Historical commits (committer_ts < revoked_at) survive the revoke.
for r in &revoked {
let r_fp = match relicario_core::device::fingerprint(&r.public_key) {
Ok(fp) => fp,
Err(_) => continue,
};
if r_fp == signing_fp && 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);
}
}
eprintln!("OK: commit {commit} verified (signed by '{}')", device_by_fp[&signing_fp].name);
Ok(())
}
- Step 3: Run the four acceptance tests
Run: cargo test -p relicario-server --test verify_commit
Expected: all four pass.
- Step 4: Run the rest of the workspace tests to confirm no regression
Run: cargo test
Expected: all tests still pass.
- Step 5: Manual sanity check (optional but good)
Build the binary and inspect a known-bad case:
cargo build -p relicario-server
# (binary at target/debug/relicario-server)
Then either run the test fixture by hand or skip — the four tests cover the four spec scenarios.
- Step 6: Commit (S1 implementation + tests together)
git add crates/relicario-server/Cargo.toml \
crates/relicario-server/src/main.rs \
crates/relicario-server/tests/verify_commit.rs
git commit -m "$(cat <<'EOF'
fix(server): real signature verification in pre-receive hook (audit S1)
`verify_commit` previously loaded devices.json/revoked.json and threw
both away, accepting any commit whose stderr contained "GOODSIG" or
"Good signature". This left device registration and revocation as no-ops:
unregistered keys could push, and revoked keys kept working forever.
The fix:
- Build a temp `gpg.ssh.allowedSignersFile` from devices.json at the
commit, passed via GIT_CONFIG_COUNT/KEY/VALUE so we never mutate
global git config.
- Run `git verify-commit --raw` and require zero exit.
- Parse `SHA256:<base64>` fingerprint out of the SSH "Good" line.
- Reject if fingerprint isn't a registered device.
- Reject if fingerprint is in revoked.json AND committer date >=
revoked_at. Historical commits (committer_ts < revoked_at) still
verify — old history survives revocation.
Acceptance: 4 integration tests covering the matrix
(registered/unregistered x revoked/non-revoked, with revoked split into
post- and pre-revocation cases).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
Task 5: S2 — Write failing tests for path-traversal hardening
Files:
- Create:
crates/relicario-cli/tests/restore_hardening.rs
The current cmd_backup_restore calls tar::Archive::unpack(target.join(".git")) blindly. We need three failing tests that craft malicious tar payloads inside a .relbak, then assert restore exits with an error and never writes outside the target directory.
The simplest path: factor the tar-extraction into a safe_unpack_git_archive(tar_bytes: &[u8], dest: &Path, max_size: u64) -> Result<()> function and unit-test that directly. Plus one CLI integration test for the happy path.
- Step 1: Decide test layer
Two options:
- (A) Pure-Rust unit test of
safe_unpack_git_archive, hand-crafting tar entries with thetar::BuilderAPI. - (B) End-to-end test: build a malicious
.relbak, run the CLI restore command, assert error.
Pick A — fewer moving parts, easier to control payload exactly. The happy-path E2E case is already covered by existing backup roundtrip tests.
- Step 2: Create the test file
Write crates/relicario-cli/tests/restore_hardening.rs:
//! Acceptance tests for tar-archive hardening on backup restore (audit S2).
//!
//! Three malicious-input scenarios that the unpacker must refuse:
//! 1. Path traversal: an entry whose path contains `..`
//! 2. Symlink: a symlink entry pointing outside the destination
//! 3. Tar bomb: total uncompressed size exceeds the cap
use std::io::Write;
use std::path::PathBuf;
use tar::{Builder, Header};
use tempfile::TempDir;
// The function under test lives in the CLI binary crate. Easiest way to
// call it from an integration test: re-export via a shim. See
// `crates/relicario-cli/src/main.rs` -- we add `pub` on the helper and
// expose it through a small `pub mod restore_hardening` module wired
// into `lib.rs`. If `relicario-cli` has no `lib.rs` yet, create one
// re-exporting the function.
use relicario_cli::restore_hardening::safe_unpack_git_archive;
fn build_traversal_tar() -> Vec<u8> {
let mut buf = Vec::new();
{
let mut b = Builder::new(&mut buf);
let mut h = Header::new_gnu();
h.set_size(4);
h.set_mode(0o644);
h.set_cksum();
b.append_data(&mut h, "../../escaped.txt", &b"hax!"[..]).unwrap();
b.finish().unwrap();
}
buf
}
fn build_absolute_path_tar() -> Vec<u8> {
let mut buf = Vec::new();
{
let mut b = Builder::new(&mut buf);
let mut h = Header::new_gnu();
h.set_size(4);
h.set_mode(0o644);
h.set_cksum();
b.append_data(&mut h, "/etc/escaped.txt", &b"hax!"[..]).unwrap();
b.finish().unwrap();
}
buf
}
fn build_symlink_tar() -> Vec<u8> {
let mut buf = Vec::new();
{
let mut b = Builder::new(&mut buf);
let mut h = Header::new_gnu();
h.set_entry_type(tar::EntryType::Symlink);
h.set_size(0);
h.set_mode(0o777);
h.set_cksum();
// link target points outside dest
b.append_link(&mut h, "evil-link", "/etc/passwd").unwrap();
b.finish().unwrap();
}
buf
}
fn build_oversize_tar() -> Vec<u8> {
let mut buf = Vec::new();
{
let mut b = Builder::new(&mut buf);
let mut h = Header::new_gnu();
// Claim 2 GiB but only write a few bytes -- the size cap should
// trip on the header before unpacking the body.
h.set_size(2 * 1024 * 1024 * 1024);
h.set_mode(0o644);
h.set_cksum();
// append_data needs to write the claimed bytes; instead we use
// append() which respects the header size and read from a sparse
// source.
let zeros = std::io::Cursor::new(vec![0u8; 4096]);
// For test purposes: just write a header with a huge claimed size
// and a small body; the cap should reject before reading.
b.append(&h, std::io::Read::take(zeros, 4096)).unwrap();
b.finish().ok();
}
buf
}
#[test]
fn restore_rejects_path_traversal() {
let dest = TempDir::new().unwrap();
let bytes = build_traversal_tar();
let err = safe_unpack_git_archive(&bytes, dest.path(), 1024 * 1024).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("path traversal") || msg.contains(".."),
"expected path-traversal rejection, got: {msg}");
// And: the escape destination must not exist.
let escaped = dest.path().parent().unwrap().join("escaped.txt");
assert!(!escaped.exists(), "traversal succeeded -- {} exists", escaped.display());
}
#[test]
fn restore_rejects_absolute_path() {
let dest = TempDir::new().unwrap();
let bytes = build_absolute_path_tar();
let err = safe_unpack_git_archive(&bytes, dest.path(), 1024 * 1024).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("absolute") || msg.contains("path traversal"),
"expected absolute-path rejection, got: {msg}");
}
#[test]
fn restore_rejects_symlink() {
let dest = TempDir::new().unwrap();
let bytes = build_symlink_tar();
let err = safe_unpack_git_archive(&bytes, dest.path(), 1024 * 1024).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("symlink") || msg.contains("link"),
"expected symlink rejection, got: {msg}");
}
#[test]
fn restore_rejects_size_bomb() {
let dest = TempDir::new().unwrap();
let bytes = build_oversize_tar();
// Cap of 1 KiB; entry claims 2 GiB.
let err = safe_unpack_git_archive(&bytes, dest.path(), 1024).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("size") || msg.contains("cap") || msg.contains("too large"),
"expected size-cap rejection, got: {msg}");
}
#[test]
fn restore_accepts_normal_files() {
let dest = TempDir::new().unwrap();
// Build a tiny well-formed tar with one regular file in subdir/.
let mut buf = Vec::new();
{
let mut b = Builder::new(&mut buf);
let mut h = Header::new_gnu();
h.set_size(5);
h.set_mode(0o644);
h.set_cksum();
b.append_data(&mut h, "subdir/hello.txt", &b"hello"[..]).unwrap();
b.finish().unwrap();
}
safe_unpack_git_archive(&buf, dest.path(), 1024 * 1024).expect("happy path");
let written = dest.path().join("subdir").join("hello.txt");
assert!(written.exists());
assert_eq!(std::fs::read(&written).unwrap(), b"hello");
}
NOTE: integration tests in crates/<x>/tests/ can only access pub items from the crate's library target. relicario-cli is currently binary-only. We have two options:
- (A) Convert
relicario-clito bin+lib by addingcrates/relicario-cli/src/lib.rsthatpub uses the helper module, and letmain.rsuse the lib. - (B) Move
safe_unpack_git_archiveintorelicario-core(since it's pure logic with no CLI deps) and unit-test it there.
Pick B — it belongs in core (no filesystem-trust assumptions are CLI-specific), it keeps relicario-cli binary-only, and it matches the project's "core is bytes-in/bytes-out" principle. The function takes &[u8] for the tar bytes and &Path for the destination, which is acceptable since it does fs writes. Or, more strictly: have the function emit Vec<(PathBuf, Vec<u8>)> of cleaned entries and let the CLI write them. Choose the cleaner variant: emit a Vec<(RelPath, Vec<u8>)> from core, let the CLI write.
Adjust the tests accordingly:
- Test target:
crates/relicario-core/tests/safe_unpack.rs(new file). - Function under test:
pub fn safe_unpack_git_archive(tar_bytes: &[u8], max_uncompressed_bytes: u64) -> Result<Vec<(PathBuf, Vec<u8>)>>incrates/relicario-core/src/backup.rs(or a newcrates/relicario-core/src/tar_safe.rs).
Move the test file to crates/relicario-core/tests/safe_unpack.rs and adjust the assertions: instead of dest.path()-based checks, assert the returned Vec is Err for malicious cases and contains the expected entry for the happy path.
- Step 3: Run tests to confirm fail
Run: cargo test -p relicario-core --test safe_unpack
Expected: compile error — safe_unpack_git_archive doesn't exist yet.
NOTE: do not commit yet — paired with the implementation in Task 6.
Task 6: S2 — Implement safe_unpack_git_archive in core, wire into CLI
Files:
-
Create or modify:
crates/relicario-core/src/tar_safe.rs(new module) -
Modify:
crates/relicario-core/src/lib.rs(export module) -
Modify:
crates/relicario-cli/src/main.rs(replace thearchive.unpack(...)call) -
Step 1: Add
tarto relicario-core dependencies
Edit crates/relicario-core/Cargo.toml:
tar = { version = "0.4", default-features = false }
(Same version as relicario-cli already uses — keep workspace consistent.)
- Step 2: Implement the function
Write crates/relicario-core/src/tar_safe.rs:
//! Hardened tar archive extraction used by backup restore.
//!
//! Defends against:
//! - Path traversal: entries with `..` components.
//! - Absolute paths and Windows drive letters.
//! - Symlinks and hardlinks (we never legitimately create either inside
//! `.git/` on restore).
//! - Tar bombs: caller provides an uncompressed-size cap.
use std::io::Read;
use std::path::{Component, Path, PathBuf};
use crate::error::{RelicarioError, Result};
/// Maximum default cap if the caller doesn't have a better number: 1 GiB.
pub const DEFAULT_MAX_UNCOMPRESSED: u64 = 1024 * 1024 * 1024;
/// Decode `tar_bytes` and return a list of (relative-path, contents) pairs
/// for regular files only. Caller writes them to disk.
///
/// Any malicious-looking entry causes the entire operation to fail. We do
/// not partially-extract.
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 mut out: Vec<(PathBuf, Vec<u8>)> = Vec::new();
let mut total_size: u64 = 0;
for entry in archive.entries().map_err(|e| RelicarioError::BackupRestore(format!("tar entries: {e}")))? {
let mut entry = entry.map_err(|e| RelicarioError::BackupRestore(format!("tar entry: {e}")))?;
// 1. Reject non-regular entries outright.
match entry.header().entry_type() {
tar::EntryType::Regular | tar::EntryType::Continuous | tar::EntryType::GNUSparse => {}
tar::EntryType::Directory => continue, // dirs are implicit in file paths
tar::EntryType::Symlink | tar::EntryType::Link => {
return Err(RelicarioError::BackupRestore(format!(
"rejecting symlink/hardlink entry: {} (path traversal blocked)",
entry.path().map(|p| p.display().to_string()).unwrap_or_default()
)));
}
other => {
return Err(RelicarioError::BackupRestore(format!(
"rejecting unexpected tar entry type {other:?} (path traversal blocked)"
)));
}
}
// 2. Size cap. Check declared size in header before reading the body.
let claimed = entry.header().size().map_err(|e| {
RelicarioError::BackupRestore(format!("tar header size: {e}"))
})?;
if claimed > max_uncompressed_bytes {
return Err(RelicarioError::BackupRestore(format!(
"tar entry declared size {claimed} bytes exceeds cap {max_uncompressed_bytes}"
)));
}
total_size = total_size.saturating_add(claimed);
if total_size > max_uncompressed_bytes {
return Err(RelicarioError::BackupRestore(format!(
"tar entries total size {total_size} bytes exceeds cap {max_uncompressed_bytes}"
)));
}
// 3. Path validation.
let raw_path = entry.path()
.map_err(|e| RelicarioError::BackupRestore(format!("tar entry path: {e}")))?
.into_owned();
let cleaned = validate_relative_path(&raw_path)?;
// 4. Read body (bounded by the size cap above).
let mut body = Vec::with_capacity(claimed as usize);
entry.read_to_end(&mut body)
.map_err(|e| RelicarioError::BackupRestore(format!("read tar entry body: {e}")))?;
out.push((cleaned, body));
}
Ok(out)
}
/// Reject any path that contains `..`, an absolute prefix, a Windows drive
/// letter, or a non-normal component. Returns a path made of only `Normal`
/// components.
fn validate_relative_path(p: &Path) -> Result<PathBuf> {
let mut cleaned = PathBuf::new();
for comp in p.components() {
match comp {
Component::Normal(s) => cleaned.push(s),
Component::CurDir => {} // "./foo" -> "foo"
Component::ParentDir => {
return Err(RelicarioError::BackupRestore(format!(
"tar entry contains `..` component: {} (path traversal blocked)",
p.display()
)));
}
Component::RootDir => {
return Err(RelicarioError::BackupRestore(format!(
"tar entry has absolute path: {} (path traversal blocked)",
p.display()
)));
}
Component::Prefix(_) => {
// Windows drive letter or UNC prefix.
return Err(RelicarioError::BackupRestore(format!(
"tar entry has drive/UNC prefix: {} (path traversal blocked)",
p.display()
)));
}
}
}
if cleaned.as_os_str().is_empty() {
return Err(RelicarioError::BackupRestore(format!(
"tar entry has empty path after normalisation: {}",
p.display()
)));
}
Ok(cleaned)
}
- Step 3: Add the
BackupRestoreerror variant if missing
Read crates/relicario-core/src/error.rs. If BackupRestore(String) doesn't exist as a variant, add it:
#[error("backup restore: {0}")]
BackupRestore(String),
If a similar variant already exists (e.g. Backup, Restore, Tar), use that instead and adjust the function above.
- Step 4: Wire module into lib.rs
Edit crates/relicario-core/src/lib.rs:
pub mod tar_safe;
pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED};
- Step 5: Run unit tests for the function
Run: cargo test -p relicario-core --test safe_unpack
Expected: all 5 tests pass (4 reject cases + 1 happy path).
- Step 6: Replace the unsafe
archive.unpackcall in CLI
Edit crates/relicario-cli/src/main.rs. Find the block at line 1719-1723:
// .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/")?;
} else {
Replace with:
// .git/ history. Hardened against path traversal, symlinks, and tar bombs.
if let Some(tar_bytes) = &unpacked.git_archive {
// Cap: 100x the compressed bundle, or 1 GiB, whichever is lower.
// 100x is a generous heuristic; legitimate .git pack data
// compresses 3-10x, so 100x leaves room for unusual repos.
let cap = std::cmp::min(
(tar_bytes.len() as u64).saturating_mul(100),
relicario_core::tar_safe::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);
// Final paranoid check: ensure dest is inside git_dir even after
// OS-level resolution. We did the textual check in core; this
// catches any platform-specific quirk.
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!("failed to create parent {}", parent.display())
})?;
}
fs::write(&dest, &body).with_context(|| {
format!("failed to write {}", dest.display())
})?;
}
} else {
- Step 7: Run the full test suite
Run: cargo test
Expected: all tests pass — including the existing happy-path backup roundtrip in crates/relicario-cli/tests/backup.rs (or wherever the existing restore test lives — confirm it's still green).
- Step 8: Commit
git add crates/relicario-core/Cargo.toml \
crates/relicario-core/src/tar_safe.rs \
crates/relicario-core/src/lib.rs \
crates/relicario-core/src/error.rs \
crates/relicario-core/tests/safe_unpack.rs \
crates/relicario-cli/src/main.rs
git commit -m "$(cat <<'EOF'
fix(core,cli): harden backup-restore tar unpack (audit S2)
`cmd_backup_restore` previously called tar::Archive::unpack on the
embedded .git archive with default settings, trusting the tar crate's
defaults to reject malicious payloads. They don't reject hardlinks,
they don't enforce size caps, and an attacker-crafted .relbak with
`../../etc/passwd` entries could escape the target directory.
Replaced with `relicario_core::safe_unpack_git_archive`, which:
- Rejects entries with `..` components, absolute paths, or Windows
drive prefixes (Component::ParentDir/RootDir/Prefix).
- Rejects symlinks and hardlinks outright -- legitimate .git/ tar
archives produced by `relicario backup export` only contain regular
files and directories.
- Enforces a max-uncompressed-bytes cap (caller picks; CLI uses
min(100x compressed, 1 GiB)).
- Returns (path, bytes) pairs to the caller; the CLI writes them and
re-checks `dest.starts_with(git_dir)` for OS-level paranoia.
Acceptance: 5 unit tests in relicario-core (path traversal, absolute
path, symlink, size bomb, happy path) plus existing CLI restore
roundtrip stays green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
Task 7: S3 — Audit RELICARIO_* env vars and document them in SECURITY.md
Files:
- Modify:
docs/SECURITY.md
The full set of RELICARIO_* env vars in production code (after the test-gate work in the previous device-auth plan):
| Var | Site | Trust |
|---|---|---|
RELICARIO_VAULT |
crates/relicario-cli/src/helpers.rs:vault_dir (referenced via doc-comment at line 172) |
User-trusted: vault-root override |
RELICARIO_IMAGE |
crates/relicario-cli/src/session.rs:125 |
User-trusted: reference-image path override |
RELICARIO_NO_GROUPS_CACHE |
crates/relicario-cli/src/helpers.rs:103 |
Dev-only — to be cfg-gated in Task 8 |
RELICARIO_GITEA_URL |
crates/relicario-cli/src/main.rs:2298 |
User config (Gitea base URL) |
RELICARIO_GITEA_TOKEN |
crates/relicario-cli/src/main.rs:2303 |
User secret (Gitea API token) |
RELICARIO_GITEA_OWNER |
crates/relicario-cli/src/main.rs:2308 |
User config (repo owner) |
RELICARIO_GITEA_REPO |
crates/relicario-cli/src/main.rs:2313 |
User config (repo name) |
Plus the cfg(test)-gated RELICARIO_TEST_* set (already gated by the device-auth plan): RELICARIO_TEST_PASSPHRASE, RELICARIO_TEST_ITEM_SECRET, RELICARIO_TEST_BACKUP_PASSPHRASE, RELICARIO_TEST_ITEM_PASSWORD. These are test-binary-only and should be documented as such (so security reviewers don't flag them again).
Confirm this list with a grep before writing — actual sites win over the spec's three-item list.
- Step 1: Verify the list
Run: grep -rn 'RELICARIO_' crates/ | grep -v '/target/' | grep -v '/tests/' | grep 'env::'
Expected: matches the table above (give or take RELICARIO_TEST_* which is what we're auditing). Update the table if any new vars have appeared since this plan was drafted.
- Step 2: Append a "Configuration env vars" section to SECURITY.md
Edit docs/SECURITY.md. Add at the bottom (after the existing "Access Control" section):
## Configuration env vars
Relicario reads the following environment variables. Each one is a
trust boundary: an attacker who can set them in the user's environment
can influence Relicario's behavior. They are listed here so security
reviewers can audit the surface in one place.
### User-facing
| Variable | Purpose | Trust |
|---|---|---|
| `RELICARIO_VAULT` | Override the vault root directory (default: `~/.relicario`). | Trusted: filesystem path under the user's control. |
| `RELICARIO_IMAGE` | Override the reference-image JPEG path used during unlock. | Trusted: filesystem path under the user's control. The image file is read-only; its bytes feed `imgsecret::extract_secret`. |
| `RELICARIO_GITEA_URL` | Default 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 Gitea. The CLI does not log it. |
| `RELICARIO_GITEA_OWNER` | Repo owner (e.g. `alee`). Equivalent to `--owner`. | Trusted: opaque string. |
| `RELICARIO_GITEA_REPO` | Repo name (e.g. `vault`). Equivalent to `--repo`. | Trusted: opaque string. |
### Test-only (compiled out of release builds)
The following are gated behind `#[cfg(test)]` and **do not exist** in
binaries built without the test profile (`cargo build --release`):
| Variable | Purpose |
|---|---|
| `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 prompt for backup export/restore passphrases during integration tests. |
| `RELICARIO_TEST_ITEM_PASSWORD` | Bypass the rpassword prompt for legacy item-password fields during integration tests. |
### Dev-only (compiled out of release builds via `cfg(debug_assertions)`)
| Variable | Purpose |
|---|---|
| `RELICARIO_NO_GROUPS_CACHE` | Disables the plaintext groups cache used for shell-completion. Intended for developers debugging the cache logic. Compiled out of `cargo build --release` builds. |
- Step 3: Commit
git add docs/SECURITY.md
git commit -m "$(cat <<'EOF'
docs: enumerate RELICARIO_* env vars in SECURITY.md (audit S3)
Adds a "Configuration env vars" section listing every RELICARIO_*
variable read by production code, with purpose and trust boundary.
Splits user-facing vars from test-only and dev-only ones to make the
attack surface explicit for security reviewers.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
Task 8: S3 — cfg(debug_assertions)-gate RELICARIO_NO_GROUPS_CACHE
Files:
- Modify:
crates/relicario-cli/src/helpers.rs - Modify:
crates/relicario-cli/src/main.rs(doc-comment at line 174)
RELICARIO_NO_GROUPS_CACHE is a developer escape hatch for debugging the groups-cache logic, not a user knob. Gating it with cfg(debug_assertions) makes the env-var read no-op in cargo build --release (since debug assertions are off in release by default), which means strings on the release binary won't show the variable name and the env-var lookup is dead-code-eliminated.
- Step 1: Write a build-mode-aware test
In crates/relicario-cli/src/helpers.rs, find the existing tests module (or add one) and append:
#[cfg(test)]
mod tests {
// ... existing tests ...
/// In debug builds (which `cargo test` always is) the override should still
/// work so existing dev workflows keep functioning.
#[test]
#[cfg(debug_assertions)]
fn no_groups_cache_env_var_honored_in_debug() {
use std::collections::BTreeSet;
let tmp = tempfile::tempdir().unwrap();
std::env::set_var("RELICARIO_NO_GROUPS_CACHE", "1");
super::write_groups_cache(tmp.path(), &BTreeSet::new()).unwrap();
std::env::remove_var("RELICARIO_NO_GROUPS_CACHE");
assert!(!tmp.path().join(".relicario/groups.cache").exists(),
"env var should suppress the cache write in debug builds");
}
}
- Step 2: Run test to confirm baseline (still passes)
Run: cargo test -p relicario-cli helpers::tests::no_groups_cache_env_var_honored_in_debug
Expected: passes — current code already honours the var in debug.
- Step 3: Gate the env-var check
Edit crates/relicario-cli/src/helpers.rs. Find the function (line 99-115ish):
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() {
return Ok(());
}
// ...
Change to:
pub fn write_groups_cache(
vault_dir: &Path,
groups: &std::collections::BTreeSet<String>,
) -> std::io::Result<()> {
// Dev-only escape hatch. Compiled out of release builds: the
// const below is `false` under `cargo build --release`, which lets
// the optimiser drop the env-var lookup entirely (so `strings` on
// a release binary won't reveal the variable name).
if cfg!(debug_assertions)
&& std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some()
{
return Ok(());
}
// ... rest unchanged
Update the doc-comment a few lines up to reflect the new behaviour:
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
/// one name per line. In debug builds, setting `RELICARIO_NO_GROUPS_CACHE`
/// suppresses the write (used by tests). In release builds the env var is
/// ignored.
- Step 4: Run the test again and the rest of the suite
Run: cargo test -p relicario-cli
Expected: all tests pass.
- Step 5: Update the
cmd_groupsdoc-comment in main.rs
Find lines 170-176ish in crates/relicario-cli/src/main.rs:
/// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file,
/// ...
/// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion
/// will fall back to vault-locked behaviour).
Adjust to reflect that RELICARIO_NO_GROUPS_CACHE is debug-only:
/// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file,
/// ...
/// In debug builds only, set `RELICARIO_NO_GROUPS_CACHE=1` to opt out
/// of the cache (completion will fall back to vault-locked behaviour).
/// The env var is a no-op in release builds.
- Step 6: Verify the variable is gone from a release binary
Run:
cargo build -p relicario-cli --release
strings target/release/relicario | grep RELICARIO_NO_GROUPS_CACHE && echo "FAIL: still present" || echo "OK: not present"
Expected: OK: not present.
NOTE: cfg!(debug_assertions) evaluates to a bool literal at build time. Combined with &&, the optimiser sees if false && ... in release and dead-codes the whole branch including the env-var lookup. If strings somehow still finds it (e.g. due to a stray doc-string referencing the name in #[command(...)] long-help), that's expected — what matters is no env-var read at runtime.
- Step 7: Commit
git add crates/relicario-cli/src/helpers.rs crates/relicario-cli/src/main.rs
git commit -m "$(cat <<'EOF'
fix(cli): cfg-gate RELICARIO_NO_GROUPS_CACHE to debug builds (audit S3)
The groups-cache opt-out is a developer debugging knob, not a user-facing
config. Gating its env-var lookup behind `cfg!(debug_assertions)` makes
release builds ignore the variable entirely; the optimiser drops the
lookup, removing the variable name from the release binary's strings.
Doc-comments updated to reflect the new behaviour.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
Task 9: C1 — Prune merged local feature branches (interactive)
Files:
- local git state only — no source files changed.
The five branches to evaluate (from the spec):
feature/fullscreen-ux-phase-2afeature/typed-items-1a-rust-corefeature/typed-items-1c-alphafeature/typed-items-1c-beta1feature/typed-items-1c-beta2
This task is destructive (git branch -D). The project rule (CLAUDE.md: "Always pause and ask before ... git branch -D") applies — prompt the user before each delete.
- Step 1: Confirm we're on
mainand clean
Run:
git status
git branch --show-current
Expected: branch is main. Working tree state is the user's call — Cargo.lock is currently modified but that doesn't block branch deletion.
- Step 2: List merged branches
Run:
git branch --merged main
Expected output should include all five target branches plus * main. If any are missing, do NOT delete that one — it has unmerged commits. Surface this to the user before continuing.
- Step 3: For each branch, confirm-then-delete one at a time
For each of the five branches in this exact order:
feature/fullscreen-ux-phase-2afeature/typed-items-1a-rust-corefeature/typed-items-1c-alphafeature/typed-items-1c-beta1feature/typed-items-1c-beta2
Per branch:
a. Run git log --oneline main..<branch> | head -5 to show the user what's not in main from this branch's perspective. Empty output = fully merged. Non-empty = unmerged commits — STOP and surface to the user, do not delete.
b. Show the user the tip commit for context:
git log -1 --format='%h %s' <branch>
c. Prompt the user: "Delete local branch <branch> (tip: <sha> <subject>)? [y/N]"
d. On y (or yes): run git branch -D <branch>. On anything else: skip and continue to the next.
e. After delete, run git branch to confirm the branch is gone.
- Step 4: Verify final state
Run:
git branch
Expected: only * main remains (assuming the user said yes to all five). If any branches remain, that's fine — the user chose to keep them.
- Step 5: No commit needed
Branch deletions don't produce commits. There is nothing to commit for C1; the task ends here.
- Step 6: Surface a summary to the caller
Print a one-line summary: "C1 done. Pruned N of 5 merged feature branches." (where N is whatever the user accepted).
Acceptance — final verification
- Step 1: All Rust tests green
cargo test
Expected: all tests pass, including the 4 S1 tests and 5 S2 tests added in this plan.
- Step 2: All targets build
cargo build
cargo build -p relicario-wasm --target wasm32-unknown-unknown
cargo build -p relicario-server
cargo build -p relicario-cli --release
Expected: all succeed.
- Step 3: Release binary doesn't reference the dev env var
strings target/release/relicario | grep RELICARIO_NO_GROUPS_CACHE && echo "FAIL" || echo "OK"
Expected: OK.
- Step 4: SECURITY.md is up to date
Confirm docs/SECURITY.md lists every RELICARIO_* var that appears in grep -rn 'env::var' crates/ | grep RELICARIO.
- Step 5: No stale local feature branches
git branch
Expected: matches the user's choices in Task 9.
- Step 6: Hand off to user for review and merge
Summarise the four security/cleanup landings (S1, S2, S3, C1), point at the spec, and stop. The user merges to main and tags v0.5.0 after Plan B is also green.
Completion Checklist
- Task 1:
device::fingerprinthelper added to relicario-core (S1 prep) - Task 2: relicario-server dev-dependencies added (S1 prep)
- Task 3: 4 failing acceptance tests for verify-commit (S1)
- Task 4:
verify_commitrewritten — allowed-signers temp file, fingerprint matching, devices/revoked check with committer-date semantics (S1) - Task 5: 5 failing tests for
safe_unpack_git_archive(S2) - Task 6:
safe_unpack_git_archiveimplemented in core, CLI restore wired through it (S2) - Task 7: SECURITY.md "Configuration env vars" section added (S3)
- Task 8:
RELICARIO_NO_GROUPS_CACHEcfg-gated to debug builds (S3) - Task 9: Merged local feature branches pruned interactively (C1)
- Final acceptance walk done and Plan A handed off
Out of Scope (do not implement here)
These belong to Plan B (Extension UX), tracked separately at
docs/superpowers/plans/2026-05-02-v0.5.0-plan-b-extension-ux.md (TBD by the
Plan B author):
- B1 — Strength-meter desync after regenerate (extension/TS)
- B2/P4 — Error-message audit (
vault_lockedraw-code leak), ERROR_COPY map - P1 — Password coloring
- P2 — Setup-wizard completion routes to fullscreen vault tab
- P3 — Form-layout 2-col -> full-width transition