check / check (push) Waiting to run
check, fetch, gen and freshen took --require-signature or --sign-key given an empty value, on the command line or through MFER_REQUIRE_SIGNATURE or MFER_SIGN_KEY, as the flag not given, so an unset shell variable skipped the signature check or the signing. Each now asks whether the flag was given, as gen's --output does. An empty fingerprint is refused as one of the wrong length, by fetch before it requests anything; an empty --sign-key is refused, naming the flag, before any file is read. Model: opus-5-5
316 lines
9.9 KiB
Go
316 lines
9.9 KiB
Go
//nolint:testpackage // white-box tests exercise unexported internals
|
|
package cli
|
|
|
|
import (
|
|
"bufio"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/ProtonMail/go-crypto/openpgp/packet"
|
|
"github.com/creack/pty"
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const (
|
|
testFlagSignKey = "--sign-key"
|
|
testKeyFile = "/key.asc"
|
|
)
|
|
|
|
// TestGenAndFreshenSignWithKeyFile runs gen, then freshen after a file is
|
|
// added, with --sign-key naming a key file: one key with no passphrase and
|
|
// one protected by the passphrase in MFER_SIGN_KEY_PASSPHRASE. check
|
|
// --require-signature must accept each manifest as signed by that key.
|
|
// freshen leaves its manifest out of the listing only on the real
|
|
// filesystem, so the test uses that.
|
|
func TestGenAndFreshenSignWithKeyFile(t *testing.T) {
|
|
for name, passphrase := range map[string][]byte{
|
|
"unprotected": nil,
|
|
"protected": []byte("passphrase"),
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Setenv(envSignKeyPassphrase, string(passphrase))
|
|
|
|
secretKey, fingerprint := testSecretKey(t, passphrase, nil)
|
|
|
|
fs := afero.NewOsFs()
|
|
keyFile := filepath.Join(t.TempDir(), "key.asc")
|
|
root := t.TempDir()
|
|
manifestPath := filepath.Join(root, defaultManifestName)
|
|
|
|
require.NoError(t, afero.WriteFile(fs, keyFile, secretKey, 0o600))
|
|
writeTestFile(t, fs, filepath.Join(root, testFileTxt), "hello")
|
|
|
|
opts := testOpts([]string{
|
|
testApp, cmdGenerate, "-q", testFlagSignKey, keyFile,
|
|
"-o", manifestPath, root,
|
|
}, fs)
|
|
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
|
|
|
check := []string{
|
|
testApp, cmdCheck, "-q",
|
|
"--" + flagRequireSignature, fingerprint, manifestPath,
|
|
}
|
|
|
|
opts = testOpts(check, fs)
|
|
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
|
|
|
writeTestFile(t, fs, filepath.Join(root, "added.txt"), "added")
|
|
|
|
opts = testOpts([]string{
|
|
testApp, cmdFreshen, "-q", testFlagSignKey, keyFile, manifestPath,
|
|
}, fs)
|
|
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
|
|
|
opts = testOpts(check, fs)
|
|
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
|
|
assert.Len(t, manifestFiles(t, fs, manifestPath), 2)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSignWithProtectedKeyNeedsPassphrase runs gen with a protected key,
|
|
// with MFER_SIGN_KEY_PASSPHRASE empty and no terminal to ask on. gen must
|
|
// fail, naming the variable, and write no manifest.
|
|
func TestSignWithProtectedKeyNeedsPassphrase(t *testing.T) {
|
|
t.Setenv(envSignKeyPassphrase, "")
|
|
|
|
secretKey, _ := testSecretKey(t, []byte("secret"), nil)
|
|
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, afero.WriteFile(fs, testKeyFile, secretKey, 0o600))
|
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
|
writeTestFile(t, fs, testFile1, "hello")
|
|
|
|
opts := testOpts([]string{
|
|
testApp, cmdGenerate, "-q", testFlagSignKey, testKeyFile,
|
|
"-o", testMF, testDir,
|
|
}, fs)
|
|
assert.Equal(t, 1, runCLI(opts))
|
|
assert.Contains(t, testStderr(t, opts),
|
|
"signing key is protected: set MFER_SIGN_KEY_PASSPHRASE to its passphrase")
|
|
|
|
exists, err := afero.Exists(fs, testMF)
|
|
require.NoError(t, err)
|
|
assert.False(t, exists)
|
|
}
|
|
|
|
// TestSignWithKeyThatCannotSignFailsFirst runs gen on a directory and
|
|
// freshen on a manifest, neither of which exists, with keys that cannot
|
|
// sign: a protected key with a wrong MFER_SIGN_KEY_PASSPHRASE, a key that
|
|
// expired in 2020, and a version 6 key. Each run must fail on the key: it
|
|
// checks the key before it reads any file, so a missing file goes
|
|
// unnoticed.
|
|
func TestSignWithKeyThatCannotSignFailsFirst(t *testing.T) {
|
|
t.Setenv(envSignKeyPassphrase, "wrong")
|
|
|
|
wrongPassphrase, _ := testSecretKey(t, []byte("right"), nil)
|
|
|
|
made := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
expired, _ := testSecretKey(t, nil, &packet.Config{
|
|
Algorithm: packet.PubKeyAlgoEdDSA,
|
|
Time: func() time.Time { return made },
|
|
KeyLifetimeSecs: uint32((24 * time.Hour).Seconds()),
|
|
})
|
|
|
|
version6, _ := testSecretKey(t, nil, &packet.Config{
|
|
Algorithm: packet.PubKeyAlgoEd25519,
|
|
V6Keys: true,
|
|
})
|
|
|
|
for want, secretKey := range map[string][]byte{
|
|
"unlock signing key": wrongPassphrase,
|
|
"signing key cannot sign": expired,
|
|
"signing key must be an OpenPGP version 4 key": version6,
|
|
} {
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, afero.WriteFile(fs, testKeyFile, secretKey, 0o600))
|
|
|
|
for _, args := range [][]string{
|
|
{
|
|
testApp, cmdGenerate, "-q", testFlagSignKey, testKeyFile,
|
|
"-o", testMF, "/missing",
|
|
},
|
|
{testApp, cmdFreshen, "-q", testFlagSignKey, testKeyFile, "/missing.mf"},
|
|
} {
|
|
opts := testOpts(args, fs)
|
|
assert.Equal(t, 1, runCLI(opts), args[1], want)
|
|
assert.Contains(t, testStderr(t, opts), testKeyFile+": "+want, args[1])
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGenAsksForPassphraseOnTerminal runs gen with a protected key, no
|
|
// MFER_SIGN_KEY_PASSPHRASE, and a terminal as stdin and stderr. gen must
|
|
// ask for the passphrase on stderr, and sign with what is typed after the
|
|
// prompt.
|
|
func TestGenAsksForPassphraseOnTerminal(t *testing.T) {
|
|
t.Setenv(envSignKeyPassphrase, "")
|
|
|
|
secretKey, fingerprint := testSecretKey(t, []byte("passphrase"), nil)
|
|
|
|
fs := afero.NewOsFs()
|
|
keyFile := filepath.Join(t.TempDir(), "key.asc")
|
|
root := t.TempDir()
|
|
manifestPath := filepath.Join(root, defaultManifestName)
|
|
|
|
require.NoError(t, afero.WriteFile(fs, keyFile, secretKey, 0o600))
|
|
writeTestFile(t, fs, filepath.Join(root, testFileTxt), "hello")
|
|
|
|
terminal, tty, err := pty.Open()
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() { _ = terminal.Close() })
|
|
|
|
opts := testOpts([]string{
|
|
testApp, cmdGenerate, "-q", testFlagSignKey, keyFile,
|
|
"-o", manifestPath, root,
|
|
}, fs)
|
|
opts.Stdin = tty
|
|
opts.Stderr = tty
|
|
|
|
exitCode := make(chan int, 1)
|
|
|
|
go func() {
|
|
exitCode <- runCLI(opts)
|
|
|
|
// Once gen has ended, reading the terminal fails instead of
|
|
// waiting for a prompt that will not come.
|
|
_ = tty.Close()
|
|
}()
|
|
|
|
prompt := "Passphrase for " + keyFile + ": "
|
|
output := bufio.NewReader(terminal)
|
|
written := ""
|
|
|
|
for !strings.HasSuffix(written, prompt) {
|
|
b, err := output.ReadByte()
|
|
require.NoError(t, err, "gen wrote %q and no prompt", written)
|
|
|
|
written += string(b)
|
|
}
|
|
|
|
_, err = terminal.WriteString("passphrase\n")
|
|
require.NoError(t, err)
|
|
|
|
code := <-exitCode
|
|
rest, _ := io.ReadAll(output)
|
|
require.Equal(t, 0, code, "gen wrote %q", rest)
|
|
|
|
check := testOpts([]string{
|
|
testApp, cmdCheck, "-q",
|
|
"--" + flagRequireSignature, fingerprint, manifestPath,
|
|
}, fs)
|
|
require.Equal(t, 0, runCLI(check), testStderr(t, check))
|
|
}
|
|
|
|
// assertEmptySignKeyRefused runs gen and freshen, each with flags, on a
|
|
// directory holding a file and an unsigned manifest. Each must fail,
|
|
// naming --sign-key, and leave the manifest as it was.
|
|
func assertEmptySignKeyRefused(t *testing.T, flags ...string) {
|
|
t.Helper()
|
|
|
|
manifest := manifestOf(t, map[string][]byte{testFileTxt: []byte("listed")})
|
|
|
|
for command, rest := range map[string][]string{
|
|
cmdGenerate: {"-q", "-f", "-o", testMF, testDir},
|
|
cmdFreshen: {"-q", testMF},
|
|
} {
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
|
writeTestFile(t, fs, testFile1, "hello")
|
|
require.NoError(t, afero.WriteFile(fs, testMF, manifest, 0o644))
|
|
|
|
opts := testOpts(slices.Concat([]string{testApp, command}, flags, rest), fs)
|
|
assert.Equal(t, 1, runCLI(opts), command)
|
|
assert.Contains(t, testStderr(t, opts), errEmptySignKey.Error(), command)
|
|
|
|
content, err := afero.ReadFile(fs, testMF)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, manifest, content, command)
|
|
}
|
|
}
|
|
|
|
// TestGenAndFreshenRefuseEmptySignKey runs gen and freshen with --sign-key
|
|
// given an empty value, as an unset shell variable gives it.
|
|
func TestGenAndFreshenRefuseEmptySignKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertEmptySignKeyRefused(t, testFlagSignKey, "")
|
|
}
|
|
|
|
// TestGenAndFreshenRefuseEmptySignKeyFromEnvironment runs gen and freshen
|
|
// with MFER_SIGN_KEY set to an empty string.
|
|
func TestGenAndFreshenRefuseEmptySignKeyFromEnvironment(t *testing.T) {
|
|
t.Setenv("MFER_SIGN_KEY", "")
|
|
|
|
assertEmptySignKeyRefused(t)
|
|
}
|
|
|
|
// assertEmptyFingerprintRefused runs check on an unsigned manifest, and
|
|
// fetch of that manifest from a server, each with flags. Each must refuse
|
|
// the empty fingerprint as malformed, and fetch must request nothing.
|
|
func assertEmptyFingerprintRefused(t *testing.T, flags ...string) {
|
|
t.Helper()
|
|
|
|
const want = "invalid fingerprint: must be exactly 40 hex characters, got 0"
|
|
|
|
files := map[string][]byte{testFileTxt: []byte("listed")}
|
|
manifest := manifestOf(t, files)
|
|
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
|
writeTestFile(t, fs, filepath.Join(testDir, testFileTxt), "listed")
|
|
require.NoError(t, afero.WriteFile(fs, testMF, manifest, 0o644))
|
|
|
|
opts := testOpts(slices.Concat(
|
|
[]string{testApp, cmdCheck, "-q"}, flags, []string{testMF},
|
|
), fs)
|
|
assert.Equal(t, 1, runCLI(opts), cmdCheck)
|
|
assert.Contains(t, testStderr(t, opts), want, cmdCheck)
|
|
|
|
var requests atomic.Int32
|
|
|
|
tree := fetchTestHandler(manifest, files)
|
|
|
|
server := httptest.NewServer(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requests.Add(1)
|
|
tree.ServeHTTP(w, r)
|
|
}))
|
|
defer server.Close()
|
|
|
|
opts = testOpts(slices.Concat(
|
|
[]string{testApp, cmdFetch, "-q", "--" + flagDest, t.TempDir()},
|
|
flags, []string{server.URL},
|
|
), afero.NewOsFs())
|
|
assert.Equal(t, 1, runCLI(opts), cmdFetch)
|
|
assert.Contains(t, testStderr(t, opts), want, cmdFetch)
|
|
assert.Zero(t, requests.Load(), "fetch made a request")
|
|
}
|
|
|
|
// TestCheckAndFetchRefuseEmptyRequireSignature runs check and fetch with
|
|
// --require-signature given an empty value, as an unset shell variable
|
|
// gives it.
|
|
func TestCheckAndFetchRefuseEmptyRequireSignature(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertEmptyFingerprintRefused(t, "--"+flagRequireSignature, "")
|
|
}
|
|
|
|
// TestCheckAndFetchRefuseEmptyRequireSignatureFromEnvironment runs check
|
|
// and fetch with MFER_REQUIRE_SIGNATURE set to an empty string.
|
|
func TestCheckAndFetchRefuseEmptyRequireSignatureFromEnvironment(t *testing.T) {
|
|
t.Setenv("MFER_REQUIRE_SIGNATURE", "")
|
|
|
|
assertEmptyFingerprintRefused(t)
|
|
}
|