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:
+87
-1
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"incredigo/internal/audit"
|
||||
"incredigo/internal/discover"
|
||||
"incredigo/internal/policy"
|
||||
"incredigo/internal/rotate"
|
||||
"incredigo/internal/sink"
|
||||
"incredigo/internal/vault"
|
||||
)
|
||||
@@ -49,7 +51,7 @@ func main() {
|
||||
root.PersistentFlags().StringVar(&flagConfig, "config", "", "policy.yaml path")
|
||||
root.PersistentFlags().StringVar(&flagSealer, "sealer", "age", "backup sealer: age (default, authenticated), hmac (authenticated, openssl-only), or openssl (unauthenticated)")
|
||||
|
||||
root.AddCommand(scanCmd(), migrateCmd(), statusCmd(), exportCmd(), importCmd())
|
||||
root.AddCommand(scanCmd(), migrateCmd(), statusCmd(), exportCmd(), importCmd(), rotateCmd())
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "incredigo:", err)
|
||||
@@ -323,6 +325,90 @@ func importCmd() *cobra.Command {
|
||||
return c
|
||||
}
|
||||
|
||||
// rotateCmd is the rotation entry point. ROTATION IS NOT IMPLEMENTED (design
|
||||
// phase, see docs/ROTATION.md). The command runs the mandatory backup gate and
|
||||
// prints the rotation plan, but cannot and does not change any credential: there
|
||||
// are zero registered drivers, and --execute is refused.
|
||||
func rotateCmd() *cobra.Command {
|
||||
var prefix, backupOut string
|
||||
var execute bool
|
||||
c := &cobra.Command{
|
||||
Use: "rotate",
|
||||
Short: "Plan rotation + take a verified backup (DESIGN PHASE — rotates nothing)",
|
||||
Long: "Rotation is not implemented yet (see docs/ROTATION.md). This command runs the\n" +
|
||||
"MANDATORY backup gate (sealed, verified snapshot of the gopass prefix) and prints\n" +
|
||||
"the rotation plan. It never changes a credential.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if execute {
|
||||
return fmt.Errorf("rotate --execute refused: no rotation drivers are implemented (design phase) — see docs/ROTATION.md")
|
||||
}
|
||||
applyPaths()
|
||||
gp := &sink.Gopass{Prefix: prefix}
|
||||
if !gp.Available() {
|
||||
return fmt.Errorf("gopass not found on PATH")
|
||||
}
|
||||
sealer, err := sealerFor(flagSealer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if backupOut == "" {
|
||||
dir := filepath.Join(os.Getenv("HOME"), ".incredigo", "rotation-backups")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
backupOut = filepath.Join(dir, time.Now().UTC().Format("2006-01-02T15-04-05Z")+".age")
|
||||
}
|
||||
|
||||
return withVault(func(ctx context.Context, v *vault.Vault) error {
|
||||
h, err := readPassphrase(v, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pass, err := v.Open(h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// MANDATORY backup gate — rotation may never run without this.
|
||||
n, err := rotate.Snapshot(ctx, gp, prefix, sealer, pass, backupOut)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup gate failed — rotation would be blocked: %w", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "✓ backup gate: %d entr(ies) sealed + verified -> %s\n\n", n, backupOut)
|
||||
|
||||
// Non-destructive plan over discoverable credentials.
|
||||
creds, err := discover.ScanAll(ctx, v, flagSources...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "DRIVER\tSOURCE\tIDENTITY")
|
||||
for _, p := range rotate.PlanAll(creds) {
|
||||
drv := p.Driver
|
||||
if drv == "" {
|
||||
drv = "(none)"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n", drv, p.Credential.Source, p.Credential.Identity)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
log, _ := audit.Open(flagAuditLog, time.Now)
|
||||
defer log.Close()
|
||||
log.Write(audit.Entry{Action: "rotate-plan", Outcome: "ok", Location: backupOut})
|
||||
fmt.Fprintf(os.Stderr, "\nDESIGN PHASE: %d rotation driver(s) registered — nothing rotated. See docs/ROTATION.md\n",
|
||||
len(rotate.Drivers()))
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
c.Flags().StringVar(&prefix, "prefix", "imported/", "gopass prefix to back up and plan")
|
||||
c.Flags().StringVar(&backupOut, "backup-out", "", "backup bundle path (default ~/.incredigo/rotation-backups/<ts>.age)")
|
||||
c.Flags().BoolVar(&execute, "execute", false, "(reserved) perform rotation — refused in design phase")
|
||||
c.Flags().StringArrayVar(&flagPaths, "path", nil, "extra file or directory to include in the plan")
|
||||
c.Flags().StringSliceVar(&flagSources, "source", nil, "limit discovery to these scanners")
|
||||
return c
|
||||
}
|
||||
|
||||
// sealerFor selects the backup sealer. age is the authenticated default; openssl
|
||||
// is an unauthenticated fallback (see internal/sink/openssl.go).
|
||||
func sealerFor(name string) (sink.Sealer, error) {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package rotate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
|
||||
"incredigo/internal/sink"
|
||||
)
|
||||
|
||||
func fakeGopass(t *testing.T) *sink.Gopass {
|
||||
t.Helper()
|
||||
bin := filepath.Join(t.TempDir(), "gopass")
|
||||
script := `#!/usr/bin/env bash
|
||||
store="$FAKE_GOPASS_STORE"
|
||||
case "$1" in
|
||||
ls) (cd "$store" 2>/dev/null && find . -type f | sed 's#^\./##' | sort) ;;
|
||||
show) cat "$store/$3" ;;
|
||||
insert) shift; path=""; for a in "$@"; do case "$a" in --multiline=false|-f) ;; *) path="$a";; esac; done
|
||||
mkdir -p "$store/$(dirname "$path")"; cat > "$store/$path" ;;
|
||||
esac
|
||||
`
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := t.TempDir()
|
||||
t.Setenv("FAKE_GOPASS_STORE", store)
|
||||
for p, s := range map[string]string{
|
||||
"imported/aws/default": "AKIA-secret-1",
|
||||
"imported/env/api": "tok-2-末",
|
||||
"other/skip": "not-backed-up",
|
||||
} {
|
||||
full := filepath.Join(store, p)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(s), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return &sink.Gopass{Bin: bin}
|
||||
}
|
||||
|
||||
func TestSnapshotBackupGate(t *testing.T) {
|
||||
gp := fakeGopass(t)
|
||||
pass := memguard.NewBufferFromBytes([]byte("backup-pass"))
|
||||
defer pass.Destroy()
|
||||
out := filepath.Join(t.TempDir(), "backup.age")
|
||||
|
||||
n, err := Snapshot(context.Background(), gp, "imported/", &sink.Age{}, pass, out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("backed up %d entries, want 2 (other/ excluded by prefix)", n)
|
||||
}
|
||||
fi, err := os.Stat(out)
|
||||
if err != nil || fi.Size() == 0 {
|
||||
t.Fatalf("backup bundle missing or empty: %v", err)
|
||||
}
|
||||
|
||||
// The gate must refuse to overwrite an existing backup.
|
||||
if _, err := Snapshot(context.Background(), gp, "imported/", &sink.Age{}, pass, out); err == nil {
|
||||
t.Error("Snapshot should refuse to overwrite an existing backup path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyBundleDetectsTamper(t *testing.T) {
|
||||
gp := fakeGopass(t)
|
||||
pass := memguard.NewBufferFromBytes([]byte("pw"))
|
||||
defer pass.Destroy()
|
||||
out := filepath.Join(t.TempDir(), "b.age")
|
||||
if _, err := Snapshot(context.Background(), gp, "imported/", &sink.Age{}, pass, out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b[len(b)/2] ^= 0x01 // flip a byte
|
||||
tampered := filepath.Join(t.TempDir(), "t.age")
|
||||
if err := os.WriteFile(tampered, b, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifyBundle(context.Background(), &sink.Age{}, pass, tampered); err == nil {
|
||||
t.Error("verifyBundle must fail on a tampered bundle")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// 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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user