826d804bd9
The backup gate is only as trustworthy as the sealers; internal/sink was the safety-critical coverage gap blocking all real rotation work. sealer_test.go: round-trip per sealer (age/hmac/openssl) across ascii/binary/ unicode/empty/large payloads, asserting plaintext never leaks into sealed output; tamper fails closed (age & hmac error with zero bytes emitted on a flipped ciphertext or MAC byte); openssl's unauthenticated failure mode pinned as an executable contract (no exact-plaintext recovery only); wrong-passphrase clean-error; short-bundle, bad-binary, and empty-passphrase error paths. framing_test.go: bundle framing (zero-record, clean-EOF, truncated mid-path/ len/secret, oversized path); gopass wrapper Insert/InsertAt/Show/Exists/ Available; secret-never-in-argv leak assertion (Hard Rule #3); gopass-failure paths surface errors instead of silently short backups; end-to-end export -> seal -> open -> count -> restore proving completeness + authenticity together. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
255 lines
9.2 KiB
Go
255 lines
9.2 KiB
Go
package sink
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/awnumar/memguard"
|
|
)
|
|
|
|
// newPass returns a fresh locked buffer for s. A fresh buffer is used per Seal/Open
|
|
// call because memguard.NewBufferFromBytes wipes its source slice, and reusing one
|
|
// buffer across operations would couple unrelated calls.
|
|
func newPass(s string) *memguard.LockedBuffer {
|
|
return memguard.NewBufferFromBytes([]byte(s))
|
|
}
|
|
|
|
// authenticatedSealers fail closed on tamper / wrong passphrase. openssl is
|
|
// deliberately excluded — it is unauthenticated (see openssl.go) and its tamper
|
|
// behaviour is asserted separately in TestOpenSSLTamperUnauthenticated.
|
|
func authenticatedSealers() map[string]Sealer {
|
|
return map[string]Sealer{
|
|
"age": &Age{WorkFactor: 10}, // low cost keeps the test fast; correctness is unaffected
|
|
"hmac": &HMACSealer{Iter: 1000},
|
|
}
|
|
}
|
|
|
|
func allSealers() map[string]Sealer {
|
|
s := authenticatedSealers()
|
|
s["openssl"] = &OpenSSL{Iter: 1000}
|
|
return s
|
|
}
|
|
|
|
// TestSealerRoundTrip proves every sealer is byte-exact: seal → open == original,
|
|
// including empty and binary payloads.
|
|
func TestSealerRoundTrip(t *testing.T) {
|
|
ctx := context.Background()
|
|
payloads := map[string][]byte{
|
|
"ascii": []byte("AKIA/secret+with=specials"),
|
|
"binary": {0x00, 0xff, 0x0a, 0x0d, 0x1b, 0x7f, 0x80},
|
|
"unicode": []byte("unicode-末-\U0001f511"),
|
|
"empty": {},
|
|
"large": bytes.Repeat([]byte("credential-storage-turnover "), 4096),
|
|
}
|
|
for name, sealer := range allSealers() {
|
|
for pname, pt := range payloads {
|
|
t.Run(name+"/"+pname, func(t *testing.T) {
|
|
var sealed bytes.Buffer
|
|
if err := sealer.Seal(ctx, bytes.NewReader(pt), &sealed, newPass("correct horse")); err != nil {
|
|
t.Fatalf("seal: %v", err)
|
|
}
|
|
// Sealed form must not be the plaintext (except that an empty
|
|
// plaintext may legitimately produce a tiny header-only blob).
|
|
if len(pt) > 0 && bytes.Contains(sealed.Bytes(), pt) {
|
|
t.Fatal("plaintext leaked into sealed output")
|
|
}
|
|
var opened bytes.Buffer
|
|
if err := sealer.Open(ctx, &sealed, &opened, newPass("correct horse")); err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
if !bytes.Equal(opened.Bytes(), pt) {
|
|
t.Fatalf("round-trip mismatch: got %q want %q", opened.Bytes(), pt)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSealerWrongPassphrase proves that opening with the wrong passphrase fails
|
|
// cleanly and yields no plaintext for the authenticated sealers. openssl (CBC,
|
|
// unauthenticated) may or may not error, so it is tested for the weaker property:
|
|
// it must never reproduce the original plaintext under a wrong passphrase.
|
|
func TestSealerWrongPassphrase(t *testing.T) {
|
|
ctx := context.Background()
|
|
pt := []byte("verify-new-before-revoke-old")
|
|
|
|
t.Run("authenticated-fail-closed", func(t *testing.T) {
|
|
for name, sealer := range authenticatedSealers() {
|
|
var sealed bytes.Buffer
|
|
if err := sealer.Seal(ctx, bytes.NewReader(pt), &sealed, newPass("right-pass")); err != nil {
|
|
t.Fatalf("%s seal: %v", name, err)
|
|
}
|
|
var opened bytes.Buffer
|
|
err := sealer.Open(ctx, &sealed, &opened, newPass("wrong-pass"))
|
|
if err == nil {
|
|
t.Errorf("%s: wrong passphrase opened without error", name)
|
|
}
|
|
if opened.Len() != 0 {
|
|
t.Errorf("%s: emitted %d bytes on wrong passphrase (want 0)", name, opened.Len())
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("openssl-no-plaintext-recovery", func(t *testing.T) {
|
|
sealer := &OpenSSL{Iter: 1000}
|
|
var sealed bytes.Buffer
|
|
if err := sealer.Seal(ctx, bytes.NewReader(pt), &sealed, newPass("right-pass")); err != nil {
|
|
t.Fatalf("seal: %v", err)
|
|
}
|
|
var opened bytes.Buffer
|
|
// Unauthenticated: an error is acceptable but not guaranteed; the invariant
|
|
// is that the original plaintext is never recovered.
|
|
_ = sealer.Open(ctx, &sealed, &opened, newPass("wrong-pass"))
|
|
if bytes.Equal(opened.Bytes(), pt) {
|
|
t.Fatal("openssl recovered plaintext under a wrong passphrase")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestSealerTamperFailsClosed proves the authenticated sealers reject a bundle
|
|
// whose ciphertext has been altered. This is the property the backup gate leans on:
|
|
// a corrupt backup must fail to open rather than restore silent garbage.
|
|
func TestSealerTamperFailsClosed(t *testing.T) {
|
|
ctx := context.Background()
|
|
pt := []byte("backup before rotate, always")
|
|
|
|
for name, sealer := range authenticatedSealers() {
|
|
t.Run(name, func(t *testing.T) {
|
|
var sealed bytes.Buffer
|
|
if err := sealer.Seal(ctx, bytes.NewReader(pt), &sealed, newPass("pw")); err != nil {
|
|
t.Fatalf("seal: %v", err)
|
|
}
|
|
b := sealed.Bytes()
|
|
if len(b) == 0 {
|
|
t.Fatal("empty sealed output")
|
|
}
|
|
// Flip a byte near the end (well inside the ciphertext body for both
|
|
// the age payload and the hmac mac||ct layout).
|
|
b[len(b)-1] ^= 0x01
|
|
|
|
var opened bytes.Buffer
|
|
err := sealer.Open(ctx, bytes.NewReader(b), &opened, newPass("pw"))
|
|
if err == nil {
|
|
t.Errorf("%s: opened a tampered bundle without error", name)
|
|
}
|
|
if opened.Len() != 0 {
|
|
t.Errorf("%s: emitted %d bytes from a tampered bundle (want 0)", name, opened.Len())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHMACTamperInMACHeader proves the HMAC sealer rejects a bundle whose MAC (the
|
|
// first 32 bytes) has been altered — verified before any decryption is attempted.
|
|
func TestHMACTamperInMACHeader(t *testing.T) {
|
|
ctx := context.Background()
|
|
pt := []byte("integrity first")
|
|
sealer := &HMACSealer{Iter: 1000}
|
|
|
|
var sealed bytes.Buffer
|
|
if err := sealer.Seal(ctx, bytes.NewReader(pt), &sealed, newPass("pw")); err != nil {
|
|
t.Fatalf("seal: %v", err)
|
|
}
|
|
b := sealed.Bytes()
|
|
b[0] ^= 0xff // corrupt the MAC header
|
|
|
|
var opened bytes.Buffer
|
|
if err := sealer.Open(ctx, bytes.NewReader(b), &opened, newPass("pw")); err == nil {
|
|
t.Error("hmac: opened a bundle with a corrupt MAC without error")
|
|
}
|
|
if opened.Len() != 0 {
|
|
t.Errorf("hmac: emitted %d bytes despite MAC corruption", opened.Len())
|
|
}
|
|
}
|
|
|
|
// TestHMACShortBundle proves the HMAC sealer rejects a bundle too short to contain
|
|
// a MAC rather than panicking on the slice.
|
|
func TestHMACShortBundle(t *testing.T) {
|
|
sealer := &HMACSealer{Iter: 1000}
|
|
var opened bytes.Buffer
|
|
err := sealer.Open(context.Background(), strings.NewReader("tiny"), &opened, newPass("pw"))
|
|
if err == nil {
|
|
t.Error("hmac: accepted a bundle shorter than a MAC")
|
|
}
|
|
}
|
|
|
|
// TestSealerNames pins the wire/name identity of each sealer (used in backup
|
|
// metadata and CLI selection).
|
|
func TestSealerNames(t *testing.T) {
|
|
for want, s := range map[string]Sealer{"age": &Age{}, "hmac": &HMACSealer{}, "openssl": &OpenSSL{}} {
|
|
if got := s.Name(); got != want {
|
|
t.Errorf("Name() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSealerDefaultConfig exercises the zero-value default paths of the openssl-based
|
|
// sealers (default bin "openssl", default iter) with a real round-trip. Age's default
|
|
// work factor (18, ~256 MiB scrypt) is intentionally not exercised here for speed.
|
|
func TestSealerDefaultConfig(t *testing.T) {
|
|
ctx := context.Background()
|
|
pt := []byte("default-config round trip")
|
|
for name, s := range map[string]Sealer{"openssl": &OpenSSL{}, "hmac": &HMACSealer{}} {
|
|
t.Run(name, func(t *testing.T) {
|
|
var sealed, opened bytes.Buffer
|
|
if err := s.Seal(ctx, bytes.NewReader(pt), &sealed, newPass("pw")); err != nil {
|
|
t.Fatalf("seal: %v", err)
|
|
}
|
|
if err := s.Open(ctx, &sealed, &opened, newPass("pw")); err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
if !bytes.Equal(opened.Bytes(), pt) {
|
|
t.Fatalf("round-trip mismatch")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestOpenSSLBadBinary proves a failure to launch the openssl subprocess surfaces
|
|
// as an error (the backup gate must not treat a failed seal as success).
|
|
func TestOpenSSLBadBinary(t *testing.T) {
|
|
s := &OpenSSL{Bin: "/nonexistent/openssl", Iter: 1000}
|
|
var out bytes.Buffer
|
|
if err := s.Seal(context.Background(), strings.NewReader("x"), &out, newPass("pw")); err == nil {
|
|
t.Fatal("seal with a missing openssl binary returned no error")
|
|
}
|
|
}
|
|
|
|
// TestAgeEmptyPassphrase proves age rejects an empty passphrase at seal time rather
|
|
// than producing an unopenable or weakly-keyed bundle.
|
|
func TestAgeEmptyPassphrase(t *testing.T) {
|
|
s := &Age{WorkFactor: 10}
|
|
var out bytes.Buffer
|
|
if err := s.Seal(context.Background(), strings.NewReader("x"), &out, newPass("")); err == nil {
|
|
t.Fatal("age sealed with an empty passphrase")
|
|
}
|
|
}
|
|
|
|
// TestOpenSSLTamperUnauthenticated documents — as an executable contract — that the
|
|
// openssl sealer is UNAUTHENTICATED: tampering is NOT guaranteed to be detected. The
|
|
// only invariant is that the corrupted bundle does not reproduce the plaintext. This
|
|
// is why age is the default and openssl carries a loud warning in its doc comment.
|
|
func TestOpenSSLTamperUnauthenticated(t *testing.T) {
|
|
ctx := context.Background()
|
|
pt := []byte("prefer the age sealer for anything you care about")
|
|
sealer := &OpenSSL{Iter: 1000}
|
|
|
|
var sealed bytes.Buffer
|
|
if err := sealer.Seal(ctx, bytes.NewReader(pt), &sealed, newPass("pw")); err != nil {
|
|
t.Fatalf("seal: %v", err)
|
|
}
|
|
b := sealed.Bytes()
|
|
b[len(b)-1] ^= 0x01
|
|
|
|
var opened bytes.Buffer
|
|
_ = sealer.Open(ctx, bytes.NewReader(b), &opened, newPass("pw"))
|
|
// We assert only the weak property. If this ever starts failing because the
|
|
// output equals pt, the sealer would be leaking exact plaintext from a tampered
|
|
// bundle, which would be a genuine regression.
|
|
if bytes.Equal(opened.Bytes(), pt) {
|
|
t.Fatal("openssl reproduced exact plaintext from a tampered bundle")
|
|
}
|
|
}
|