Files
incredigo/internal/sink/age.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

69 lines
2.1 KiB
Go

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
}