Files
incredigo/internal/sink/bundle.go
T
leetcrypt 425c299359 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>
2026-06-15 07:57:34 -07:00

193 lines
5.5 KiB
Go

package sink
// Bundle framing — the PLAINTEXT side of the Sealer.
//
// A bundle is a flat stream of records, each one gopass entry. It is produced by
// ExportTo and consumed by ImportFrom; in between it only ever exists encrypted
// (the Sealer wraps it) or in-flight through an io.Pipe. The wire format is:
//
// record* end
// record := uint16 pathLen // big-endian; pathLen==0 is the end sentinel
// [pathLen]byte path // non-secret gopass path, UTF-8
// chunk* secretEnd
// chunk := uint32 chunkLen // big-endian; chunkLen==0 ends this secret
// [chunkLen]byte chunk // a slice of the secret value
// end := uint16 0 // zero-length path terminates the stream
//
// Secrets are streamed in chunks so a complete secret value is never assembled in
// one buffer and never becomes a Go string: bytes flow gopass.stdout -> frame ->
// pipe (export), and pipe -> frame -> gopass.stdin (import). Paths are not secret.
import (
"context"
"encoding/binary"
"fmt"
"io"
"os/exec"
"strings"
)
const exportChunk = 32 * 1024
// ListPaths returns the gopass entry paths under prefix, in gopass's order.
func (g *Gopass) ListPaths(ctx context.Context, prefix string) ([]string, error) {
out, err := exec.CommandContext(ctx, g.bin(), "ls", "--flat").Output()
if err != nil {
return nil, fmt.Errorf("gopass ls: %w", err)
}
var paths []string
for _, line := range strings.Split(string(out), "\n") {
p := strings.TrimSpace(line)
if p == "" {
continue
}
if prefix == "" || strings.HasPrefix(p, prefix) {
paths = append(paths, p)
}
}
return paths, nil
}
// ExportTo writes the framed plaintext record stream for every entry under prefix
// to w. Each secret is streamed straight from `gopass show -o` into the frame; it
// is never buffered whole and never converted to a string. Returns the entry count.
func (g *Gopass) ExportTo(ctx context.Context, w io.Writer, prefix string) (int, error) {
paths, err := g.ListPaths(ctx, prefix)
if err != nil {
return 0, err
}
for _, p := range paths {
if err := writePath(w, p); err != nil {
return 0, err
}
if err := g.streamSecret(ctx, w, p); err != nil {
return 0, err
}
}
// end-of-stream sentinel: a zero-length path
if err := binary.Write(w, binary.BigEndian, uint16(0)); err != nil {
return 0, err
}
return len(paths), nil
}
// streamSecret pipes one entry's secret value into w as a chunk sequence followed
// by a zero-length end marker.
func (g *Gopass) streamSecret(ctx context.Context, w io.Writer, path string) error {
cmd := exec.CommandContext(ctx, g.bin(), "show", "-o", path)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
buf := make([]byte, exportChunk)
for {
n, rerr := stdout.Read(buf)
if n > 0 {
if err := binary.Write(w, binary.BigEndian, uint32(n)); err != nil {
_ = cmd.Wait()
return err
}
if _, err := w.Write(buf[:n]); err != nil {
_ = cmd.Wait()
return err
}
}
if rerr == io.EOF {
break
}
if rerr != nil {
_ = cmd.Wait()
return rerr
}
}
if err := cmd.Wait(); err != nil {
return fmt.Errorf("gopass show %q: %w", path, err)
}
// zero-length chunk ends this secret
return binary.Write(w, binary.BigEndian, uint32(0))
}
// ImportFrom reads a framed plaintext record stream from r and re-inserts every
// entry into gopass. Each secret streams frame -> gopass stdin without ever being
// held whole or stringified. force overwrites existing entries. Returns the count.
func (g *Gopass) ImportFrom(ctx context.Context, r io.Reader, force bool) (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 // tolerate missing sentinel at clean EOF
}
return n, err
}
if pathLen == 0 {
return n, nil // end-of-stream sentinel
}
pb := make([]byte, pathLen)
if _, err := io.ReadFull(r, pb); err != nil {
return n, err
}
if err := g.insertStream(ctx, r, string(pb), force); err != nil {
return n, err
}
n++
}
}
// insertStream spawns `gopass insert` for path and feeds it the chunked secret
// from r (consuming exactly up to this record's zero-length end marker).
func (g *Gopass) insertStream(ctx context.Context, r io.Reader, path string, force bool) error {
args := []string{"insert", "--multiline=false"}
if force {
args = append(args, "-f")
}
args = append(args, path)
cmd := exec.CommandContext(ctx, g.bin(), args...)
stdin, err := cmd.StdinPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
copyErr := copySecret(stdin, r)
stdin.Close()
if werr := cmd.Wait(); werr != nil {
return fmt.Errorf("gopass insert %q: %w", path, werr)
}
return copyErr
}
// copySecret streams one secret's chunks from r to dst until the zero-length end
// marker. Uses io.CopyN so a full secret is never buffered.
func copySecret(dst io.Writer, r io.Reader) error {
for {
var chunkLen uint32
if err := binary.Read(r, binary.BigEndian, &chunkLen); err != nil {
return err
}
if chunkLen == 0 {
return nil
}
if _, err := io.CopyN(dst, r, int64(chunkLen)); err != nil {
return err
}
}
}
func writePath(w io.Writer, path string) error {
if len(path) > 0xffff {
return fmt.Errorf("gopass path too long: %d bytes", len(path))
}
if err := binary.Write(w, binary.BigEndian, uint16(len(path))); err != nil {
return err
}
_, err := io.WriteString(w, path)
return err
}