rotate: safety-spine scaffold + mandatory backup gate (NO rotation)

Design-phase foundation for v2 rotation. Changes NO credential at any service.

- internal/rotate: Rotator interface + registry + PlanAll (zero drivers
  registered, so every credential plans as "(none)")
- internal/rotate.Snapshot: the MANDATORY backup gate — seals the gopass
  prefix into an authenticated bundle, re-opens it, and confirms the entry
  count matches before returning; any failure blocks rotation
- internal/sink.CountBundleRecords: read-only bundle completeness check
- cmd: `incredigo rotate` runs the backup gate + prints the plan; it is
  dry-run only and refuses --execute (design phase)
- tests: backup gate happy path, no-overwrite, tamper detection; race-clean

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-15 10:39:45 -07:00
parent a7bcbed8ce
commit 5c4727d1e6
5 changed files with 377 additions and 1 deletions
+92
View File
@@ -0,0 +1,92 @@
package rotate
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/awnumar/memguard"
"incredigo/internal/sink"
)
func fakeGopass(t *testing.T) *sink.Gopass {
t.Helper()
bin := filepath.Join(t.TempDir(), "gopass")
script := `#!/usr/bin/env bash
store="$FAKE_GOPASS_STORE"
case "$1" in
ls) (cd "$store" 2>/dev/null && find . -type f | sed 's#^\./##' | sort) ;;
show) cat "$store/$3" ;;
insert) shift; path=""; for a in "$@"; do case "$a" in --multiline=false|-f) ;; *) path="$a";; esac; done
mkdir -p "$store/$(dirname "$path")"; cat > "$store/$path" ;;
esac
`
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
store := t.TempDir()
t.Setenv("FAKE_GOPASS_STORE", store)
for p, s := range map[string]string{
"imported/aws/default": "AKIA-secret-1",
"imported/env/api": "tok-2-末",
"other/skip": "not-backed-up",
} {
full := filepath.Join(store, p)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(s), 0o600); err != nil {
t.Fatal(err)
}
}
return &sink.Gopass{Bin: bin}
}
func TestSnapshotBackupGate(t *testing.T) {
gp := fakeGopass(t)
pass := memguard.NewBufferFromBytes([]byte("backup-pass"))
defer pass.Destroy()
out := filepath.Join(t.TempDir(), "backup.age")
n, err := Snapshot(context.Background(), gp, "imported/", &sink.Age{}, pass, out)
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Fatalf("backed up %d entries, want 2 (other/ excluded by prefix)", n)
}
fi, err := os.Stat(out)
if err != nil || fi.Size() == 0 {
t.Fatalf("backup bundle missing or empty: %v", err)
}
// The gate must refuse to overwrite an existing backup.
if _, err := Snapshot(context.Background(), gp, "imported/", &sink.Age{}, pass, out); err == nil {
t.Error("Snapshot should refuse to overwrite an existing backup path")
}
}
func TestVerifyBundleDetectsTamper(t *testing.T) {
gp := fakeGopass(t)
pass := memguard.NewBufferFromBytes([]byte("pw"))
defer pass.Destroy()
out := filepath.Join(t.TempDir(), "b.age")
if _, err := Snapshot(context.Background(), gp, "imported/", &sink.Age{}, pass, out); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
b[len(b)/2] ^= 0x01 // flip a byte
tampered := filepath.Join(t.TempDir(), "t.age")
if err := os.WriteFile(tampered, b, 0o600); err != nil {
t.Fatal(err)
}
if _, err := verifyBundle(context.Background(), &sink.Age{}, pass, tampered); err == nil {
t.Error("verifyBundle must fail on a tampered bundle")
}
}