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>
1529 lines
60 KiB
Markdown
1529 lines
60 KiB
Markdown
# 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
|