A secret too large for the locked-memory limit fails with an error, not a panic (closes #145)
check / check (push) Waiting to run
check / check (push) Waiting to run
secret add, import and generate secret crashed with memguard's panic once a value outgrew the locked-memory limit, and because the value was held twice, half the usual 8 MiB limit was enough. secret now raises the limit to the hard limit at startup. Each of the three commands refuses, before its first locked allocation, a limit that leaves fewer than 8 pages for its keys and buffers. The value is read a page at a time into a memguard.Stream, which keeps it encrypted, and copied once into locked memory after its size is checked against 100 MiB and the limit. It leaves the stream through Next, not Read: Read re-encrypts a leftover piece, and memguard hangs if that runs out of locked memory. Model: opus-5-5
This commit is contained in:
@@ -3,17 +3,23 @@ package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
"sneak.berlin/go/secret/pkg/agehd"
|
||||
)
|
||||
@@ -315,6 +321,159 @@ func TestAddSecretStreamingBehavior(t *testing.T) {
|
||||
"Retrieved secret should match original")
|
||||
}
|
||||
|
||||
// newBinaryTestStateDir returns a new state directory holding a vault made
|
||||
// from the test mnemonic, for the secret binary to use.
|
||||
func newBinaryTestStateDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
stateDir := t.TempDir()
|
||||
_, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default",
|
||||
testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
return stateDir
|
||||
}
|
||||
|
||||
// runAtLockedMemoryLimit runs the secret binary with args, stdin, the state
|
||||
// directory stateDir and the test mnemonic, its locked-memory limit (ulimit
|
||||
// -l) lowered to limitKiB, and returns what it printed.
|
||||
func runAtLockedMemoryLimit(
|
||||
t *testing.T, stateDir string, limitKiB int, stdin io.Reader,
|
||||
args ...string,
|
||||
) (string, error) {
|
||||
t.Helper()
|
||||
|
||||
const commandWait = 10 * time.Second
|
||||
|
||||
secretPath, err := filepath.Abs("../../secret")
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
|
||||
defer cancel()
|
||||
|
||||
// The shell lowers the limit for itself and the binary only.
|
||||
script := fmt.Sprintf(`ulimit -l %d && exec "$0" "$@"`, limitKiB)
|
||||
|
||||
//nolint:gosec // G204: runs the freshly built secret binary
|
||||
cmd := exec.CommandContext(ctx, "sh",
|
||||
append([]string{"-c", script, secretPath}, args...)...)
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + stateDir,
|
||||
secret.EnvMnemonic + "=" + testMnemonic,
|
||||
}
|
||||
cmd.Stdin = stdin
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
// requireRefusedAtLockedMemoryLimit runs the secret binary as
|
||||
// runAtLockedMemoryLimit does, and requires it to exit with status 1 and
|
||||
// print wantError, with no panic.
|
||||
func requireRefusedAtLockedMemoryLimit(
|
||||
t *testing.T, stateDir string, limitKiB int, wantError string,
|
||||
stdin io.Reader, args ...string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
output, err := runAtLockedMemoryLimit(t, stateDir, limitKiB, stdin, args...)
|
||||
|
||||
var exitErr *exec.ExitError
|
||||
|
||||
require.ErrorAs(t, err, &exitErr, "%v: %s", args, output)
|
||||
assert.Equal(t, 1, exitErr.ExitCode(), "%v: %s", args, output)
|
||||
assert.Contains(t, output, wantError, args)
|
||||
assert.NotContains(t, output, "panic", args)
|
||||
}
|
||||
|
||||
// TestSecretsAtLockedMemoryLimit runs the secret binary with the
|
||||
// locked-memory limit (ulimit -l) lowered to 1 MiB, where memguard would crash
|
||||
// the process with a panic instead of returning an error. add must store the
|
||||
// largest value that fits, which takes holding it in locked memory only once.
|
||||
// add, import and generate secret must refuse a value one byte larger, and one
|
||||
// over 100 MB, with exit status 1 and an error saying why.
|
||||
func TestSecretsAtLockedMemoryLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const limitKiB = 1024
|
||||
|
||||
fits := limitKiB*kibibyte - lockedMemoryReservePages*os.Getpagesize()
|
||||
limitError := fmt.Sprintf("locked-memory limit of %d KiB", limitKiB)
|
||||
stateDir := newBinaryTestStateDir(t)
|
||||
|
||||
tooBigFile := filepath.Join(t.TempDir(), "too-big")
|
||||
require.NoError(t, os.WriteFile(tooBigFile, make([]byte, fits+1), 0o600))
|
||||
|
||||
// Sparse, so that it takes no room on disk
|
||||
overMaxSize, err := os.Create(filepath.Join(t.TempDir(), "over-max-size"))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = overMaxSize.Close() })
|
||||
require.NoError(t, overMaxSize.Truncate(maxSecretSize+1))
|
||||
|
||||
output, err := runAtLockedMemoryLimit(t, stateDir, limitKiB,
|
||||
bytes.NewReader(make([]byte, fits)), "add", "fits")
|
||||
require.NoError(t, err, "the largest value that fits: %s", output)
|
||||
|
||||
requireRefusedAtLockedMemoryLimit(t, stateDir, limitKiB, limitError,
|
||||
bytes.NewReader(make([]byte, fits+1)), "add", "too-big")
|
||||
requireRefusedAtLockedMemoryLimit(t, stateDir, limitKiB, limitError,
|
||||
nil, "import", "too-big", "--source", tooBigFile)
|
||||
requireRefusedAtLockedMemoryLimit(t, stateDir, limitKiB, limitError,
|
||||
nil, "generate", "secret", "too-big", "--length", strconv.Itoa(fits+1))
|
||||
|
||||
requireRefusedAtLockedMemoryLimit(t, stateDir, limitKiB,
|
||||
errSecretTooLarge.Error(), overMaxSize, "add", "too-big")
|
||||
requireRefusedAtLockedMemoryLimit(t, stateDir, limitKiB,
|
||||
errSecretTooLarge.Error(), nil,
|
||||
"import", "too-big", "--source", overMaxSize.Name())
|
||||
requireRefusedAtLockedMemoryLimit(t, stateDir, limitKiB,
|
||||
errSecretTooLarge.Error(), nil,
|
||||
"generate", "secret", "too-big", "--length", strconv.Itoa(maxSecretSize+1))
|
||||
}
|
||||
|
||||
// TestSecretsUnderSmallLockedMemoryLimits runs add, import and generate
|
||||
// secret with a short value under two small locked-memory limits (ulimit
|
||||
// -l). Under 20 KiB, too little for the buffers they need besides the value,
|
||||
// each must refuse it with exit status 1 and an error naming the limit, where
|
||||
// memguard would panic or hang. Under 64 KiB, the default of older Linux
|
||||
// kernels, each must store it.
|
||||
func TestSecretsUnderSmallLockedMemoryLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
tooSmallLimitKiB = 20
|
||||
oldDefaultLimitKiB = 64
|
||||
shortValue = "hunter2"
|
||||
)
|
||||
|
||||
tooSmallLimitError := fmt.Sprintf("locked-memory limit of %d KiB",
|
||||
tooSmallLimitKiB)
|
||||
stateDir := newBinaryTestStateDir(t)
|
||||
|
||||
shortFile := filepath.Join(t.TempDir(), "short")
|
||||
require.NoError(t, os.WriteFile(shortFile, []byte(shortValue), 0o600))
|
||||
|
||||
requireRefusedAtLockedMemoryLimit(t, stateDir, tooSmallLimitKiB,
|
||||
tooSmallLimitError, strings.NewReader(shortValue), "add", "added")
|
||||
requireRefusedAtLockedMemoryLimit(t, stateDir, tooSmallLimitKiB,
|
||||
tooSmallLimitError, nil, "import", "imported", "--source", shortFile)
|
||||
requireRefusedAtLockedMemoryLimit(t, stateDir, tooSmallLimitKiB,
|
||||
tooSmallLimitError, nil, "generate", "secret", "generated")
|
||||
|
||||
output, err := runAtLockedMemoryLimit(t, stateDir, oldDefaultLimitKiB,
|
||||
strings.NewReader(shortValue), "add", "added")
|
||||
require.NoError(t, err, "add: %s", output)
|
||||
|
||||
output, err = runAtLockedMemoryLimit(t, stateDir, oldDefaultLimitKiB,
|
||||
nil, "import", "imported", "--source", shortFile)
|
||||
require.NoError(t, err, "import: %s", output)
|
||||
|
||||
output, err = runAtLockedMemoryLimit(t, stateDir, oldDefaultLimitKiB,
|
||||
nil, "generate", "secret", "generated")
|
||||
require.NoError(t, err, "generate secret: %s", output)
|
||||
}
|
||||
|
||||
// slowReader simulates a reader that returns data in small chunks
|
||||
type slowReader struct {
|
||||
data []byte
|
||||
|
||||
Reference in New Issue
Block a user