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