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 was merged in pull request #170.
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"
|
||||
)
|
||||
@@ -208,23 +214,25 @@ func TestImportSecretVariousSizes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddSecretBufferGrowth tests that our buffer growth strategy works correctly
|
||||
// TestAddSecretAroundPageSize tests that secret add, which reads its input
|
||||
// one memory page (usually 4 KiB) at a time, stores values just under, at and
|
||||
// just over one and two pages unchanged.
|
||||
//
|
||||
//nolint:paralleltest // in parallel, size tests could exceed the memlock limit
|
||||
func TestAddSecretBufferGrowth(t *testing.T) {
|
||||
// Test various sizes that should trigger buffer growth
|
||||
func TestAddSecretAroundPageSize(t *testing.T) {
|
||||
// Sizes around one and two pages of 4 KiB, then larger ones up to 1 MiB
|
||||
sizes := []int{
|
||||
1, // Single byte
|
||||
100, // Small
|
||||
4095, // Just under initial 4KB
|
||||
4095, // Just under 4KB
|
||||
4096, // Exactly 4KB
|
||||
4097, // Just over 4KB
|
||||
8191, // Just under 8KB (first double)
|
||||
8191, // Just under 8KB
|
||||
8192, // Exactly 8KB
|
||||
8193, // Just over 8KB
|
||||
12288, // 12KB (should trigger second double)
|
||||
12288, // 12KB
|
||||
16384, // 16KB
|
||||
32768, // 32KB (after more doublings)
|
||||
32768, // 32KB
|
||||
65536, // 64KB
|
||||
131072, // 128KB
|
||||
524288, // 512KB
|
||||
@@ -315,6 +323,144 @@ 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 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))
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// 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