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>
80 lines
2.3 KiB
Go
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)
|
|
}
|