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,192 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user