// Package sink writes secrets out of the RAM vault. gopass is the live store // (GPG); openssl produces portable sealed backups. Plaintext flows vault → pipe // → external tool's stdin, and never touches the filesystem in cleartext. package sink import ( "bytes" "context" "fmt" "os/exec" "path" "strings" "incredigo/internal/discover" "incredigo/internal/vault" ) // Gopass streams secrets into `gopass insert`. The binary path is configurable // for testing; defaults to "gopass" on PATH. type Gopass struct { Bin string // default "gopass" Prefix string // default "imported/" } func (g *Gopass) bin() string { if g.Bin == "" { return "gopass" } return g.Bin } // Available reports whether gopass is usable. func (g *Gopass) Available() bool { _, err := exec.LookPath(g.bin()) return err == nil } // StorePath is the gopass entry path for a credential: //. func (g *Gopass) StorePath(c discover.Credential) string { prefix := g.Prefix if prefix == "" { prefix = "imported/" } return path.Join(prefix, c.Source, slug(c.Identity)) } // Insert writes one credential's secret into gopass at its StorePath. The // plaintext is taken from the locked vault buffer and piped directly to gopass // stdin; we never build a plaintext string or temp file. `-f` forces overwrite; // caller decides dedupe. func (g *Gopass) Insert(ctx context.Context, v *vault.Vault, c discover.Credential, force bool) error { if err := g.InsertAt(ctx, v, g.StorePath(c), c.Secret, force); err != nil { return err } // Shrink the in-memory window: this secret is now safely in gopass. v.Forget(c.Secret) return nil } // InsertAt writes a secret handle to an exact gopass path. Used by rotation to // store a freshly minted secret back at the same entry it came from (the credential // path is known, not recomputed). The plaintext streams from locked memory to // gopass stdin and is never stringified. Unlike Insert it does NOT forget the // handle — the caller may still need it (e.g. to re-read/verify). func (g *Gopass) InsertAt(ctx context.Context, v *vault.Vault, storePath string, secret *vault.Handle, force bool) error { buf, err := v.Open(secret) if err != nil { return err } args := []string{"insert", "--multiline=false"} if force { args = append(args, "-f") } args = append(args, storePath) cmd := exec.CommandContext(ctx, g.bin(), args...) // buf.Bytes() is the locked memory; gopass reads it from the pipe and GPG- // encrypts. The bytes are never copied into a managed Go string. cmd.Stdin = bytes.NewReader(buf.Bytes()) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return fmt.Errorf("gopass insert %q: %v: %s", storePath, err, stderr.String()) } return nil } // Show reads one entry's secret value (`gopass show -o`) into the locked vault and // returns a handle to it. The plaintext exists only transiently as captured output, // which is wiped immediately after copying into the vault. Used by rotation to load // a stored credential for in-place rotation (Mode A — rotate what's in gopass). func (g *Gopass) Show(ctx context.Context, v *vault.Vault, storePath string) (*vault.Handle, error) { cmd := exec.CommandContext(ctx, g.bin(), "show", "-o", storePath) var out, stderr bytes.Buffer cmd.Stdout = &out cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return nil, fmt.Errorf("gopass show %q: %v: %s", storePath, err, stderr.String()) } b := out.Bytes() b = bytes.TrimRight(b, "\r\n") h := v.Store(b) // Store copies into locked memory and wipes b for i := range out.Bytes() { out.Bytes()[i] = 0 // belt-and-suspenders: wipe the transient capture buffer } return h, nil } // Exists reports whether an entry already exists (for --dedupe). func (g *Gopass) Exists(ctx context.Context, storePath string) bool { cmd := exec.CommandContext(ctx, g.bin(), "ls", "--flat") out, err := cmd.Output() if err != nil { return false } for _, line := range strings.Split(string(out), "\n") { if strings.TrimSpace(line) == storePath { return true } } return false } func slug(s string) string { s = strings.ToLower(strings.TrimSpace(s)) repl := strings.NewReplacer(" / ", "/", " ", "-", "*", "") s = repl.Replace(s) // Strip leading dots per path segment. gopass hides dot-prefixed // directories/leaves from `ls --flat`, so a segment like ".env" would make // the entry invisible to the backup gate's coverage check — silently // violating Hard Rule #1 (backup before rotate). See slug regression test. segs := strings.Split(s, "/") for i, seg := range segs { segs[i] = strings.TrimLeft(seg, ".") } return strings.Join(segs, "/") }