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"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
@@ -20,6 +21,7 @@ import (
|
|||||||
"incredigo/internal/audit"
|
"incredigo/internal/audit"
|
||||||
"incredigo/internal/discover"
|
"incredigo/internal/discover"
|
||||||
"incredigo/internal/policy"
|
"incredigo/internal/policy"
|
||||||
|
"incredigo/internal/rotate"
|
||||||
"incredigo/internal/sink"
|
"incredigo/internal/sink"
|
||||||
"incredigo/internal/vault"
|
"incredigo/internal/vault"
|
||||||
)
|
)
|
||||||
@@ -49,7 +51,7 @@ func main() {
|
|||||||
root.PersistentFlags().StringVar(&flagConfig, "config", "", "policy.yaml path")
|
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.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 {
|
if err := root.Execute(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "incredigo:", err)
|
fmt.Fprintln(os.Stderr, "incredigo:", err)
|
||||||
@@ -323,6 +325,90 @@ func importCmd() *cobra.Command {
|
|||||||
return c
|
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
|
// sealerFor selects the backup sealer. age is the authenticated default; openssl
|
||||||
// is an unauthenticated fallback (see internal/sink/openssl.go).
|
// is an unauthenticated fallback (see internal/sink/openssl.go).
|
||||||
func sealerFor(name string) (sink.Sealer, error) {
|
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 {
|
func writePath(w io.Writer, path string) error {
|
||||||
if len(path) > 0xffff {
|
if len(path) > 0xffff {
|
||||||
return fmt.Errorf("gopass path too long: %d bytes", len(path))
|
return fmt.Errorf("gopass path too long: %d bytes", len(path))
|
||||||
|
|||||||
Reference in New Issue
Block a user