Files
incredigo/internal/rotate/backup.go
T
leetcrypt 5c4727d1e6 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>
2026-06-15 10:39:45 -07:00

80 lines
2.3 KiB
Go

package rotate
import (
"context"
"fmt"
"io"
"os"
"github.com/awnumar/memguard"
"incredigo/internal/sink"
)
// Snapshot is the MANDATORY backup gate (docs/ROTATION.md §3). It MUST succeed
// before any credential is rotated.
//
// It exports the gopass entries under prefix into a sealed, authenticated bundle
// at outPath (refusing to overwrite), then re-opens and walks the bundle to
// confirm it decrypts/authenticates and contains exactly the number of entries
// that were sealed. It returns the verified entry count. Any error — failure to
// seal, to re-open/authenticate, or an entry-count mismatch — means the caller
// MUST NOT proceed with rotation.
func Snapshot(ctx context.Context, gp *sink.Gopass, prefix string, sealer sink.Sealer, pass *memguard.LockedBuffer, outPath string) (int, error) {
f, err := os.OpenFile(outPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return 0, fmt.Errorf("backup: create %s (use a fresh path): %w", outPath, err)
}
// export (framed plaintext) -> Sealer -> file
type exp struct {
n int
err error
}
ch := make(chan exp, 1)
pr, pw := io.Pipe()
go func() {
n, e := gp.ExportTo(ctx, pw, prefix)
pw.CloseWithError(e)
ch <- exp{n, e}
}()
sealErr := sealer.Seal(ctx, pr, f, pass)
closeErr := f.Close()
res := <-ch // synchronizes the exported count
if res.err != nil {
os.Remove(outPath)
return 0, fmt.Errorf("backup: export: %w", res.err)
}
if sealErr != nil {
os.Remove(outPath)
return 0, fmt.Errorf("backup: seal: %w", sealErr)
}
if closeErr != nil {
return 0, closeErr
}
verified, err := verifyBundle(ctx, sealer, pass, outPath)
if err != nil {
return 0, fmt.Errorf("backup: VERIFY FAILED for %s — do NOT rotate: %w", outPath, err)
}
if verified != res.n {
return 0, fmt.Errorf("backup: integrity mismatch — sealed %d entries but verified %d; do NOT rotate", res.n, verified)
}
return verified, nil
}
// verifyBundle re-opens a sealed bundle, authenticates it via the Sealer, and
// counts its records — without writing anything anywhere.
func verifyBundle(ctx context.Context, sealer sink.Sealer, pass *memguard.LockedBuffer, path string) (int, error) {
f, err := os.Open(path)
if err != nil {
return 0, err
}
defer f.Close()
pr, pw := io.Pipe()
go func() { pw.CloseWithError(sealer.Open(ctx, f, pw, pass)) }()
return sink.CountBundleRecords(pr)
}