# 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` returning `SHA256:` (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: ```rust #[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`: ```rust /// 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:` /// (standard base64 without padding). pub fn fingerprint(public_key_openssh: &str) -> Result { 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`: ```rust 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** ```bash 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 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): ```toml [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** ```bash 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 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 ` 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`: ```rust //! 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: 1. Load `devices.json` at the commit (existing logic). 2. Bootstrap: empty/missing -> accept. 3. Load `revoked.json` at the commit (existing logic, unwrap_or_default). 4. Build a temp file `/allowed_signers` containing one line per device entry: `relicario ` (newline-terminated). 5. Spawn `git verify-commit --raw ` with env `GIT_CONFIG_COUNT=1`, `GIT_CONFIG_KEY_0=gpg.ssh.allowedSignersFile`, `GIT_CONFIG_VALUE_0=/allowed_signers`. Capture stderr. 6. If exit code is non-zero -> reject ("git verify-commit failed: "). 7. Parse the SHA256 fingerprint out of stderr. The line we want is `Good "git" signature for relicario with ED25519 key SHA256:`. Match a regex like `key (SHA256:[A-Za-z0-9+/]+)` — first capture group. 8. Build a fingerprint-to-name map from `devices` via `relicario_core::device::fingerprint` (skip entries that fail to parse, with a warning). 9. If the parsed fingerprint isn't in the map -> reject ("signed by unregistered device "). 10. Get committer date: `git show -s --format=%ct `, parse as `i64`. 11. Build a fingerprint -> `revoked_at` map from `revoked` via `fingerprint`. If the parsed fingerprint is in this map AND `committer_ts >= revoked_at` -> reject ("signed by revoked device ''"). 12. Else -> accept. The `let _ = &revoked;` dead code goes away. The `devices.contains` check is the new gate. - [ ] **Step 1: Add `tempfile` to 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]`: ```toml tempfile = "3" regex = "1" ``` (`regex` is needed for fingerprint extraction; alternative is hand-rolled string slicing — regex is clearer.) - [ ] **Step 2: Replace `verify_commit` body** Edit `crates/relicario-server/src/main.rs`. Replace the entire `verify_commit` function (lines 36-81) with: ```rust 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 = 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 = 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: // (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 = 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: ```bash 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)** ```bash 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:` 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 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 the `tar::Builder` API. - (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`: ```rust //! 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 { 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 { 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 { 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 { 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//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-cli` to bin+lib by adding `crates/relicario-cli/src/lib.rs` that `pub use`s the helper module, and let `main.rs` use the lib. - (B) Move `safe_unpack_git_archive` into `relicario-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)>` of cleaned entries and let the CLI write them. **Choose the cleaner variant**: emit a `Vec<(RelPath, Vec)>` 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)>>` in `crates/relicario-core/src/backup.rs` (or a new `crates/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 the `archive.unpack(...)` call) - [ ] **Step 1: Add `tar` to relicario-core dependencies** Edit `crates/relicario-core/Cargo.toml`: ```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`: ```rust //! 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)>> { let mut archive = tar::Archive::new(tar_bytes); let mut out: Vec<(PathBuf, Vec)> = 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 { 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 `BackupRestore` error variant if missing** Read `crates/relicario-core/src/error.rs`. If `BackupRestore(String)` doesn't exist as a variant, add it: ```rust #[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`: ```rust 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.unpack` call in CLI** Edit `crates/relicario-cli/src/main.rs`. Find the block at line 1719-1723: ```rust // .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: ```rust // .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** ```bash 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 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): ```markdown ## 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** ```bash 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 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: ```rust #[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): ```rust pub fn write_groups_cache( vault_dir: &Path, groups: &std::collections::BTreeSet, ) -> std::io::Result<()> { if std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() { return Ok(()); } // ... ``` Change to: ```rust pub fn write_groups_cache( vault_dir: &Path, groups: &std::collections::BTreeSet, ) -> 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: ```rust /// Write the sorted set of group names to `/.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_groups` doc-comment in main.rs** Find lines 170-176ish in `crates/relicario-cli/src/main.rs`: ```rust /// 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: ```rust /// 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: ```bash 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** ```bash 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 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-2a` - `feature/typed-items-1a-rust-core` - `feature/typed-items-1c-alpha` - `feature/typed-items-1c-beta1` - `feature/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 `main` and clean** Run: ```bash 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: ```bash 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: 1. `feature/fullscreen-ux-phase-2a` 2. `feature/typed-items-1a-rust-core` 3. `feature/typed-items-1c-alpha` 4. `feature/typed-items-1c-beta1` 5. `feature/typed-items-1c-beta2` Per branch: a. Run `git log --oneline main.. | 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: ```bash git log -1 --format='%h %s' ``` c. **Prompt the user**: "Delete local branch `` (tip: ` `)? [y/N]" d. On `y` (or `yes`): run `git branch -D `. 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: ```bash 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** ```bash cargo test ``` Expected: all tests pass, including the 4 S1 tests and 5 S2 tests added in this plan. - [ ] **Step 2: All targets build** ```bash 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** ```bash 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** ```bash 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::fingerprint` helper 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_commit` rewritten — 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_archive` implemented in core, CLI restore wired through it (S2) - [ ] Task 7: SECURITY.md "Configuration env vars" section added (S3) - [ ] Task 8: `RELICARIO_NO_GROUPS_CACHE` cfg-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_locked` raw-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