425c299359
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>
91 lines
2.7 KiB
Go
91 lines
2.7 KiB
Go
// Package vault is the RAM-only custody core of incredigo.
|
|
//
|
|
// All decrypted secret material lives here and nowhere else. Buffers are backed
|
|
// by github.com/awnumar/memguard, which mlocks the pages (no swap), marks them
|
|
// MADV_DONTDUMP (no core dumps), surrounds them with guard pages, and zeroizes
|
|
// on Destroy. Plaintext is never returned as a Go string (strings are immutable,
|
|
// GC-managed and cannot be reliably wiped) — callers receive a Handle and must
|
|
// Open it for a short-lived locked buffer they then Destroy.
|
|
package vault
|
|
|
|
import (
|
|
"fmt"
|
|
"sync"
|
|
|
|
"github.com/awnumar/memguard"
|
|
)
|
|
|
|
// Handle is an opaque reference to a secret stored in the vault. It contains no
|
|
// plaintext and is safe to pass around, log (it has no String of the secret),
|
|
// and embed in metadata records.
|
|
type Handle struct {
|
|
id uint64
|
|
}
|
|
|
|
// Vault owns every live secret buffer for the process lifetime.
|
|
type Vault struct {
|
|
mu sync.Mutex
|
|
next uint64
|
|
bufs map[uint64]*memguard.LockedBuffer
|
|
}
|
|
|
|
// New creates a vault and installs a signal handler so secrets are purged from
|
|
// memory on SIGINT/SIGTERM as well as normal exit.
|
|
func New() *Vault {
|
|
memguard.CatchInterrupt() // wipes all locked buffers on interrupt, then exits
|
|
return &Vault{bufs: make(map[uint64]*memguard.LockedBuffer)}
|
|
}
|
|
|
|
// Store copies plaintext into a freshly locked buffer and wipes the caller's
|
|
// slice. The returned Handle is the only way back to the bytes.
|
|
func (v *Vault) Store(plaintext []byte) *Handle {
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
|
|
buf := memguard.NewBufferFromBytes(plaintext) // also wipes the source slice
|
|
id := v.next
|
|
v.next++
|
|
v.bufs[id] = buf
|
|
return &Handle{id: id}
|
|
}
|
|
|
|
// Open returns the locked buffer for a handle. The caller MUST treat the bytes
|
|
// as ephemeral: use them immediately (e.g. write to a pipe) and do not copy them
|
|
// into a string. The buffer is owned by the vault; do not Destroy it directly —
|
|
// use Forget or Purge.
|
|
func (v *Vault) Open(h *Handle) (*memguard.LockedBuffer, error) {
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
|
|
buf, ok := v.bufs[h.id]
|
|
if !ok {
|
|
return nil, fmt.Errorf("vault: unknown or already-purged handle %d", h.id)
|
|
}
|
|
return buf, nil
|
|
}
|
|
|
|
// Forget destroys (zeroizes + unlocks) a single secret as soon as it is no
|
|
// longer needed, shrinking the in-memory window.
|
|
func (v *Vault) Forget(h *Handle) {
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
|
|
if buf, ok := v.bufs[h.id]; ok {
|
|
buf.Destroy()
|
|
delete(v.bufs, h.id)
|
|
}
|
|
}
|
|
|
|
// Purge zeroizes and frees every secret. Call from a deferred cleanup at the top
|
|
// of every command so plaintext never outlives the run.
|
|
func (v *Vault) Purge() {
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
|
|
for id, buf := range v.bufs {
|
|
buf.Destroy()
|
|
delete(v.bufs, id)
|
|
}
|
|
memguard.Purge()
|
|
}
|