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,68 @@
|
||||
package sink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
)
|
||||
|
||||
// Age is the default Sealer. It wraps the bundle in the age format
|
||||
// (ChaCha20-Poly1305 payload, scrypt-derived key from the passphrase), which is
|
||||
// authenticated: a corrupted or tampered backup fails to open rather than
|
||||
// decrypting to silent garbage. A bundle can also be restored by hand with the
|
||||
// standard `age` CLI: `age -d backup.incredigo.age`.
|
||||
//
|
||||
// Passphrase caveat: age's NewScrypt{Recipient,Identity} take a Go string, so the
|
||||
// passphrase transits an (immutable, un-zeroable) Go string for the duration of
|
||||
// the operation. The stored secrets themselves never do — they stream through as
|
||||
// bytes (see bundle.go). This is the one place incredigo's RAM-only guarantee is
|
||||
// reduced to "the passphrase, not the vaulted secrets."
|
||||
type Age struct {
|
||||
// WorkFactor is the scrypt log2(N) cost. Default 18 (~256 MiB) — a deliberate
|
||||
// brute-force speed bump for a passphrase-protected disaster-recovery bundle.
|
||||
WorkFactor int
|
||||
}
|
||||
|
||||
func (a *Age) Name() string { return "age" }
|
||||
|
||||
func (a *Age) workFactor() int {
|
||||
if a.WorkFactor == 0 {
|
||||
return 18
|
||||
}
|
||||
return a.WorkFactor
|
||||
}
|
||||
|
||||
func (a *Age) Seal(ctx context.Context, plaintext io.Reader, out io.Writer, pass *memguard.LockedBuffer) error {
|
||||
r, err := age.NewScryptRecipient(string(pass.Bytes()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("age: recipient: %w", err)
|
||||
}
|
||||
r.SetWorkFactor(a.workFactor())
|
||||
w, err := age.Encrypt(out, r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("age: encrypt: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(w, plaintext); err != nil {
|
||||
_ = w.Close()
|
||||
return fmt.Errorf("age: write: %w", err)
|
||||
}
|
||||
return w.Close()
|
||||
}
|
||||
|
||||
func (a *Age) Open(ctx context.Context, in io.Reader, out io.Writer, pass *memguard.LockedBuffer) error {
|
||||
id, err := age.NewScryptIdentity(string(pass.Bytes()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("age: identity: %w", err)
|
||||
}
|
||||
r, err := age.Decrypt(in, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("age: decrypt (wrong passphrase or corrupt bundle?): %w", err)
|
||||
}
|
||||
if _, err := io.Copy(out, r); err != nil {
|
||||
return fmt.Errorf("age: read: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package sink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"incredigo/internal/discover"
|
||||
)
|
||||
|
||||
// writeFakeGopass writes a stub `gopass` binary that stores entries as files under
|
||||
// $FAKE_GOPASS_STORE, supporting the ls/show/insert subcommands the bundle code
|
||||
// uses. This exercises the real framing + subprocess plumbing without touching the
|
||||
// user's actual gopass store.
|
||||
func writeFakeGopass(t *testing.T) string {
|
||||
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)
|
||||
}
|
||||
return bin
|
||||
}
|
||||
|
||||
func seed(t *testing.T, store string, entries map[string]string) {
|
||||
t.Helper()
|
||||
for path, secret := range entries {
|
||||
full := filepath.Join(store, path)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(secret), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundleRoundTrip(t *testing.T) {
|
||||
bin := writeFakeGopass(t)
|
||||
gp := &Gopass{Bin: bin}
|
||||
ctx := context.Background()
|
||||
|
||||
src := t.TempDir()
|
||||
seed(t, src, map[string]string{
|
||||
"imported/aws/default": "AKIA/secret+with=specials",
|
||||
"imported/env/api": "unicode-末-\U0001f511",
|
||||
"other/skip": "should-not-export",
|
||||
})
|
||||
|
||||
// Export the imported/ subtree to a framed (unsealed) buffer.
|
||||
t.Setenv("FAKE_GOPASS_STORE", src)
|
||||
var buf bytes.Buffer
|
||||
n, err := gp.ExportTo(ctx, &buf, "imported/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("exported %d entries, want 2 (other/ excluded by prefix)", n)
|
||||
}
|
||||
|
||||
// Import into a fresh store and assert byte-exact restoration.
|
||||
dst := t.TempDir()
|
||||
t.Setenv("FAKE_GOPASS_STORE", dst)
|
||||
m, err := gp.ImportFrom(ctx, &buf, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m != 2 {
|
||||
t.Fatalf("imported %d entries, want 2", m)
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"imported/aws/default": "AKIA/secret+with=specials",
|
||||
"imported/env/api": "unicode-末-\U0001f511",
|
||||
}
|
||||
for path, exp := range want {
|
||||
got, err := os.ReadFile(filepath.Join(dst, path))
|
||||
if err != nil {
|
||||
t.Fatalf("restored %s: %v", path, err)
|
||||
}
|
||||
if string(got) != exp {
|
||||
t.Errorf("%s: got %q want %q", path, got, exp)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dst, "other", "skip")); !os.IsNotExist(err) {
|
||||
t.Error("other/skip leaked across the prefix filter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorePathNoDoubleSegment(t *testing.T) {
|
||||
gp := &Gopass{Prefix: "imported/"}
|
||||
c := discover.Credential{Source: "file", Identity: "keys/deploy"}
|
||||
if got := gp.StorePath(c); got != "imported/file/keys/deploy" {
|
||||
t.Errorf("StorePath = %q, want imported/file/keys/deploy", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// 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: <prefix>/<source>/<slug>.
|
||||
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. 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 {
|
||||
buf, err := v.Open(c.Secret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
args := []string{"insert", "--multiline=false"}
|
||||
if force {
|
||||
args = append(args, "-f")
|
||||
}
|
||||
args = append(args, g.StorePath(c))
|
||||
|
||||
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", g.StorePath(c), err, stderr.String())
|
||||
}
|
||||
// Shrink the in-memory window: this secret is now safely in gopass.
|
||||
v.Forget(c.Secret)
|
||||
return 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(" / ", "/", " ", "-", "*", "")
|
||||
return repl.Replace(s)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package sink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
)
|
||||
|
||||
// HMACSealer is an authenticated Sealer that needs no age dependency — only the
|
||||
// openssl CLI. It is encrypt-then-MAC:
|
||||
//
|
||||
// - confidentiality: AES-256-CTR via `openssl enc`, key = PBKDF2(passphrase,
|
||||
// salt) (the salt rides in openssl's `Salted__` header).
|
||||
// - integrity: HMAC-SHA256 over the ciphertext, key = the passphrase bytes,
|
||||
// computed in-process.
|
||||
//
|
||||
// The MAC is verified BEFORE any decryption, so a tampered or corrupt bundle is
|
||||
// rejected rather than silently decrypted (the property plain `openssl enc`
|
||||
// can't give on OpenSSL 3.x, which refuses AEAD ciphers).
|
||||
//
|
||||
// Bundle layout: HMAC-SHA256(32 bytes) || openssl-CTR-ciphertext
|
||||
//
|
||||
// Unlike the age sealer, the passphrase here is never a Go string: openssl gets
|
||||
// the cipher key over fd 3, and the MAC key is the locked-buffer bytes.
|
||||
//
|
||||
// Hand recovery (also in docs/SECURITY.md):
|
||||
//
|
||||
// head -c 32 bundle > mac.bin
|
||||
// tail -c +33 bundle > ct.bin
|
||||
// openssl dgst -sha256 -hmac "$PASS" -binary ct.bin | cmp - mac.bin # integrity gate
|
||||
// openssl enc -d -aes-256-ctr -pbkdf2 -iter 600000 -pass pass:"$PASS" -in ct.bin
|
||||
type HMACSealer struct {
|
||||
Bin string // default "openssl"
|
||||
Iter int // default 600000
|
||||
}
|
||||
|
||||
func (h *HMACSealer) bin() string {
|
||||
if h.Bin == "" {
|
||||
return "openssl"
|
||||
}
|
||||
return h.Bin
|
||||
}
|
||||
|
||||
func (h *HMACSealer) iter() int {
|
||||
if h.Iter == 0 {
|
||||
return 600000
|
||||
}
|
||||
return h.Iter
|
||||
}
|
||||
|
||||
func (h *HMACSealer) Name() string { return "hmac" }
|
||||
|
||||
func (h *HMACSealer) cipher(ctx context.Context, decrypt bool, in io.Reader, out io.Writer, pass *memguard.LockedBuffer) error {
|
||||
args := []string{"enc", "-aes-256-ctr", "-pbkdf2", "-iter", fmt.Sprint(h.iter()), "-pass", "fd:3"}
|
||||
if decrypt {
|
||||
args = append(args, "-d")
|
||||
} else {
|
||||
args = append(args, "-salt")
|
||||
}
|
||||
return opensslEnc(ctx, h.bin(), args, in, out, pass)
|
||||
}
|
||||
|
||||
func (h *HMACSealer) Seal(ctx context.Context, plaintext io.Reader, out io.Writer, pass *memguard.LockedBuffer) error {
|
||||
// Encrypt to a buffer (ciphertext is not secret), MAC it, then write
|
||||
// mac || ciphertext so the reader can authenticate before decrypting.
|
||||
var ct bytes.Buffer
|
||||
if err := h.cipher(ctx, false, plaintext, &ct, pass); err != nil {
|
||||
return err
|
||||
}
|
||||
mac := hmac.New(sha256.New, pass.Bytes())
|
||||
mac.Write(ct.Bytes())
|
||||
if _, err := out.Write(mac.Sum(nil)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := out.Write(ct.Bytes())
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *HMACSealer) Open(ctx context.Context, in io.Reader, out io.Writer, pass *memguard.LockedBuffer) error {
|
||||
all, err := io.ReadAll(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(all) < sha256.Size {
|
||||
return fmt.Errorf("hmac: bundle too short (%d bytes)", len(all))
|
||||
}
|
||||
want, ct := all[:sha256.Size], all[sha256.Size:]
|
||||
|
||||
mac := hmac.New(sha256.New, pass.Bytes())
|
||||
mac.Write(ct)
|
||||
if !hmac.Equal(want, mac.Sum(nil)) {
|
||||
return fmt.Errorf("hmac: authentication failed (wrong passphrase, or tampered/corrupt bundle)")
|
||||
}
|
||||
return h.cipher(ctx, true, bytes.NewReader(ct), out, pass)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package sink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
)
|
||||
|
||||
// OpenSSL is a non-default Sealer kept for environments that have the openssl CLI
|
||||
// but not age, and for hand-restorability with a one-line openssl command.
|
||||
//
|
||||
// openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 -in backup.enc
|
||||
//
|
||||
// IMPORTANT — this mode is UNAUTHENTICATED. OpenSSL 3.x's `enc` command refuses
|
||||
// AEAD ciphers ("AEAD ciphers not supported"), so there is no GCM/Poly1305 tag:
|
||||
// a tampered or truncated bundle will decrypt to garbage with no error. Prefer the
|
||||
// age sealer (see age.go), which is authenticated, for any backup you care about.
|
||||
//
|
||||
// The passphrase is handed to openssl over an inherited file descriptor
|
||||
// (`-pass fd:3`), never via argv (visible in `ps`) or the environment (visible in
|
||||
// /proc/<pid>/environ). The bytes come straight from a locked buffer.
|
||||
type OpenSSL struct {
|
||||
Bin string // default "openssl"
|
||||
Iter int // default 600000
|
||||
}
|
||||
|
||||
func (o *OpenSSL) bin() string {
|
||||
if o.Bin == "" {
|
||||
return "openssl"
|
||||
}
|
||||
return o.Bin
|
||||
}
|
||||
|
||||
func (o *OpenSSL) iter() int {
|
||||
if o.Iter == 0 {
|
||||
return 600000
|
||||
}
|
||||
return o.Iter
|
||||
}
|
||||
|
||||
func (o *OpenSSL) Name() string { return "openssl" }
|
||||
|
||||
func (o *OpenSSL) run(ctx context.Context, decrypt bool, in io.Reader, out io.Writer, pass *memguard.LockedBuffer) error {
|
||||
args := []string{"enc", "-aes-256-cbc", "-pbkdf2", "-iter", fmt.Sprint(o.iter()), "-pass", "fd:3"}
|
||||
if decrypt {
|
||||
args = append(args, "-d")
|
||||
} else {
|
||||
args = append(args, "-salt")
|
||||
}
|
||||
return opensslEnc(ctx, o.bin(), args, in, out, pass)
|
||||
}
|
||||
|
||||
func (o *OpenSSL) Seal(ctx context.Context, plaintext io.Reader, out io.Writer, pass *memguard.LockedBuffer) error {
|
||||
return o.run(ctx, false, plaintext, out, pass)
|
||||
}
|
||||
|
||||
func (o *OpenSSL) Open(ctx context.Context, in io.Reader, out io.Writer, pass *memguard.LockedBuffer) error {
|
||||
return o.run(ctx, true, in, out, pass)
|
||||
}
|
||||
|
||||
// opensslEnc runs `openssl <args...>` with stdin/stdout wired to in/out and the
|
||||
// passphrase delivered over inherited fd 3. The caller must include
|
||||
// `-pass fd:3` in args. The passphrase bytes come straight from a locked buffer —
|
||||
// they never appear in argv (visible in `ps`) or the environment (visible in
|
||||
// /proc/<pid>/environ). Shared by the OpenSSL and HMAC sealers.
|
||||
func opensslEnc(ctx context.Context, bin string, args []string, in io.Reader, out io.Writer, pass *memguard.LockedBuffer) error {
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, bin, args...)
|
||||
cmd.Stdin = in
|
||||
cmd.Stdout = out
|
||||
cmd.ExtraFiles = []*os.File{pr} // child sees this as fd 3
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
pr.Close()
|
||||
pw.Close()
|
||||
return err
|
||||
}
|
||||
pr.Close() // the child holds its own copy of the read end
|
||||
|
||||
// Feed the passphrase from locked memory, then close so openssl sees EOF.
|
||||
_, werr := pw.Write(pass.Bytes())
|
||||
pw.Close()
|
||||
|
||||
if err := cmd.Wait(); err != nil {
|
||||
return fmt.Errorf("%s: %v: %s", bin, err, stderr.String())
|
||||
}
|
||||
return werr
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package sink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
)
|
||||
|
||||
// Sealer produces and opens portable, agent-independent encrypted backup bundles.
|
||||
//
|
||||
// gopass already GPG-encrypts every entry, so incredigo deliberately does NOT
|
||||
// double-wrap individual secrets. A Sealer instead seals the whole exported set
|
||||
// into one blob that can be restored on a bare machine with nothing but this one
|
||||
// tool and a passphrase — no gpg-agent, no keyring.
|
||||
//
|
||||
// openssl is the default implementation (see openssl.go). Because this is an
|
||||
// interface, a contributor can drop in age (github.com/FiloSottile/age) without
|
||||
// touching anything else in incredigo.
|
||||
type Sealer interface {
|
||||
Name() string
|
||||
Seal(ctx context.Context, plaintext io.Reader, out io.Writer, pass *memguard.LockedBuffer) error
|
||||
Open(ctx context.Context, in io.Reader, out io.Writer, pass *memguard.LockedBuffer) error
|
||||
}
|
||||
Reference in New Issue
Block a user