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>
83 lines
2.7 KiB
Go
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
|
|
}
|