Initial commit: Incredigo — local-first RAM-only credential custody
v1: discover → migrate → expiry tracking → sealed export/import. - vault: memguard mlock/DONTDUMP arena, handle indirection, zeroize-on-drop - discover: registry + 8 read-only scanners (aws, env, netrc, ssh, docker, kube, git) and a file/dir harvester (--path) - sink: gopass streaming insert; length-prefixed bundle framing; Sealer interface with three impls — age (default, authenticated), hmac (authenticated, openssl-only encrypt-then-MAC), openssl (CBC fallback, unauthenticated; OpenSSL 3.x enc refuses AEAD) - policy: local expiry engine, 60d/8w threshold parser - audit: redacted append-only JSONL, injectable clock - export/import: passphrase from no-echo prompt or $INCREDIGO_PASSPHRASE into locked memory; secrets stream gopass<->Sealer as bytes, never Go strings - tests: scanner leak-checks, vault zeroize, bundle round-trip via fake gopass; go test ./... green, -race clean Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
// Command incredigo is a local-first, encrypted, RAM-only credential custody tool.
|
||||
//
|
||||
// v1 subcommands: scan, migrate, status, export, import.
|
||||
// See DESIGN.md for the full architecture and threat model.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
"incredigo/internal/audit"
|
||||
"incredigo/internal/discover"
|
||||
"incredigo/internal/policy"
|
||||
"incredigo/internal/sink"
|
||||
"incredigo/internal/vault"
|
||||
)
|
||||
|
||||
var (
|
||||
flagSources []string
|
||||
flagPaths []string
|
||||
flagPrefix string
|
||||
flagDedupe bool
|
||||
flagForce bool
|
||||
flagJSON bool
|
||||
flagDryRun bool
|
||||
flagAuditLog string
|
||||
flagConfig string
|
||||
flagSealer string
|
||||
)
|
||||
|
||||
func main() {
|
||||
root := &cobra.Command{
|
||||
Use: "incredigo",
|
||||
Short: "Local-first, encrypted, RAM-only credential custody",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
}
|
||||
root.PersistentFlags().BoolVar(&flagDryRun, "dry-run", false, "do not write anything")
|
||||
root.PersistentFlags().StringVar(&flagAuditLog, "audit-log", "", "append-only redacted audit log 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.AddCommand(scanCmd(), migrateCmd(), statusCmd(), exportCmd(), importCmd())
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "incredigo:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// applyPaths registers any --path targets with the file scanner and, when an
|
||||
// explicit --source filter is in play, makes sure "file" is included so --path
|
||||
// just works alongside it.
|
||||
func applyPaths() {
|
||||
for _, p := range flagPaths {
|
||||
discover.AddPath(p)
|
||||
}
|
||||
if len(flagPaths) == 0 || len(flagSources) == 0 {
|
||||
return
|
||||
}
|
||||
for _, s := range flagSources {
|
||||
if s == "file" {
|
||||
return
|
||||
}
|
||||
}
|
||||
flagSources = append(flagSources, "file")
|
||||
}
|
||||
|
||||
// withVault sets up the RAM vault and guarantees it is purged on exit.
|
||||
func withVault(fn func(ctx context.Context, v *vault.Vault) error) error {
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
return fn(context.Background(), v)
|
||||
}
|
||||
|
||||
func scanCmd() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "scan",
|
||||
Short: "Discover credentials (no secrets printed)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
applyPaths()
|
||||
return withVault(func(ctx context.Context, v *vault.Vault) error {
|
||||
creds, err := discover.ScanAll(ctx, v, flagSources...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log, _ := audit.Open(flagAuditLog, time.Now)
|
||||
defer log.Close()
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "SOURCE\tKIND\tIDENTITY\tLOCATION")
|
||||
for _, cr := range creds {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", cr.Source, cr.Kind, cr.Identity, cr.Location)
|
||||
log.Write(audit.Entry{Action: "scan", Source: cr.Source,
|
||||
Kind: string(cr.Kind), Identity: cr.Identity, Location: cr.Location, Outcome: "ok"})
|
||||
}
|
||||
w.Flush()
|
||||
fmt.Fprintf(os.Stderr, "\n%d credential(s) found across %d source(s)\n",
|
||||
len(creds), countSources(creds))
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
c.Flags().StringSliceVar(&flagSources, "source", nil, "limit to these scanners (comma-separated)")
|
||||
c.Flags().StringArrayVar(&flagPaths, "path", nil, "extra file or directory to harvest (repeatable)")
|
||||
c.Flags().BoolVar(&flagJSON, "json", false, "json output")
|
||||
return c
|
||||
}
|
||||
|
||||
func migrateCmd() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "migrate",
|
||||
Short: "Migrate discovered credentials into gopass (plaintext never hits disk)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
applyPaths()
|
||||
return withVault(func(ctx context.Context, v *vault.Vault) error {
|
||||
gp := &sink.Gopass{Prefix: flagPrefix}
|
||||
if !gp.Available() {
|
||||
return fmt.Errorf("gopass not found on PATH")
|
||||
}
|
||||
creds, err := discover.ScanAll(ctx, v, flagSources...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log, _ := audit.Open(flagAuditLog, time.Now)
|
||||
defer log.Close()
|
||||
|
||||
var done, skipped int
|
||||
for _, cr := range creds {
|
||||
sp := gp.StorePath(cr)
|
||||
if flagDedupe && gp.Exists(ctx, sp) {
|
||||
skipped++
|
||||
fmt.Printf("skip %s (exists)\n", sp)
|
||||
log.Write(audit.Entry{Action: "migrate", Source: cr.Source, Kind: string(cr.Kind),
|
||||
Identity: cr.Identity, Outcome: "skipped"})
|
||||
continue
|
||||
}
|
||||
if flagDryRun {
|
||||
fmt.Printf("would %s\n", sp)
|
||||
continue
|
||||
}
|
||||
if err := gp.Insert(ctx, v, cr, flagForce); err != nil {
|
||||
log.Write(audit.Entry{Action: "migrate", Source: cr.Source, Kind: string(cr.Kind),
|
||||
Identity: cr.Identity, Outcome: err.Error()})
|
||||
return err
|
||||
}
|
||||
done++
|
||||
fmt.Printf("store %s\n", sp)
|
||||
log.Write(audit.Entry{Action: "migrate", Source: cr.Source, Kind: string(cr.Kind),
|
||||
Identity: cr.Identity, Outcome: "ok"})
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\nmigrated %d, skipped %d\n", done, skipped)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
c.Flags().StringSliceVar(&flagSources, "source", nil, "limit to these scanners")
|
||||
c.Flags().StringArrayVar(&flagPaths, "path", nil, "extra file or directory to harvest (repeatable)")
|
||||
c.Flags().StringVar(&flagPrefix, "prefix", "imported/", "gopass path prefix")
|
||||
c.Flags().BoolVar(&flagDedupe, "dedupe", false, "skip entries that already exist")
|
||||
c.Flags().BoolVar(&flagForce, "force", false, "overwrite existing entries")
|
||||
return c
|
||||
}
|
||||
|
||||
func statusCmd() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Report credential age vs expiry policy",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
applyPaths()
|
||||
pol, err := policy.Load(flagConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return withVault(func(ctx context.Context, v *vault.Vault) error {
|
||||
creds, err := discover.ScanAll(ctx, v, flagSources...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "STATE\tAGE\tSOURCE\tIDENTITY")
|
||||
for _, cr := range creds {
|
||||
st, age := pol.Evaluate(cr, now)
|
||||
fmt.Fprintf(w, "%s\t%dd\t%s\t%s\n", strings.ToUpper(string(st)),
|
||||
int(age.Hours()/24), cr.Source, cr.Identity)
|
||||
}
|
||||
w.Flush()
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
c.Flags().StringSliceVar(&flagSources, "source", nil, "limit to these scanners")
|
||||
c.Flags().StringArrayVar(&flagPaths, "path", nil, "extra file or directory to harvest (repeatable)")
|
||||
return c
|
||||
}
|
||||
|
||||
func exportCmd() *cobra.Command {
|
||||
var out string
|
||||
c := &cobra.Command{
|
||||
Use: "export",
|
||||
Short: "Write a sealed backup bundle of the gopass prefix",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
gp := &sink.Gopass{Prefix: flagPrefix}
|
||||
if !gp.Available() {
|
||||
return fmt.Errorf("gopass not found on PATH")
|
||||
}
|
||||
sealer, err := sealerFor(flagSealer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if flagDryRun {
|
||||
paths, err := gp.ListPaths(context.Background(), flagPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range paths {
|
||||
fmt.Printf("would export %s\n", p)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\n%d entr(ies) under %q\n", len(paths), flagPrefix)
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(out, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s (use a fresh path; export refuses to overwrite): %w", out, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// gopass show -> framed plaintext -> pipe -> Sealer -> file.
|
||||
pr, pw := io.Pipe()
|
||||
var n int
|
||||
go func() {
|
||||
var gerr error
|
||||
n, gerr = gp.ExportTo(ctx, pw, flagPrefix)
|
||||
pw.CloseWithError(gerr)
|
||||
}()
|
||||
if err := sealer.Seal(ctx, pr, f, pass); err != nil {
|
||||
return err
|
||||
}
|
||||
log, _ := audit.Open(flagAuditLog, time.Now)
|
||||
defer log.Close()
|
||||
log.Write(audit.Entry{Action: "export", Outcome: "ok",
|
||||
Location: out, Source: sealer.Name()})
|
||||
fmt.Fprintf(os.Stderr, "sealed %d entr(ies) with %s -> %s\n", n, sealer.Name(), out)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
c.Flags().StringVar(&out, "out", "backup.incredigo.age", "output bundle path (refuses to overwrite)")
|
||||
c.Flags().StringVar(&flagPrefix, "prefix", "imported/", "gopass prefix to export")
|
||||
return c
|
||||
}
|
||||
|
||||
func importCmd() *cobra.Command {
|
||||
var in string
|
||||
c := &cobra.Command{
|
||||
Use: "import",
|
||||
Short: "Restore a sealed bundle into gopass",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
gp := &sink.Gopass{Prefix: flagPrefix}
|
||||
if !gp.Available() {
|
||||
return fmt.Errorf("gopass not found on PATH")
|
||||
}
|
||||
sealer, err := sealerFor(flagSealer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Open(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
return withVault(func(ctx context.Context, v *vault.Vault) error {
|
||||
h, err := readPassphrase(v, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pass, err := v.Open(h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// file -> Sealer.Open -> pipe -> framed plaintext -> gopass insert.
|
||||
pr, pw := io.Pipe()
|
||||
go func() {
|
||||
pw.CloseWithError(sealer.Open(ctx, f, pw, pass))
|
||||
}()
|
||||
n, err := gp.ImportFrom(ctx, pr, flagForce)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log, _ := audit.Open(flagAuditLog, time.Now)
|
||||
defer log.Close()
|
||||
log.Write(audit.Entry{Action: "import", Outcome: "ok",
|
||||
Location: in, Source: sealer.Name()})
|
||||
fmt.Fprintf(os.Stderr, "restored %d entr(ies) from %s\n", n, in)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
c.Flags().StringVar(&in, "in", "backup.incredigo.age", "input bundle path")
|
||||
c.Flags().BoolVar(&flagForce, "force", false, "overwrite existing gopass entries")
|
||||
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) {
|
||||
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||
case "", "age":
|
||||
return &sink.Age{}, nil
|
||||
case "hmac":
|
||||
return &sink.HMACSealer{}, nil
|
||||
case "openssl":
|
||||
return &sink.OpenSSL{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown sealer %q (want: age, hmac, openssl)", name)
|
||||
}
|
||||
}
|
||||
|
||||
// readPassphrase obtains a backup passphrase into the locked vault and returns its
|
||||
// handle. Sources, in order: $INCREDIGO_PASSPHRASE (automation), an interactive
|
||||
// no-echo TTY prompt, or one line from non-terminal stdin (piped). On a TTY,
|
||||
// confirm re-prompts to guard against typos in a backup passphrase.
|
||||
func readPassphrase(v *vault.Vault, confirm bool) (*vault.Handle, error) {
|
||||
if env, ok := os.LookupEnv("INCREDIGO_PASSPHRASE"); ok {
|
||||
if env == "" {
|
||||
return nil, fmt.Errorf("INCREDIGO_PASSPHRASE is set but empty")
|
||||
}
|
||||
return v.Store([]byte(env)), nil
|
||||
}
|
||||
|
||||
fd := int(os.Stdin.Fd())
|
||||
if !term.IsTerminal(fd) {
|
||||
// piped stdin: read a single line as the passphrase
|
||||
line, err := readLine(os.Stdin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(line) == 0 {
|
||||
return nil, fmt.Errorf("empty passphrase on stdin")
|
||||
}
|
||||
return v.Store(line), nil
|
||||
}
|
||||
|
||||
fmt.Fprint(os.Stderr, "Passphrase: ")
|
||||
pw1, err := term.ReadPassword(fd)
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(pw1) == 0 {
|
||||
return nil, fmt.Errorf("empty passphrase")
|
||||
}
|
||||
if confirm {
|
||||
fmt.Fprint(os.Stderr, "Confirm passphrase: ")
|
||||
pw2, err := term.ReadPassword(fd)
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil {
|
||||
wipe(pw1)
|
||||
return nil, err
|
||||
}
|
||||
eq := bytes.Equal(pw1, pw2)
|
||||
wipe(pw2)
|
||||
if !eq {
|
||||
wipe(pw1)
|
||||
return nil, fmt.Errorf("passphrases do not match")
|
||||
}
|
||||
}
|
||||
return v.Store(pw1), nil // Store wipes pw1
|
||||
}
|
||||
|
||||
// readLine reads bytes up to (not including) the first newline.
|
||||
func readLine(r io.Reader) ([]byte, error) {
|
||||
var out []byte
|
||||
b := make([]byte, 1)
|
||||
for {
|
||||
n, err := r.Read(b)
|
||||
if n > 0 {
|
||||
if b[0] == '\n' {
|
||||
return out, nil
|
||||
}
|
||||
out = append(out, b[0])
|
||||
}
|
||||
if err == io.EOF {
|
||||
return out, nil
|
||||
}
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func wipe(b []byte) {
|
||||
for i := range b {
|
||||
b[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
func countSources(creds []discover.Credential) int {
|
||||
seen := map[string]struct{}{}
|
||||
for _, c := range creds {
|
||||
seen[c.Source] = struct{}{}
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
Reference in New Issue
Block a user