sink: sealer + framing test coverage 30% -> 85% (M1)
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>
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
package sink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"incredigo/internal/discover"
|
||||
"incredigo/internal/vault"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
)
|
||||
|
||||
// frameRecord builds a single well-formed bundle record: path then one secret chunk
|
||||
// then the secret end marker. No trailing stream sentinel (callers add it).
|
||||
func frameRecord(path, secret string) []byte {
|
||||
var b bytes.Buffer
|
||||
binary.Write(&b, binary.BigEndian, uint16(len(path)))
|
||||
b.WriteString(path)
|
||||
binary.Write(&b, binary.BigEndian, uint32(len(secret)))
|
||||
b.WriteString(secret)
|
||||
binary.Write(&b, binary.BigEndian, uint32(0)) // secret end
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func streamSentinel() []byte {
|
||||
var b bytes.Buffer
|
||||
binary.Write(&b, binary.BigEndian, uint16(0))
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// TestCountBundleRecordsZeroRecord proves an empty bundle (just the sentinel) counts
|
||||
// as zero records without error.
|
||||
func TestCountBundleRecordsZeroRecord(t *testing.T) {
|
||||
n, err := CountBundleRecords(bytes.NewReader(streamSentinel()))
|
||||
if err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("zero-record bundle counted %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCountBundleRecordsCleanEOF proves a stream that ends after complete records
|
||||
// (no explicit sentinel) still counts correctly — ImportFrom tolerates this, so the
|
||||
// counter must agree.
|
||||
func TestCountBundleRecordsCleanEOF(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
b.Write(frameRecord("imported/aws/default", "AKIA-secret"))
|
||||
b.Write(frameRecord("imported/env/api", "token-value"))
|
||||
// no sentinel — clean EOF
|
||||
n, err := CountBundleRecords(bytes.NewReader(b.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("counted %d records, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCountBundleRecordsTruncated proves a stream truncated mid-record is reported
|
||||
// as an error, not silently under-counted — the backup gate must not accept a short
|
||||
// backup as complete.
|
||||
func TestCountBundleRecordsTruncated(t *testing.T) {
|
||||
full := frameRecord("imported/aws/default", "AKIA-secret-value")
|
||||
cases := map[string]int{
|
||||
"mid-path": 3, // inside the path bytes
|
||||
"mid-chunk-len": 2 + 20 + 2, // partway through the chunk length prefix
|
||||
"mid-secret": 2 + 20 + 4 + 3, // inside the secret chunk body
|
||||
}
|
||||
for name, cut := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if cut >= len(full) {
|
||||
t.Skipf("cut %d beyond record length %d", cut, len(full))
|
||||
}
|
||||
_, err := CountBundleRecords(bytes.NewReader(full[:cut]))
|
||||
if err == nil {
|
||||
t.Fatal("truncated bundle counted without error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWritePathOversized proves a path longer than the uint16 frame field is
|
||||
// rejected rather than silently wrapping to a short length (which would corrupt the
|
||||
// stream and hide entries from the backup).
|
||||
func TestWritePathOversized(t *testing.T) {
|
||||
var w bytes.Buffer
|
||||
long := strings.Repeat("a", 0x10000) // 65536 > 0xffff
|
||||
if err := writePath(&w, long); err == nil {
|
||||
t.Fatal("oversized path accepted")
|
||||
}
|
||||
if w.Len() != 0 {
|
||||
t.Fatalf("oversized path wrote %d bytes before failing", w.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportImportSecretNeverInArgv proves Hard Rule #3 at the wrapper boundary: a
|
||||
// secret streamed through Insert/Import never appears in the child process argv. The
|
||||
// fake gopass records its full argv; we assert the secret is absent from it.
|
||||
func TestExportImportSecretNeverInArgv(t *testing.T) {
|
||||
secret := "s3cr3t-must-not-appear-in-argv"
|
||||
argvLog := filepath.Join(t.TempDir(), "argv.log")
|
||||
bin := writeArgvLoggingGopass(t, argvLog)
|
||||
gp := &Gopass{Bin: bin}
|
||||
ctx := context.Background()
|
||||
|
||||
store := t.TempDir()
|
||||
t.Setenv("FAKE_GOPASS_STORE", store)
|
||||
|
||||
// Build a one-record bundle and import it (spawns `gopass insert`).
|
||||
var bundle bytes.Buffer
|
||||
bundle.Write(frameRecord("imported/env/api", secret))
|
||||
bundle.Write(streamSentinel())
|
||||
if _, err := gp.ImportFrom(ctx, &bundle, true); err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
|
||||
logged, err := os.ReadFile(argvLog)
|
||||
if err != nil {
|
||||
t.Fatalf("read argv log: %v", err)
|
||||
}
|
||||
if strings.Contains(string(logged), secret) {
|
||||
t.Fatalf("secret leaked into gopass argv: %s", logged)
|
||||
}
|
||||
// Sanity: the secret did land in the store (via stdin, not argv).
|
||||
got, err := os.ReadFile(filepath.Join(store, "imported/env/api"))
|
||||
if err != nil {
|
||||
t.Fatalf("stored entry missing: %v", err)
|
||||
}
|
||||
if string(got) != secret {
|
||||
t.Fatalf("stored %q, want %q", got, secret)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGopassShowAndExists exercises the Show (into vault) and Exists wrapper paths
|
||||
// against the fake binary.
|
||||
func TestGopassShowAndExists(t *testing.T) {
|
||||
bin := writeFakeGopass(t)
|
||||
gp := &Gopass{Bin: bin}
|
||||
ctx := context.Background()
|
||||
|
||||
store := t.TempDir()
|
||||
seed(t, store, map[string]string{"imported/env/api": "token-xyz\n"})
|
||||
t.Setenv("FAKE_GOPASS_STORE", store)
|
||||
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
h, err := gp.Show(ctx, v, "imported/env/api")
|
||||
if err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
buf, err := v.Open(h)
|
||||
if err != nil {
|
||||
t.Fatalf("vault open: %v", err)
|
||||
}
|
||||
if got := string(buf.Bytes()); got != "token-xyz" { // trailing newline trimmed
|
||||
t.Fatalf("show returned %q, want %q", got, "token-xyz")
|
||||
}
|
||||
|
||||
if !gp.Exists(ctx, "imported/env/api") {
|
||||
t.Error("Exists reported false for a present entry")
|
||||
}
|
||||
if gp.Exists(ctx, "imported/nope") {
|
||||
t.Error("Exists reported true for an absent entry")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGopassInsert proves the rotation write-back paths (Insert and InsertAt) stream
|
||||
// a vaulted secret into gopass, that Insert forgets the handle afterwards, and that
|
||||
// InsertAt writes to an exact path without forgetting.
|
||||
func TestGopassInsert(t *testing.T) {
|
||||
bin := writeFakeGopass(t)
|
||||
gp := &Gopass{Bin: bin}
|
||||
ctx := context.Background()
|
||||
|
||||
store := t.TempDir()
|
||||
t.Setenv("FAKE_GOPASS_STORE", store)
|
||||
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
|
||||
// Insert derives the path from Source+Identity via StorePath.
|
||||
c := discover.Credential{Source: "env", Identity: "API_TOKEN", Secret: v.Store([]byte("inserted-secret"))}
|
||||
if err := gp.Insert(ctx, v, c, true); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
want := filepath.Join(store, gp.StorePath(c))
|
||||
got, err := os.ReadFile(want)
|
||||
if err != nil {
|
||||
t.Fatalf("Insert did not write %s: %v", gp.StorePath(c), err)
|
||||
}
|
||||
if string(got) != "inserted-secret" {
|
||||
t.Fatalf("Insert stored %q, want %q", got, "inserted-secret")
|
||||
}
|
||||
// Insert must forget the handle (secret is now in gopass).
|
||||
if _, err := v.Open(c.Secret); err == nil {
|
||||
t.Error("Insert did not forget the handle")
|
||||
}
|
||||
|
||||
// InsertAt writes a fresh handle to an exact path and keeps it usable.
|
||||
h := v.Store([]byte("rotated-value"))
|
||||
if err := gp.InsertAt(ctx, v, "imported/env/rotated", h, true); err != nil {
|
||||
t.Fatalf("insertAt: %v", err)
|
||||
}
|
||||
got, err = os.ReadFile(filepath.Join(store, "imported/env/rotated"))
|
||||
if err != nil {
|
||||
t.Fatalf("InsertAt did not write: %v", err)
|
||||
}
|
||||
if string(got) != "rotated-value" {
|
||||
t.Fatalf("InsertAt stored %q, want %q", got, "rotated-value")
|
||||
}
|
||||
if _, err := v.Open(h); err != nil {
|
||||
t.Error("InsertAt should not forget the handle (caller may re-read)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGopassAvailable proves Available reflects whether the binary resolves on PATH.
|
||||
func TestGopassAvailable(t *testing.T) {
|
||||
if (&Gopass{Bin: writeFakeGopass(t)}).Available() != true {
|
||||
t.Error("Available false for an existing binary")
|
||||
}
|
||||
if (&Gopass{Bin: "definitely-not-a-real-binary-xyz"}).Available() != false {
|
||||
t.Error("Available true for a missing binary")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportSealOpenPipeline proves the whole backup path the rotation gate depends
|
||||
// on: export a store → seal it (age) → open it → the record count and secrets match.
|
||||
// This is the completeness+authenticity contract of a verified backup end-to-end.
|
||||
func TestExportSealOpenPipeline(t *testing.T) {
|
||||
bin := writeFakeGopass(t)
|
||||
gp := &Gopass{Bin: bin}
|
||||
ctx := context.Background()
|
||||
|
||||
src := t.TempDir()
|
||||
entries := map[string]string{
|
||||
"imported/aws/default": "AKIA/secret+with=specials",
|
||||
"imported/env/api": "unicode-末-\U0001f511",
|
||||
}
|
||||
seed(t, src, entries)
|
||||
t.Setenv("FAKE_GOPASS_STORE", src)
|
||||
|
||||
// export → plaintext bundle
|
||||
var plain bytes.Buffer
|
||||
n, err := gp.ExportTo(ctx, &plain, "imported/")
|
||||
if err != nil {
|
||||
t.Fatalf("export: %v", err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("exported %d, want 2", n)
|
||||
}
|
||||
|
||||
// seal → open
|
||||
sealer := &Age{WorkFactor: 10}
|
||||
pass := memguard.NewBufferFromBytes([]byte("backup-pass"))
|
||||
var sealed bytes.Buffer
|
||||
if err := sealer.Seal(ctx, bytes.NewReader(plain.Bytes()), &sealed, pass); err != nil {
|
||||
t.Fatalf("seal: %v", err)
|
||||
}
|
||||
var opened bytes.Buffer
|
||||
if err := sealer.Open(ctx, &sealed, &opened, memguard.NewBufferFromBytes([]byte("backup-pass"))); err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
|
||||
// completeness: count matches
|
||||
cnt, err := CountBundleRecords(bytes.NewReader(opened.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if cnt != 2 {
|
||||
t.Fatalf("opened bundle has %d records, want 2", cnt)
|
||||
}
|
||||
|
||||
// authenticity: import into a fresh store restores the exact secrets
|
||||
dst := t.TempDir()
|
||||
t.Setenv("FAKE_GOPASS_STORE", dst)
|
||||
if _, err := gp.ImportFrom(ctx, bytes.NewReader(opened.Bytes()), true); err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
for path, exp := range entries {
|
||||
got, err := os.ReadFile(filepath.Join(dst, path))
|
||||
if err != nil {
|
||||
t.Fatalf("restored %s: %v", path, err)
|
||||
}
|
||||
if string(got) != exp {
|
||||
t.Errorf("%s: restored %q want %q", path, got, exp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportFailsOnGopassShowError proves ExportTo surfaces a `gopass show` failure
|
||||
// as an error rather than silently emitting a short (incomplete) backup — the backup
|
||||
// gate must never treat a partial export as a complete one.
|
||||
func TestExportFailsOnGopassShowError(t *testing.T) {
|
||||
bin := filepath.Join(t.TempDir(), "gopass")
|
||||
// ls lists one path; show always fails.
|
||||
script := `#!/usr/bin/env bash
|
||||
case "$1" in
|
||||
ls) echo "imported/env/api" ;;
|
||||
show) echo "boom" >&2; exit 1 ;;
|
||||
esac
|
||||
`
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gp := &Gopass{Bin: bin}
|
||||
var buf bytes.Buffer
|
||||
if _, err := gp.ExportTo(context.Background(), &buf, "imported/"); err == nil {
|
||||
t.Fatal("ExportTo ignored a gopass show failure")
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportFailsOnInsertError proves ImportFrom surfaces a `gopass insert` failure
|
||||
// rather than silently dropping an entry on restore.
|
||||
func TestImportFailsOnInsertError(t *testing.T) {
|
||||
bin := filepath.Join(t.TempDir(), "gopass")
|
||||
script := `#!/usr/bin/env bash
|
||||
case "$1" in
|
||||
insert) cat >/dev/null; echo "denied" >&2; exit 1 ;;
|
||||
esac
|
||||
`
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gp := &Gopass{Bin: bin}
|
||||
|
||||
var bundle bytes.Buffer
|
||||
bundle.Write(frameRecord("imported/env/api", "value"))
|
||||
bundle.Write(streamSentinel())
|
||||
if _, err := gp.ImportFrom(context.Background(), &bundle, true); err == nil {
|
||||
t.Fatal("ImportFrom ignored a gopass insert failure")
|
||||
}
|
||||
}
|
||||
|
||||
// writeArgvLoggingGopass is like writeFakeGopass but appends the full argv of every
|
||||
// invocation to logPath, so a test can assert the secret never rode in argv.
|
||||
func writeArgvLoggingGopass(t *testing.T, logPath string) string {
|
||||
t.Helper()
|
||||
bin := filepath.Join(t.TempDir(), "gopass")
|
||||
script := `#!/usr/bin/env bash
|
||||
echo "$@" >> "` + logPath + `"
|
||||
store="$FAKE_GOPASS_STORE"
|
||||
case "$1" in
|
||||
ls) (cd "$store" 2>/dev/null && find . -type f | sed 's#^\./##' | sort) ;;
|
||||
show) cat "$store/$3" ;;
|
||||
insert)
|
||||
shift; path=""
|
||||
for a in "$@"; do case "$a" in --multiline=false|-f) ;; *) path="$a";; esac; done
|
||||
mkdir -p "$store/$(dirname "$path")"; cat > "$store/$path" ;;
|
||||
esac
|
||||
`
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return bin
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user