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
+37
View File
@@ -180,6 +180,43 @@ func copySecret(dst io.Writer, r io.Reader) error {
}
}
// CountBundleRecords walks a decrypted bundle stream and returns how many records
// (entries) it contains, writing nothing. It is the completeness half of backup
// verification: a bundle that the Sealer can Open is authentic; counting its
// records confirms every expected entry is present. Secret bytes are read only to
// skip past them (discarded).
func CountBundleRecords(r io.Reader) (int, error) {
var n int
for {
var pathLen uint16
if err := binary.Read(r, binary.BigEndian, &pathLen); err != nil {
if err == io.EOF {
return n, nil
}
return n, err
}
if pathLen == 0 {
return n, nil // end-of-stream sentinel
}
if _, err := io.CopyN(io.Discard, r, int64(pathLen)); err != nil {
return n, err
}
for { // skip the secret's chunks up to the zero-length marker
var chunkLen uint32
if err := binary.Read(r, binary.BigEndian, &chunkLen); err != nil {
return n, err
}
if chunkLen == 0 {
break
}
if _, err := io.CopyN(io.Discard, r, int64(chunkLen)); err != nil {
return n, err
}
}
n++
}
}
func writePath(w io.Writer, path string) error {
if len(path) > 0xffff {
return fmt.Errorf("gopass path too long: %d bytes", len(path))