Files
incredigo/internal/rotate/rotate.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

83 lines
2.7 KiB
Go

// Package rotate is the credential-rotation scaffold.
//
// IMPORTANT — DESIGN PHASE: no Rotator that actually changes a credential at a
// service is implemented. The interface, registry, planning, and the mandatory
// backup gate (see backup.go and docs/ROTATION.md) exist so rotation can be built
// safely later. Today there are zero registered drivers, so PlanAll reports
// "(none)" for every credential and nothing can be rotated.
package rotate
import (
"context"
"sort"
"sync"
"incredigo/internal/discover"
"incredigo/internal/vault"
)
// Rotator changes one class of credential at its issuing authority.
//
// Contract (docs/ROTATION.md §5): Rotate mints and returns a NEW secret but MUST
// NOT revoke the old one. RevokeOld runs only after the new value has been
// verified against the service AND stored in gopass. A backup (see Snapshot) must
// already exist before Rotate is ever called.
type Rotator interface {
Name() string
// Detect reports whether this driver handles the given credential.
Detect(c discover.Credential) bool
// Rotate mints a new secret at the provider and returns a vault handle to it.
// It must not revoke or invalidate the old credential.
Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error)
// Verify proves the newly minted secret actually authenticates.
Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error
// RevokeOld retires the previous credential — only after verify + store.
RevokeOld(ctx context.Context, c discover.Credential) error
}
var (
regMu sync.Mutex
registry []Rotator
)
// Register adds a rotation driver. (No real driver registers yet.)
func Register(r Rotator) {
regMu.Lock()
defer regMu.Unlock()
registry = append(registry, r)
}
// Drivers returns the registered rotators, sorted by name.
func Drivers() []Rotator {
regMu.Lock()
defer regMu.Unlock()
out := append([]Rotator(nil), registry...)
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
return out
}
// Plan is the non-destructive decision for one credential: which driver would
// handle it, or none.
type Plan struct {
Credential discover.Credential
Driver string // "" when no registered driver claims the credential
}
// PlanAll classifies credentials against the registered drivers WITHOUT changing
// anything. With no drivers registered (the current state) every Plan.Driver is "".
func PlanAll(creds []discover.Credential) []Plan {
drivers := Drivers()
plans := make([]Plan, 0, len(creds))
for _, c := range creds {
p := Plan{Credential: c}
for _, d := range drivers {
if d.Detect(c) {
p.Driver = d.Name()
break
}
}
plans = append(plans, p)
}
return plans
}