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

101 lines
3.0 KiB
Go

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)
}