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

60 KiB
Raw Permalink Blame History

Plan A — v0.5.0 Polish + Harden — Security + Cleanup (Rust + Docs)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. TDD discipline throughout: write a failing test, run to confirm fail, implement, run to confirm pass, then commit.

Goal: Land the four Rust + docs items in the v0.5.0 polish-harden bundle: fix the broken relicario-server pre-receive hook (S1, the security anchor), harden the backup-restore tar unpack against path-traversal/symlink/tar-bomb attacks (S2), audit and document RELICARIO_* env vars while moving the dev-only one under cfg(debug_assertions) (S3), and prune merged local feature branches with per-branch user confirmation (C1). Plan A's scope is bounded — items B1, B2, P1-P4 belong to Plan B (Extension UX) and are explicitly out of scope here.

Architecture: S1 rewrites verify_commit to load devices.json and revoked.json at the commit, build a temporary gpg.ssh.allowedSignersFile, run git verify-commit --raw with GIT_CONFIG_* env to point at that file, parse the signing-key SHA256 fingerprint out of the Good "git" signature for relicario with ED25519 key SHA256:... line on stderr, and accept only when the fingerprint matches a registered, non-revoked device (using committer-date for the historical-commit revocation window). S2 swaps tar::Archive::unpack for an explicit per-entry loop with .. / absolute / drive-letter rejection, symlink and hardlink rejection, a 100×-or-1-GiB size cap, and a final canonicalised starts_with(target) check. S3 documents every RELICARIO_* env var in docs/SECURITY.md and the relevant clap doc-comments and cfg(debug_assertions)-gates RELICARIO_NO_GROUPS_CACHE. C1 walks the five known-merged feature branches one-by-one, confirms each is in git branch --merged main output, and prompts the user before each git branch -D.

Tech Stack: Rust (relicario-core, relicario-cli, relicario-server), ssh-key crate (already a relicario-core dependency) for fingerprint computation, tar crate (already a relicario-cli dependency) iterated by hand, tempfile (already a relicario-cli dev-dependency, mirror to relicario-server), assert_cmd (already in relicario-cli's dev-dependencies, mirror to relicario-server).


File Structure

File Change Purpose
crates/relicario-core/src/device.rs Modify Add pub fn fingerprint(public_key_openssh: &str) -> Result<String> returning SHA256:<base64> (S1 needs this; keep in core so server + tests share it)
crates/relicario-core/src/lib.rs Modify Re-export device::fingerprint
crates/relicario-server/Cargo.toml Modify Add tempfile, assert_cmd, predicates to [dev-dependencies]
crates/relicario-server/src/main.rs Rewrite verify_commit (S1) Allowed-signers temp file, fingerprint extraction, devices/revoked check
crates/relicario-server/tests/verify_commit.rs Create 4 acceptance integration tests for S1
crates/relicario-cli/src/main.rs Modify (S2) Replace archive.unpack(...) at line 1722 with safe_unpack_git_archive call; add the helper above cmd_backup_restore
crates/relicario-cli/src/helpers.rs Modify (S3) cfg(debug_assertions)-gate the RELICARIO_NO_GROUPS_CACHE check; document
crates/relicario-cli/src/main.rs Modify (S3) Update the doc-comment on cmd_groups/CLI long-help mentioning the env var to note it's debug-only
crates/relicario-cli/tests/backup.rs (or new crates/relicario-cli/tests/restore_hardening.rs) Modify/Create 3 S2 acceptance tests + happy-path stays green
docs/SECURITY.md Modify (S3) Add a "Configuration env vars" section enumerating every RELICARIO_* var with purpose + trust assumption
local git branches Delete (C1) Five merged feature branches, interactive

Task 1: S1 — Add device::fingerprint helper to relicario-core

Files:

  • Modify: crates/relicario-core/src/device.rs
  • Modify: crates/relicario-core/src/lib.rs

The relicario-server verify_commit rewrite needs to compute SHA256 fingerprints from OpenSSH public keys (so it can match git verify-commit --raw stderr output against devices.json / revoked.json entries). The ssh-key crate already exposes this via PublicKey::fingerprint(HashAlg::Sha256). We add it to core so the server crate (and unit tests) share the same implementation.

  • Step 1: Write failing unit test for fingerprint

Add to crates/relicario-core/src/device.rs tests module:

#[test]
fn fingerprint_matches_ssh_keygen_format() {
    // Generate a key, compute fingerprint, and confirm format.
    let (_, public) = generate_keypair().unwrap();
    let fp = fingerprint(&public).unwrap();
    // Format from ssh-keygen -l: "SHA256:<43 chars of url-safe base64 without padding>"
    assert!(fp.starts_with("SHA256:"), "fingerprint should start with SHA256: prefix, got {fp}");
    let body = fp.strip_prefix("SHA256:").unwrap();
    assert_eq!(body.len(), 43, "SHA-256 fingerprint body is 43 base64 chars (no padding)");
    assert!(body.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/'));
}

#[test]
fn fingerprint_is_deterministic() {
    let (_, public) = generate_keypair().unwrap();
    assert_eq!(fingerprint(&public).unwrap(), fingerprint(&public).unwrap());
}

#[test]
fn fingerprint_differs_per_key() {
    let (_, p1) = generate_keypair().unwrap();
    let (_, p2) = generate_keypair().unwrap();
    assert_ne!(fingerprint(&p1).unwrap(), fingerprint(&p2).unwrap());
}
  • Step 2: Run test to confirm fail

Run: cargo test -p relicario-core device::tests::fingerprint Expected: compilation error — fingerprint is not defined.

  • Step 3: Implement fingerprint

Add to crates/relicario-core/src/device.rs:

/// Compute the OpenSSH SHA-256 fingerprint of a public key, formatted exactly as
/// `ssh-keygen -lf` and as `git verify-commit --raw` reports it: `SHA256:<base64>`
/// (standard base64 without padding).
pub fn fingerprint(public_key_openssh: &str) -> Result<String> {
    use ssh_key::HashAlg;
    let public = PublicKey::from_openssh(public_key_openssh)
        .map_err(|e| RelicarioError::DeviceKey(format!("parse public key: {e}")))?;
    Ok(public.fingerprint(HashAlg::Sha256).to_string())
}
  • Step 4: Re-export from lib.rs

In crates/relicario-core/src/lib.rs, find the existing pub use device::... line and add fingerprint:

pub use device::{fingerprint, generate_keypair, sign, verify, DeviceEntry, RevokedEntry};
  • Step 5: Run tests

Run: cargo test -p relicario-core device Expected: all device tests pass including the three new fingerprint tests.

  • Step 6: Cross-check fingerprint format against ssh-keygen

Sanity-check that the format matches what git verify-commit --raw actually emits (we captured this during planning — see crates/relicario-server/tests/verify_commit.rs Step 2 below where it gets exercised end-to-end). If the ssh-key crate's Fingerprint::Display emits something different from the SHA256:NX7cg... form, this is the place to find out. Format note for the engineer: ssh-keygen omits trailing = padding from base64, exactly 43 chars for SHA-256.

  • Step 7: Commit
git add crates/relicario-core/src/device.rs crates/relicario-core/src/lib.rs
git commit -m "$(cat <<'EOF'
feat(core): add device::fingerprint helper for SSH SHA256 fingerprints

Wraps `ssh-key`'s `PublicKey::fingerprint(HashAlg::Sha256)`. Output
format matches `ssh-keygen -lf` and `git verify-commit --raw` stderr
("SHA256:<43-char base64>"). Used by the upcoming relicario-server
verify-commit rewrite (audit S1).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"

Task 2: S1 — Add dev-dependencies to relicario-server

Files:

  • Modify: crates/relicario-server/Cargo.toml

The S1 acceptance tests need assert_cmd to spawn the binary and tempfile to build throwaway repos. Both are already used in relicario-cli — mirror the versions to keep workspace dependency drift minimal.

  • Step 1: Confirm versions used by relicario-cli

Read crates/relicario-cli/Cargo.toml [dev-dependencies]. As of writing: assert_cmd = "2", predicates = "3", tempfile = "3". If any have changed, use the values currently in that file.

  • Step 2: Add to relicario-server Cargo.toml

Edit crates/relicario-server/Cargo.toml. Append (or merge if section exists):

[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"
  • Step 3: Verify it builds

Run: cargo build -p relicario-server --tests Expected: succeeds (no tests yet, just confirms manifest is valid).

  • Step 4: Commit
git add crates/relicario-server/Cargo.toml
git commit -m "$(cat <<'EOF'
chore(server): add assert_cmd/predicates/tempfile dev-deps

Needed for the upcoming verify-commit acceptance suite (audit S1).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"

Task 3: S1 — Write the four failing acceptance tests

Files:

  • Create: crates/relicario-server/tests/verify_commit.rs

These tests drive the implementation. They build a real on-disk git repo, generate two ed25519 keypairs, write .relicario/devices.json and .relicario/revoked.json, sign commits with git -c gpg.format=ssh ..., then invoke the relicario-server verify-commit <sha> binary via assert_cmd and assert exit code + stderr.

Reference output formats (captured from a real git -c gpg.format=ssh ... repo during planning):

  • Signed by allowed key, exit 0:
    Good "git" signature for relicario with ED25519 key SHA256:NX7cgViq2JvCRDYWVun/kiGYDbl5yyNKApg/L5e/ixU
  • Signed by non-allowed key (still has GOODSIG line!), exit 1:
    Good "git" signature with ED25519 key SHA256:BMyEafi4tdXsSdaOwCqdLXq25Xe3I/LzZWG63WCvfBE
    No principal matched.
  • Unsigned commit: empty stderr, exit non-zero from git verify-commit.

The current code's stderr.contains("Good signature") check is doubly wrong: SSH signatures emit Good "git" signature ..., AND that line shows up even for unknown keys. We need fingerprint matching against devices.json.

  • Step 1: Create the test file with helpers

Write crates/relicario-server/tests/verify_commit.rs:

//! Acceptance tests for `relicario-server verify-commit`.
//!
//! These build real on-disk git repos with SSH-signed commits and invoke the
//! binary via `assert_cmd`. They cover the four scenarios from the audit S1
//! acceptance criteria:
//!
//! 1. Revoked key, commit dated AFTER `revoked_at` -> exit 1
//! 2. Unregistered key -> exit 1
//! 3. Registered, non-revoked key -> exit 0
//! 4. Revoked key, commit dated BEFORE `revoked_at` (historical) -> exit 0

use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

use assert_cmd::Command as AssertCommand;
use relicario_core::device::{generate_keypair, DeviceEntry, RevokedEntry};
use tempfile::TempDir;

/// Write a private OpenSSH key + matching .pub file into `dir`, returning
/// (private_path, public_path, public_openssh_string).
fn write_keypair(dir: &Path, name: &str) -> (PathBuf, PathBuf, String) {
    let (priv_pem, pub_line) = generate_keypair().expect("generate keypair");
    let priv_path = dir.join(format!("{name}.key"));
    let pub_path = dir.join(format!("{name}.pub"));
    fs::write(&priv_path, priv_pem.as_str()).unwrap();
    fs::write(&pub_path, &pub_line).unwrap();
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).unwrap();
    }
    (priv_path, pub_path, pub_line)
}

/// Run `git` in `repo` with the given args; panic on non-zero exit.
fn git(repo: &Path, args: &[&str], extra_env: &[(&str, &str)]) {
    let mut cmd = Command::new("git");
    cmd.current_dir(repo).args(args);
    for (k, v) in extra_env {
        cmd.env(k, v);
    }
    let status = cmd.status().expect("spawn git");
    assert!(status.success(), "git {args:?} failed");
}

/// Initialise a git repo with an empty initial commit (unsigned, bootstrap).
fn init_repo(repo: &Path) {
    git(repo, &["init", "-q", "-b", "main"], &[]);
    git(repo, &["config", "user.email", "test@test"], &[]);
    git(repo, &["config", "user.name", "test"], &[]);
    git(repo, &["commit", "--allow-empty", "-q", "-m", "init"], &[]);
}

/// Sign a commit with `signing_key` against `allowed_signers` and a fixed
/// committer date (unix timestamp). Returns the new commit SHA.
fn sign_commit(
    repo: &Path,
    signing_key: &Path,
    allowed_signers: &Path,
    committer_unix: i64,
    msg: &str,
    file_path: &str,
    file_content: &str,
) -> String {
    fs::write(repo.join(file_path), file_content).unwrap();
    git(repo, &["add", file_path], &[]);
    let date = format!("@{committer_unix} +0000");
    git(
        repo,
        &[
            "-c", "gpg.format=ssh",
            "-c", &format!("user.signingkey={}", signing_key.display()),
            "-c", &format!("gpg.ssh.allowedSignersFile={}", allowed_signers.display()),
            "commit", "-S", "-q", "-m", msg,
        ],
        &[
            ("GIT_AUTHOR_DATE", &date),
            ("GIT_COMMITTER_DATE", &date),
        ],
    );
    let out = Command::new("git")
        .current_dir(repo)
        .args(["rev-parse", "HEAD"])
        .output()
        .unwrap();
    String::from_utf8(out.stdout).unwrap().trim().to_string()
}

/// Write `.relicario/devices.json` and `.relicario/revoked.json`, commit, return SHA.
/// The commit itself is unsigned/bootstrap so we don't recurse into verification.
fn write_device_files(
    repo: &Path,
    devices: &[DeviceEntry],
    revoked: &[RevokedEntry],
) {
    let dir = repo.join(".relicario");
    fs::create_dir_all(&dir).unwrap();
    fs::write(
        dir.join("devices.json"),
        serde_json::to_string_pretty(devices).unwrap(),
    ).unwrap();
    fs::write(
        dir.join("revoked.json"),
        serde_json::to_string_pretty(revoked).unwrap(),
    ).unwrap();
    git(repo, &["add", ".relicario"], &[]);
    git(repo, &["commit", "-q", "-m", "device files"], &[]);
}

#[test]
fn registered_non_revoked_key_accepted() {
    let tmp = TempDir::new().unwrap();
    let repo = tmp.path();
    init_repo(repo);

    let (priv_a, _pub_path_a, pub_a) = write_keypair(repo, "alice");
    write_device_files(
        repo,
        &[DeviceEntry {
            name: "alice".into(),
            public_key: pub_a.clone(),
            added_at: 1_700_000_000,
            added_by: "bootstrap".into(),
        }],
        &[],
    );

    // Allowed-signers file used only by the test fixture's git invocation
    // (the binary builds its own at verify time).
    let allowed = repo.join("test_allowed_signers");
    fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();

    let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "x", "a.txt", "hi");

    AssertCommand::cargo_bin("relicario-server")
        .unwrap()
        .current_dir(repo)
        .args(["verify-commit", &sha])
        .assert()
        .success();
}

#[test]
fn unregistered_key_rejected() {
    let tmp = TempDir::new().unwrap();
    let repo = tmp.path();
    init_repo(repo);

    let (_priv_a, _, pub_a) = write_keypair(repo, "alice");
    let (priv_evil, _, pub_evil) = write_keypair(repo, "evil");

    // Only Alice is registered.
    write_device_files(
        repo,
        &[DeviceEntry {
            name: "alice".into(),
            public_key: pub_a.clone(),
            added_at: 1_700_000_000,
            added_by: "bootstrap".into(),
        }],
        &[],
    );

    // Evil signs against an allowed-signers file containing both keys (so
    // git verify-commit emits a "Good" line); the binary should still reject
    // because evil isn't in devices.json.
    let allowed = repo.join("test_allowed_signers");
    fs::write(
        &allowed,
        format!("relicario {}\nrelicario {}\n", pub_a.trim(), pub_evil.trim()),
    ).unwrap();

    let sha = sign_commit(repo, &priv_evil, &allowed, 1_710_000_000, "evil", "a.txt", "hi");

    AssertCommand::cargo_bin("relicario-server")
        .unwrap()
        .current_dir(repo)
        .args(["verify-commit", &sha])
        .assert()
        .failure()
        .stderr(predicates::str::contains("unregistered"));
}

#[test]
fn revoked_key_after_revoked_at_rejected() {
    let tmp = TempDir::new().unwrap();
    let repo = tmp.path();
    init_repo(repo);

    let (priv_a, _, pub_a) = write_keypair(repo, "alice");

    // Alice was registered, then revoked at t=1_705_000_000.
    write_device_files(
        repo,
        &[],
        &[RevokedEntry {
            name: "alice".into(),
            public_key: pub_a.clone(),
            revoked_at: 1_705_000_000,
            revoked_by: "admin".into(),
        }],
    );

    let allowed = repo.join("test_allowed_signers");
    fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();

    // Commit dated AFTER revocation.
    let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "post", "a.txt", "hi");

    AssertCommand::cargo_bin("relicario-server")
        .unwrap()
        .current_dir(repo)
        .args(["verify-commit", &sha])
        .assert()
        .failure()
        .stderr(predicates::str::contains("revoked"));
}

#[test]
fn revoked_key_before_revoked_at_accepted_historical() {
    let tmp = TempDir::new().unwrap();
    let repo = tmp.path();
    init_repo(repo);

    let (priv_a, _, pub_a) = write_keypair(repo, "alice");

    // Devices.json (current state) lists Alice as revoked.
    write_device_files(
        repo,
        &[],
        &[RevokedEntry {
            name: "alice".into(),
            public_key: pub_a.clone(),
            revoked_at: 1_705_000_000,
            revoked_by: "admin".into(),
        }],
    );

    let allowed = repo.join("test_allowed_signers");
    fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();

    // Commit dated BEFORE revocation -- historical-commit case.
    let sha = sign_commit(repo, &priv_a, &allowed, 1_700_000_000, "historical", "a.txt", "hi");

    AssertCommand::cargo_bin("relicario-server")
        .unwrap()
        .current_dir(repo)
        .args(["verify-commit", &sha])
        .assert()
        .success();
}
  • Step 2: Run the suite to confirm all four fail

Run: cargo test -p relicario-server --test verify_commit Expected: all four tests fail. The current verify_commit accepts everything that has a "Good signature" or "GOODSIG" string — unregistered_key_rejected and revoked_key_after_revoked_at_rejected will pass on a passing-everything implementation (which is the bug), but they assert specific stderr substrings that the current code never emits, so they will fail. registered_non_revoked_key_accepted and revoked_key_before_revoked_at_accepted_historical may or may not pass coincidentally — both should be checked under the new implementation.

NOTE: do not commit yet — these are paired with the implementation in Task 4.


Task 4: S1 — Implement verify_commit

Files:

  • Modify: crates/relicario-server/src/main.rs

Replace the body of verify_commit with the real check. The flow:

  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: ").
  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 ").
  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 ''").
  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]:

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:

fn verify_commit(commit: &str) -> Result<()> {
    // 1. Load devices.json at this commit (bootstrap-tolerant).
    let devices_json = match git_show(commit, ".relicario/devices.json") {
        Ok(json) => json,
        Err(_) => {
            eprintln!("OK: commit {commit} (bootstrap - no devices.json)");
            return Ok(());
        }
    };
    let devices: Vec<DeviceEntry> = serde_json::from_str(&devices_json)
        .context("parse devices.json")?;

    // 2. Empty devices.json => bootstrap mode.
    if devices.is_empty() {
        eprintln!("OK: commit {commit} (bootstrap - empty devices.json)");
        return Ok(());
    }

    // 3. Load revoked.json (may not exist).
    let revoked: Vec<RevokedEntry> = git_show(commit, ".relicario/revoked.json")
        .ok()
        .and_then(|s| serde_json::from_str(&s).ok())
        .unwrap_or_default();

    // 4. Build temp allowed-signers file (one entry per registered device).
    let tmp = tempfile::tempdir().context("create tempdir")?;
    let allowed_path = tmp.path().join("allowed_signers");
    let mut allowed_body = String::new();
    for d in &devices {
        // Format per ssh-keygen(1): principal[,principal...] [options] keytype base64key
        // We use "relicario" as the single principal (matches what the CLI configures).
        allowed_body.push_str("relicario ");
        allowed_body.push_str(d.public_key.trim());
        allowed_body.push('\n');
    }
    std::fs::write(&allowed_path, allowed_body).context("write allowed_signers")?;

    // 5. Run git verify-commit --raw with our allowed-signers (NOT mutating
    //    global git config -- use GIT_CONFIG_COUNT/KEY/VALUE override).
    let output = Command::new("git")
        .args(["verify-commit", "--raw", commit])
        .env("GIT_CONFIG_COUNT", "1")
        .env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
        .env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
        .output()
        .context("git verify-commit")?;

    let stderr = String::from_utf8_lossy(&output.stderr);

    // 6. Non-zero exit means: unsigned, bad signature, or signed by a key
    //    not in our allowed-signers file (which is exactly the registered
    //    device set). All three are rejection cases.
    if !output.status.success() {
        eprintln!("REJECT: commit {commit} — git verify-commit failed: {}", stderr.trim());
        std::process::exit(1);
    }

    // 7. Parse the SHA-256 fingerprint of the signing key from stderr.
    //    SSH signature line format:
    //      Good "git" signature for relicario with ED25519 key SHA256:<base64>
    //    (capture the SHA256:... token; it's exactly what device::fingerprint emits.)
    let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)")
        .expect("static regex");
    let signing_fp = match re.captures(&stderr).and_then(|c| c.get(1)) {
        Some(m) => m.as_str().to_string(),
        None => {
            eprintln!(
                "REJECT: commit {commit} — could not parse signing key fingerprint from: {}",
                stderr.trim()
            );
            std::process::exit(1);
        }
    };

    // 8. Build device fingerprint maps.
    let mut device_by_fp: std::collections::HashMap<String, &DeviceEntry> =
        std::collections::HashMap::new();
    for d in &devices {
        match relicario_core::device::fingerprint(&d.public_key) {
            Ok(fp) => { device_by_fp.insert(fp, d); }
            Err(e) => eprintln!(
                "warning: skipping unparseable device entry '{}': {e}",
                d.name
            ),
        }
    }

    // 9. Reject if signing key isn't a registered device.
    if !device_by_fp.contains_key(&signing_fp) {
        eprintln!(
            "REJECT: commit {commit} — signed by unregistered device (fingerprint {signing_fp})"
        );
        std::process::exit(1);
    }

    // 10. Get committer date (NOT author date — committer date is when the
    //     signature was applied; author date can be backdated by an attacker).
    let ct_out = Command::new("git")
        .args(["show", "-s", "--format=%ct", commit])
        .output()
        .context("git show committer date")?;
    if !ct_out.status.success() {
        anyhow::bail!("git show committer date failed for {commit}");
    }
    let committer_ts: i64 = String::from_utf8_lossy(&ct_out.stdout)
        .trim()
        .parse()
        .context("parse committer timestamp")?;

    // 11. Reject if signing key is revoked AND commit is at-or-after revocation.
    //     Historical commits (committer_ts < revoked_at) survive the revoke.
    for r in &revoked {
        let r_fp = match relicario_core::device::fingerprint(&r.public_key) {
            Ok(fp) => fp,
            Err(_) => continue,
        };
        if r_fp == signing_fp && committer_ts >= r.revoked_at {
            eprintln!(
                "REJECT: commit {commit} — signed by revoked device '{}' \
                 (committer ts {committer_ts} >= revoked_at {})",
                r.name, r.revoked_at
            );
            std::process::exit(1);
        }
    }

    eprintln!("OK: commit {commit} verified (signed by '{}')", device_by_fp[&signing_fp].name);
    Ok(())
}
  • Step 3: Run the four acceptance tests

Run: cargo test -p relicario-server --test verify_commit Expected: all four pass.

  • Step 4: Run the rest of the workspace tests to confirm no regression

Run: cargo test Expected: all tests still pass.

  • Step 5: Manual sanity check (optional but good)

Build the binary and inspect a known-bad case:

cargo build -p relicario-server
# (binary at target/debug/relicario-server)

Then either run the test fixture by hand or skip — the four tests cover the four spec scenarios.

  • Step 6: Commit (S1 implementation + tests together)
git add crates/relicario-server/Cargo.toml \
        crates/relicario-server/src/main.rs \
        crates/relicario-server/tests/verify_commit.rs
git commit -m "$(cat <<'EOF'
fix(server): real signature verification in pre-receive hook (audit S1)

`verify_commit` previously loaded devices.json/revoked.json and threw
both away, accepting any commit whose stderr contained "GOODSIG" or
"Good signature". This left device registration and revocation as no-ops:
unregistered keys could push, and revoked keys kept working forever.

The fix:

- Build a temp `gpg.ssh.allowedSignersFile` from devices.json at the
  commit, passed via GIT_CONFIG_COUNT/KEY/VALUE so we never mutate
  global git config.
- Run `git verify-commit --raw` and require zero exit.
- Parse `SHA256:<base64>` fingerprint out of the SSH "Good" line.
- Reject if fingerprint isn't a registered device.
- Reject if fingerprint is in revoked.json AND committer date >=
  revoked_at. Historical commits (committer_ts < revoked_at) still
  verify — old history survives revocation.

Acceptance: 4 integration tests covering the matrix
(registered/unregistered x revoked/non-revoked, with revoked split into
post- and pre-revocation cases).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"

Task 5: S2 — Write failing tests for path-traversal hardening

Files:

  • Create: crates/relicario-cli/tests/restore_hardening.rs

The current cmd_backup_restore calls tar::Archive::unpack(target.join(".git")) blindly. We need three failing tests that craft malicious tar payloads inside a .relbak, then assert restore exits with an error and never writes outside the target directory.

The simplest path: factor the tar-extraction into a safe_unpack_git_archive(tar_bytes: &[u8], dest: &Path, max_size: u64) -> Result<()> function and unit-test that directly. Plus one CLI integration test for the happy path.

  • Step 1: Decide test layer

Two options:

  • (A) Pure-Rust unit test of safe_unpack_git_archive, hand-crafting tar entries with 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:

//! 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 uses 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:

tar = { version = "0.4", default-features = false }

(Same version as relicario-cli already uses — keep workspace consistent.)

  • Step 2: Implement the function

Write crates/relicario-core/src/tar_safe.rs:

//! Hardened tar archive extraction used by backup restore.
//!
//! Defends against:
//!  - Path traversal: entries with `..` components.
//!  - Absolute paths and Windows drive letters.
//!  - Symlinks and hardlinks (we never legitimately create either inside
//!    `.git/` on restore).
//!  - Tar bombs: caller provides an uncompressed-size cap.

use std::io::Read;
use std::path::{Component, Path, PathBuf};

use crate::error::{RelicarioError, Result};

/// Maximum default cap if the caller doesn't have a better number: 1 GiB.
pub const DEFAULT_MAX_UNCOMPRESSED: u64 = 1024 * 1024 * 1024;

/// Decode `tar_bytes` and return a list of (relative-path, contents) pairs
/// for regular files only. Caller writes them to disk.
///
/// Any malicious-looking entry causes the entire operation to fail. We do
/// not partially-extract.
pub fn safe_unpack_git_archive(
    tar_bytes: &[u8],
    max_uncompressed_bytes: u64,
) -> Result<Vec<(PathBuf, Vec<u8>)>> {
    let mut archive = tar::Archive::new(tar_bytes);
    let mut out: Vec<(PathBuf, Vec<u8>)> = Vec::new();
    let mut total_size: u64 = 0;

    for entry in archive.entries().map_err(|e| RelicarioError::BackupRestore(format!("tar entries: {e}")))? {
        let mut entry = entry.map_err(|e| RelicarioError::BackupRestore(format!("tar entry: {e}")))?;

        // 1. Reject non-regular entries outright.
        match entry.header().entry_type() {
            tar::EntryType::Regular | tar::EntryType::Continuous | tar::EntryType::GNUSparse => {}
            tar::EntryType::Directory => continue, // dirs are implicit in file paths
            tar::EntryType::Symlink | tar::EntryType::Link => {
                return Err(RelicarioError::BackupRestore(format!(
                    "rejecting symlink/hardlink entry: {} (path traversal blocked)",
                    entry.path().map(|p| p.display().to_string()).unwrap_or_default()
                )));
            }
            other => {
                return Err(RelicarioError::BackupRestore(format!(
                    "rejecting unexpected tar entry type {other:?} (path traversal blocked)"
                )));
            }
        }

        // 2. Size cap. Check declared size in header before reading the body.
        let claimed = entry.header().size().map_err(|e| {
            RelicarioError::BackupRestore(format!("tar header size: {e}"))
        })?;
        if claimed > max_uncompressed_bytes {
            return Err(RelicarioError::BackupRestore(format!(
                "tar entry declared size {claimed} bytes exceeds cap {max_uncompressed_bytes}"
            )));
        }
        total_size = total_size.saturating_add(claimed);
        if total_size > max_uncompressed_bytes {
            return Err(RelicarioError::BackupRestore(format!(
                "tar entries total size {total_size} bytes exceeds cap {max_uncompressed_bytes}"
            )));
        }

        // 3. Path validation.
        let raw_path = entry.path()
            .map_err(|e| RelicarioError::BackupRestore(format!("tar entry path: {e}")))?
            .into_owned();
        let cleaned = validate_relative_path(&raw_path)?;

        // 4. Read body (bounded by the size cap above).
        let mut body = Vec::with_capacity(claimed as usize);
        entry.read_to_end(&mut body)
            .map_err(|e| RelicarioError::BackupRestore(format!("read tar entry body: {e}")))?;

        out.push((cleaned, body));
    }

    Ok(out)
}

/// Reject any path that contains `..`, an absolute prefix, a Windows drive
/// letter, or a non-normal component. Returns a path made of only `Normal`
/// components.
fn validate_relative_path(p: &Path) -> Result<PathBuf> {
    let mut cleaned = PathBuf::new();
    for comp in p.components() {
        match comp {
            Component::Normal(s) => cleaned.push(s),
            Component::CurDir => {} // "./foo" -> "foo"
            Component::ParentDir => {
                return Err(RelicarioError::BackupRestore(format!(
                    "tar entry contains `..` component: {} (path traversal blocked)",
                    p.display()
                )));
            }
            Component::RootDir => {
                return Err(RelicarioError::BackupRestore(format!(
                    "tar entry has absolute path: {} (path traversal blocked)",
                    p.display()
                )));
            }
            Component::Prefix(_) => {
                // Windows drive letter or UNC prefix.
                return Err(RelicarioError::BackupRestore(format!(
                    "tar entry has drive/UNC prefix: {} (path traversal blocked)",
                    p.display()
                )));
            }
        }
    }
    if cleaned.as_os_str().is_empty() {
        return Err(RelicarioError::BackupRestore(format!(
            "tar entry has empty path after normalisation: {}",
            p.display()
        )));
    }
    Ok(cleaned)
}
  • Step 3: Add the BackupRestore error variant if missing

Read crates/relicario-core/src/error.rs. If BackupRestore(String) doesn't exist as a variant, add it:

#[error("backup restore: {0}")]
BackupRestore(String),

If a similar variant already exists (e.g. Backup, Restore, Tar), use that instead and adjust the function above.

  • Step 4: Wire module into lib.rs

Edit crates/relicario-core/src/lib.rs:

pub mod tar_safe;
pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED};
  • Step 5: Run unit tests for the function

Run: cargo test -p relicario-core --test safe_unpack Expected: all 5 tests pass (4 reject cases + 1 happy path).

  • Step 6: Replace the unsafe archive.unpack call in CLI

Edit crates/relicario-cli/src/main.rs. Find the block at line 1719-1723:

    // .git/ history.
    if let Some(tar_bytes) = &unpacked.git_archive {
        let mut archive = tar::Archive::new(tar_bytes.as_slice());
        archive.unpack(target.join(".git"))
            .with_context(|| "failed to untar .git/")?;
    } else {

Replace with:

    // .git/ history. Hardened against path traversal, symlinks, and tar bombs.
    if let Some(tar_bytes) = &unpacked.git_archive {
        // Cap: 100x the compressed bundle, or 1 GiB, whichever is lower.
        // 100x is a generous heuristic; legitimate .git pack data
        // compresses 3-10x, so 100x leaves room for unusual repos.
        let cap = std::cmp::min(
            (tar_bytes.len() as u64).saturating_mul(100),
            relicario_core::tar_safe::DEFAULT_MAX_UNCOMPRESSED,
        );
        let entries = relicario_core::safe_unpack_git_archive(tar_bytes, cap)
            .with_context(|| "failed to safely unpack .git/ archive")?;
        let git_dir = target.join(".git");
        for (rel_path, body) in entries {
            let dest = git_dir.join(&rel_path);
            // Final paranoid check: ensure dest is inside git_dir even after
            // OS-level resolution. We did the textual check in core; this
            // catches any platform-specific quirk.
            if !dest.starts_with(&git_dir) {
                anyhow::bail!(
                    "tar entry {} resolved outside .git/ (path traversal blocked)",
                    rel_path.display()
                );
            }
            if let Some(parent) = dest.parent() {
                fs::create_dir_all(parent).with_context(|| {
                    format!("failed to create parent {}", parent.display())
                })?;
            }
            fs::write(&dest, &body).with_context(|| {
                format!("failed to write {}", dest.display())
            })?;
        }
    } else {
  • Step 7: Run the full test suite

Run: cargo test Expected: all tests pass — including the existing happy-path backup roundtrip in crates/relicario-cli/tests/backup.rs (or wherever the existing restore test lives — confirm it's still green).

  • Step 8: Commit
git add crates/relicario-core/Cargo.toml \
        crates/relicario-core/src/tar_safe.rs \
        crates/relicario-core/src/lib.rs \
        crates/relicario-core/src/error.rs \
        crates/relicario-core/tests/safe_unpack.rs \
        crates/relicario-cli/src/main.rs
git commit -m "$(cat <<'EOF'
fix(core,cli): harden backup-restore tar unpack (audit S2)

`cmd_backup_restore` previously called tar::Archive::unpack on the
embedded .git archive with default settings, trusting the tar crate's
defaults to reject malicious payloads. They don't reject hardlinks,
they don't enforce size caps, and an attacker-crafted .relbak with
`../../etc/passwd` entries could escape the target directory.

Replaced with `relicario_core::safe_unpack_git_archive`, which:

- Rejects entries with `..` components, absolute paths, or Windows
  drive prefixes (Component::ParentDir/RootDir/Prefix).
- Rejects symlinks and hardlinks outright -- legitimate .git/ tar
  archives produced by `relicario backup export` only contain regular
  files and directories.
- Enforces a max-uncompressed-bytes cap (caller picks; CLI uses
  min(100x compressed, 1 GiB)).
- Returns (path, bytes) pairs to the caller; the CLI writes them and
  re-checks `dest.starts_with(git_dir)` for OS-level paranoia.

Acceptance: 5 unit tests in relicario-core (path traversal, absolute
path, symlink, size bomb, happy path) plus existing CLI restore
roundtrip stays green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"

Task 7: S3 — Audit RELICARIO_* env vars and document them in SECURITY.md

Files:

  • Modify: docs/SECURITY.md

The full set of RELICARIO_* env vars in production code (after the test-gate work in the previous device-auth plan):

Var Site Trust
RELICARIO_VAULT crates/relicario-cli/src/helpers.rs:vault_dir (referenced via doc-comment at line 172) User-trusted: vault-root override
RELICARIO_IMAGE crates/relicario-cli/src/session.rs:125 User-trusted: reference-image path override
RELICARIO_NO_GROUPS_CACHE crates/relicario-cli/src/helpers.rs:103 Dev-only — to be cfg-gated in Task 8
RELICARIO_GITEA_URL crates/relicario-cli/src/main.rs:2298 User config (Gitea base URL)
RELICARIO_GITEA_TOKEN crates/relicario-cli/src/main.rs:2303 User secret (Gitea API token)
RELICARIO_GITEA_OWNER crates/relicario-cli/src/main.rs:2308 User config (repo owner)
RELICARIO_GITEA_REPO crates/relicario-cli/src/main.rs:2313 User config (repo name)

Plus the cfg(test)-gated RELICARIO_TEST_* set (already gated by the device-auth plan): RELICARIO_TEST_PASSPHRASE, RELICARIO_TEST_ITEM_SECRET, RELICARIO_TEST_BACKUP_PASSPHRASE, RELICARIO_TEST_ITEM_PASSWORD. These are test-binary-only and should be documented as such (so security reviewers don't flag them again).

Confirm this list with a grep before writing — actual sites win over the spec's three-item list.

  • Step 1: Verify the list

Run: grep -rn 'RELICARIO_' crates/ | grep -v '/target/' | grep -v '/tests/' | grep 'env::' Expected: matches the table above (give or take RELICARIO_TEST_* which is what we're auditing). Update the table if any new vars have appeared since this plan was drafted.

  • Step 2: Append a "Configuration env vars" section to SECURITY.md

Edit docs/SECURITY.md. Add at the bottom (after the existing "Access Control" section):

## Configuration env vars

Relicario reads the following environment variables. Each one is a
trust boundary: an attacker who can set them in the user's environment
can influence Relicario's behavior. They are listed here so security
reviewers can audit the surface in one place.

### User-facing

| Variable | Purpose | Trust |
|---|---|---|
| `RELICARIO_VAULT` | Override the vault root directory (default: `~/.relicario`). | Trusted: filesystem path under the user's control. |
| `RELICARIO_IMAGE` | Override the reference-image JPEG path used during unlock. | Trusted: filesystem path under the user's control. The image file is read-only; its bytes feed `imgsecret::extract_secret`. |
| `RELICARIO_GITEA_URL` | Default Gitea API base URL for `relicario device add`. Equivalent to `--gitea-url`. | Trusted: HTTPS URL. Used only in the device-add code path. |
| `RELICARIO_GITEA_TOKEN` | Gitea personal-access token. Equivalent to `--gitea-token`. | **Secret**: anyone who can read this env var can manage the user's deploy keys via Gitea. The CLI does not log it. |
| `RELICARIO_GITEA_OWNER` | Repo owner (e.g. `alee`). Equivalent to `--owner`. | Trusted: opaque string. |
| `RELICARIO_GITEA_REPO` | Repo name (e.g. `vault`). Equivalent to `--repo`. | Trusted: opaque string. |

### Test-only (compiled out of release builds)

The following are gated behind `#[cfg(test)]` and **do not exist** in
binaries built without the test profile (`cargo build --release`):

| Variable | Purpose |
|---|---|
| `RELICARIO_TEST_PASSPHRASE` | Bypass the rpassword prompt during integration tests. |
| `RELICARIO_TEST_ITEM_SECRET` | Bypass the rpassword prompt for item-secret fields during integration tests. |
| `RELICARIO_TEST_BACKUP_PASSPHRASE` | Bypass the prompt for backup export/restore passphrases during integration tests. |
| `RELICARIO_TEST_ITEM_PASSWORD` | Bypass the rpassword prompt for legacy item-password fields during integration tests. |

### Dev-only (compiled out of release builds via `cfg(debug_assertions)`)

| Variable | Purpose |
|---|---|
| `RELICARIO_NO_GROUPS_CACHE` | Disables the plaintext groups cache used for shell-completion. Intended for developers debugging the cache logic. Compiled out of `cargo build --release` builds. |
  • Step 3: Commit
git add docs/SECURITY.md
git commit -m "$(cat <<'EOF'
docs: enumerate RELICARIO_* env vars in SECURITY.md (audit S3)

Adds a "Configuration env vars" section listing every RELICARIO_*
variable read by production code, with purpose and trust boundary.
Splits user-facing vars from test-only and dev-only ones to make the
attack surface explicit for security reviewers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"

Task 8: S3 — cfg(debug_assertions)-gate RELICARIO_NO_GROUPS_CACHE

Files:

  • Modify: crates/relicario-cli/src/helpers.rs
  • Modify: crates/relicario-cli/src/main.rs (doc-comment at line 174)

RELICARIO_NO_GROUPS_CACHE is a developer escape hatch for debugging the groups-cache logic, not a user knob. Gating it with cfg(debug_assertions) makes the env-var read no-op in cargo build --release (since debug assertions are off in release by default), which means strings on the release binary won't show the variable name and the env-var lookup is dead-code-eliminated.

  • Step 1: Write a build-mode-aware test

In crates/relicario-cli/src/helpers.rs, find the existing tests module (or add one) and append:

#[cfg(test)]
mod tests {
    // ... existing tests ...

    /// In debug builds (which `cargo test` always is) the override should still
    /// work so existing dev workflows keep functioning.
    #[test]
    #[cfg(debug_assertions)]
    fn no_groups_cache_env_var_honored_in_debug() {
        use std::collections::BTreeSet;
        let tmp = tempfile::tempdir().unwrap();
        std::env::set_var("RELICARIO_NO_GROUPS_CACHE", "1");
        super::write_groups_cache(tmp.path(), &BTreeSet::new()).unwrap();
        std::env::remove_var("RELICARIO_NO_GROUPS_CACHE");
        assert!(!tmp.path().join(".relicario/groups.cache").exists(),
            "env var should suppress the cache write in debug builds");
    }
}
  • Step 2: Run test to confirm baseline (still passes)

Run: cargo test -p relicario-cli helpers::tests::no_groups_cache_env_var_honored_in_debug Expected: passes — current code already honours the var in debug.

  • Step 3: Gate the env-var check

Edit crates/relicario-cli/src/helpers.rs. Find the function (line 99-115ish):

pub fn write_groups_cache(
    vault_dir: &Path,
    groups: &std::collections::BTreeSet<String>,
) -> std::io::Result<()> {
    if std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() {
        return Ok(());
    }
    // ...

Change to:

pub fn write_groups_cache(
    vault_dir: &Path,
    groups: &std::collections::BTreeSet<String>,
) -> std::io::Result<()> {
    // Dev-only escape hatch. Compiled out of release builds: the
    // const below is `false` under `cargo build --release`, which lets
    // the optimiser drop the env-var lookup entirely (so `strings` on
    // a release binary won't reveal the variable name).
    if cfg!(debug_assertions)
        && std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some()
    {
        return Ok(());
    }
    // ... rest unchanged

Update the doc-comment a few lines up to reflect the new behaviour:

/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
/// one name per line. In debug builds, setting `RELICARIO_NO_GROUPS_CACHE`
/// suppresses the write (used by tests). In release builds the env var is
/// ignored.
  • Step 4: Run the test again and the rest of the suite

Run: cargo test -p relicario-cli Expected: all tests pass.

  • Step 5: Update the cmd_groups doc-comment in main.rs

Find lines 170-176ish in crates/relicario-cli/src/main.rs:

    /// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file,
    /// ...
    /// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion
    /// will fall back to vault-locked behaviour).

Adjust to reflect that RELICARIO_NO_GROUPS_CACHE is debug-only:

    /// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file,
    /// ...
    /// In debug builds only, set `RELICARIO_NO_GROUPS_CACHE=1` to opt out
    /// of the cache (completion will fall back to vault-locked behaviour).
    /// The env var is a no-op in release builds.
  • Step 6: Verify the variable is gone from a release binary

Run:

cargo build -p relicario-cli --release
strings target/release/relicario | grep RELICARIO_NO_GROUPS_CACHE && echo "FAIL: still present" || echo "OK: not present"

Expected: OK: not present.

NOTE: cfg!(debug_assertions) evaluates to a bool literal at build time. Combined with &&, the optimiser sees if false && ... in release and dead-codes the whole branch including the env-var lookup. If strings somehow still finds it (e.g. due to a stray doc-string referencing the name in #[command(...)] long-help), that's expected — what matters is no env-var read at runtime.

  • Step 7: Commit
git add crates/relicario-cli/src/helpers.rs crates/relicario-cli/src/main.rs
git commit -m "$(cat <<'EOF'
fix(cli): cfg-gate RELICARIO_NO_GROUPS_CACHE to debug builds (audit S3)

The groups-cache opt-out is a developer debugging knob, not a user-facing
config. Gating its env-var lookup behind `cfg!(debug_assertions)` makes
release builds ignore the variable entirely; the optimiser drops the
lookup, removing the variable name from the release binary's strings.

Doc-comments updated to reflect the new behaviour.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"

Task 9: C1 — Prune merged local feature branches (interactive)

Files:

  • local git state only — no source files changed.

The five branches to evaluate (from the spec):

  • feature/fullscreen-ux-phase-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:

git status
git branch --show-current

Expected: branch is main. Working tree state is the user's call — Cargo.lock is currently modified but that doesn't block branch deletion.

  • Step 2: List merged branches

Run:

git branch --merged main

Expected output should include all five target branches plus * main. If any are missing, do NOT delete that one — it has unmerged commits. Surface this to the user before continuing.

  • Step 3: For each branch, confirm-then-delete one at a time

For each of the five branches in this exact order:

  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:

git log -1 --format='%h %s' <branch>

c. Prompt the user: "Delete local branch <branch> (tip: <sha> <subject>)? [y/N]"

d. On y (or yes): run git branch -D <branch>. On anything else: skip and continue to the next.

e. After delete, run git branch to confirm the branch is gone.

  • Step 4: Verify final state

Run:

git branch

Expected: only * main remains (assuming the user said yes to all five). If any branches remain, that's fine — the user chose to keep them.

  • Step 5: No commit needed

Branch deletions don't produce commits. There is nothing to commit for C1; the task ends here.

  • Step 6: Surface a summary to the caller

Print a one-line summary: "C1 done. Pruned N of 5 merged feature branches." (where N is whatever the user accepted).


Acceptance — final verification

  • Step 1: All Rust tests green
cargo test

Expected: all tests pass, including the 4 S1 tests and 5 S2 tests added in this plan.

  • Step 2: All targets build
cargo build
cargo build -p relicario-wasm --target wasm32-unknown-unknown
cargo build -p relicario-server
cargo build -p relicario-cli --release

Expected: all succeed.

  • Step 3: Release binary doesn't reference the dev env var
strings target/release/relicario | grep RELICARIO_NO_GROUPS_CACHE && echo "FAIL" || echo "OK"

Expected: OK.

  • Step 4: SECURITY.md is up to date

Confirm docs/SECURITY.md lists every RELICARIO_* var that appears in grep -rn 'env::var' crates/ | grep RELICARIO.

  • Step 5: No stale local feature branches
git branch

Expected: matches the user's choices in Task 9.

  • Step 6: Hand off to user for review and merge

Summarise the four security/cleanup landings (S1, S2, S3, C1), point at the spec, and stop. The user merges to main and tags v0.5.0 after Plan B is also green.


Completion Checklist

  • Task 1: device::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