5c4727d1e6
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>
93 lines
2.5 KiB
Go
93 lines
2.5 KiB
Go
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")
|
|
}
|
|
}
|