Files
relicario/docs/superpowers/plans/2026-05-02-v0.5.0-plan-a-security-cleanup.md
adlee-was-taken 3caa7af194 docs(plan): v0.5.0 plans A/B and doc audit
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>
2026-05-02 16:03:53 -04:00

1529 lines
60 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```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:<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`:
```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 <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):
```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 <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`:
```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 `<tmp>/allowed_signers` containing one line per device entry: `relicario <pub_key_openssh>` (newline-terminated).
5. Spawn `git verify-commit --raw <commit>` with env `GIT_CONFIG_COUNT=1`, `GIT_CONFIG_KEY_0=gpg.ssh.allowedSignersFile`, `GIT_CONFIG_VALUE_0=<tmp>/allowed_signers`. Capture stderr.
6. If exit code is non-zero -> reject ("git verify-commit failed: <stderr>").
7. 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 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 <fp>").
10. Get committer date: `git show -s --format=%ct <commit>`, 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 '<name>'").
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<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:
```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:<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 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<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-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<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>)>>` 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<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 `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 <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):
```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 <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:
```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<String>,
) -> 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<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:
```rust
/// 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_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 <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-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..<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:
```bash
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:
```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