Compare commits
6
Commits
v1.0.0-rc2
...
e4a866949b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4a866949b | ||
|
|
72cd6c30a1 | ||
|
|
46c395e045 | ||
|
|
fe94e99801 | ||
|
|
0630a32167 | ||
|
|
5371382cb4 |
@@ -197,7 +197,8 @@ for confirmation, naming the version, the secret and its vault (see
|
|||||||
|
|
||||||
#### `secret generate mnemonic`
|
#### `secret generate mnemonic`
|
||||||
|
|
||||||
Generates a cryptographically secure BIP39 mnemonic phrase.
|
Generates a cryptographically secure BIP39 mnemonic phrase and prints it to
|
||||||
|
stdout. The warning to keep it safe goes to stderr.
|
||||||
|
|
||||||
#### `secret generate secret <name> [--length=16] [--type=base58] [--force]`
|
#### `secret generate secret <name> [--length=16] [--type=base58] [--force]`
|
||||||
|
|
||||||
@@ -241,6 +242,14 @@ that the vault then opens only with its mnemonic (see
|
|||||||
[Confirmation Before Removal](#confirmation-before-removal)). An unlocker
|
[Confirmation Before Removal](#confirmation-before-removal)). An unlocker
|
||||||
directory that `secret unlocker list` skips with a warning, because its metadata
|
directory that `secret unlocker list` skips with a warning, because its metadata
|
||||||
cannot be read or parsed, is removed by the directory name the warning gives.
|
cannot be read or parsed, is removed by the directory name the warning gives.
|
||||||
|
Removing the current unlocker makes the first of the vault's other unlockers
|
||||||
|
that `secret unlocker select` accepts, in the order `secret unlocker list` shows
|
||||||
|
them, the current one, and says which; when there is none, the vault is left
|
||||||
|
without a current unlocker. `secret unlocker select` refuses an unlocker whose
|
||||||
|
type is not `passphrase` or `pgp`. When `current-unlocker` names an unlocker
|
||||||
|
that does not exist and the vault has another that `secret unlocker select`
|
||||||
|
accepts, opening the vault fails with an error that says to select one with
|
||||||
|
`secret unlocker select`.
|
||||||
|
|
||||||
- `--force, -f`: Remove without asking, even the last unlocker
|
- `--force, -f`: Remove without asking, even the last unlocker
|
||||||
- **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase,
|
- **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase,
|
||||||
@@ -274,6 +283,19 @@ generates a new Age key.
|
|||||||
|
|
||||||
Decrypts data using an Age key stored as a secret.
|
Decrypts data using an Age key stored as a secret.
|
||||||
|
|
||||||
|
Both write the result to a new file next to `--output` and rename it over
|
||||||
|
`--output` only once they have succeeded, so a failed command leaves an existing
|
||||||
|
`--output` as it was and creates none, and `--input` and `--output` can be the
|
||||||
|
same file. When `--output` is a symbolic link, the file it points to is
|
||||||
|
replaced, or created if it does not exist yet, and the link kept; the new file
|
||||||
|
is then made next to that file. The directory the new file is made in must be
|
||||||
|
writable. The new file has mode 0600 and belongs to the user running the
|
||||||
|
command, whatever mode and owner the file it replaces had, so a hard link to the
|
||||||
|
old file keeps the old content. Ctrl-C, SIGTERM or a hangup removes the new
|
||||||
|
file; a command killed any other way, such as with SIGKILL, leaves it there,
|
||||||
|
named `.tmp-` and a number. A device or a pipe, such as `/dev/null`, is written
|
||||||
|
to directly.
|
||||||
|
|
||||||
## Storage Architecture
|
## Storage Architecture
|
||||||
|
|
||||||
### Directory Structure
|
### Directory Structure
|
||||||
@@ -372,6 +394,11 @@ which every command except `secret vault import` offers when the variable is not
|
|||||||
set, is the safer default; `secret vault import` has no prompt and needs both
|
set, is the safer default; `secret vault import` has no prompt and needs both
|
||||||
variables.
|
variables.
|
||||||
|
|
||||||
|
The prompt asks on the terminal the command runs in, not on stdin, so it also
|
||||||
|
asks when stdin is a pipe, as in `echo value | secret add name --force` or
|
||||||
|
`cat file | secret encrypt key`. With no controlling terminal, as under cron or
|
||||||
|
in a CI job, the command fails and names the variable to set.
|
||||||
|
|
||||||
## Security Features
|
## Security Features
|
||||||
|
|
||||||
### Encryption
|
### Encryption
|
||||||
|
|||||||
@@ -18,6 +18,65 @@ https://git.eeqj.de/sneak/secret/milestone/12
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-14: A failed `secret encrypt` or `secret decrypt` leaves the file
|
||||||
|
named by `--output` as it was, and the same file can be given as `--input` and
|
||||||
|
`--output` (https://git.eeqj.de/sneak/secret/issues/143). Both emptied the
|
||||||
|
output file before reading any input, so a wrong key left it empty, and a file
|
||||||
|
given as both was emptied before it was read. They now write the result into a
|
||||||
|
temporary file next to it, or next to the file it is a symbolic link to, and
|
||||||
|
rename that over it once they have succeeded, with the new
|
||||||
|
`secret.WriteFileAtomicFunc`, which `secret.WriteFileAtomic` now calls; the
|
||||||
|
output file has mode 0600. An output file that cannot be written still fails
|
||||||
|
the command. Ctrl-C, SIGTERM or a hangup removes the temporary file, and a
|
||||||
|
command started with `nohup` still ignores a hangup. A device or a pipe given
|
||||||
|
as `--output`, such as `/dev/null`, is still written to directly.
|
||||||
|
- 2026-10-14: A wrong passphrase, from `SB_UNLOCK_PASSPHRASE` or typed at the
|
||||||
|
prompt, gives the error `wrong passphrase for unlocker <id>`
|
||||||
|
(https://git.eeqj.de/sneak/secret/issues/157). It used to be age's "no
|
||||||
|
identity matched any of the recipients", followed by advice to give the vault
|
||||||
|
a new unlocker from its mnemonic, which would have replaced an unlocker that
|
||||||
|
works. The error now wraps `secret.ErrWrongPassphrase` and carries no advice
|
||||||
|
to use the mnemonic. A `priv.age` that is not an age file still gets that
|
||||||
|
advice.
|
||||||
|
- 2026-10-14: The passphrase and mnemonic prompts ask on the controlling
|
||||||
|
terminal (`/dev/tty`) instead of stdin, so `echo v | secret add s1 --force`,
|
||||||
|
`cat file | secret encrypt key` and `cat file.age | secret decrypt key` ask
|
||||||
|
for the passphrase at a terminal instead of failing
|
||||||
|
(https://git.eeqj.de/sneak/secret/issues/144). Only a process with no
|
||||||
|
controlling terminal gets the error that names the variable to set. On SIGINT
|
||||||
|
or SIGTERM, `Entry()` restores the settings of that terminal, not of stdin,
|
||||||
|
since that is where the prompt turns echo off. `secret init` and
|
||||||
|
`secret vault create` no longer write a newline to stderr after the mnemonic
|
||||||
|
is typed, since the prompt ends that line on the terminal.
|
||||||
|
- 2026-10-14: `secret generate mnemonic`, `secret vault list` and
|
||||||
|
`secret unlocker list` print their output to stdout, as `secret list` does, so
|
||||||
|
it can be piped or captured (https://git.eeqj.de/sneak/secret/issues/138).
|
||||||
|
They printed with cobra's `cmd.Println` and `cmd.Printf`, which write to
|
||||||
|
stderr when no output writer is set, so `secret vault list --json | jq`
|
||||||
|
received nothing. "No versions found" from `secret version list` moved to
|
||||||
|
stdout too. Messages such as "Created vault", and the warning after the
|
||||||
|
mnemonic, stay on stderr.
|
||||||
|
- 2026-10-14: Removing the current unlocker no longer leaves the vault unable to
|
||||||
|
open through the unlockers it still has
|
||||||
|
(https://git.eeqj.de/sneak/secret/issues/137). `current-unlocker` went on
|
||||||
|
naming the removed unlocker, so every command that opened the vault failed,
|
||||||
|
with advice to use the mnemonic. `secret unlocker remove` of the current
|
||||||
|
unlocker now makes the first of the vault's other unlockers that
|
||||||
|
`secret unlocker select` accepts, in the order `secret unlocker list` shows,
|
||||||
|
current before removing it, and says so; when there is none, it also removes
|
||||||
|
`current-unlocker`. `vault.RemoveUnlocker` returns the ID of the unlocker it
|
||||||
|
made current. When `current-unlocker` names an unlocker that does not exist
|
||||||
|
and the vault has another that `secret unlocker select` accepts, the error
|
||||||
|
wraps `vault.ErrUnlockerNotFound` and says to run `secret unlocker select`,
|
||||||
|
without the advice to use the mnemonic.
|
||||||
|
- 2026-10-14: A `secret add --force` that cannot unlock the vault, given a wrong
|
||||||
|
passphrase or mnemonic, leaves the secret as it was
|
||||||
|
(https://git.eeqj.de/sneak/secret/issues/136). The new version used to be
|
||||||
|
written before the vault was unlocked to set `notAfter` on the version it
|
||||||
|
replaces, so each failed add left one more version that `secret version list`
|
||||||
|
showed. The vault is now unlocked, and that version's metadata read, before
|
||||||
|
anything is written. `generate secret --force` and `import --force` take the
|
||||||
|
same path.
|
||||||
- 2026-10-12: A mnemonic typed with extra spaces, tabs or line breaks gives the
|
- 2026-10-12: A mnemonic typed with extra spaces, tabs or line breaks gives the
|
||||||
same long-term key as its words separated by single spaces
|
same long-term key as its words separated by single spaces
|
||||||
(https://git.eeqj.de/sneak/secret/issues/135). The key was derived from the
|
(https://git.eeqj.de/sneak/secret/issues/135). The key was derived from the
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
@@ -197,10 +198,10 @@ func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
|
|||||||
|
|
||||||
// TestMnemonicNotReadNamesOnlyMnemonic is a regression test for
|
// TestMnemonicNotReadNamesOnlyMnemonic is a regression test for
|
||||||
// https://git.eeqj.de/sneak/secret/issues/115: `secret init` without
|
// https://git.eeqj.de/sneak/secret/issues/115: `secret init` without
|
||||||
// SB_SECRET_MNEMONIC and with a stdin that is not a terminal said "failed to
|
// SB_SECRET_MNEMONIC and with no terminal said "failed to read mnemonic:
|
||||||
// read mnemonic: failed to read passphrase: ...". The error must wrap
|
// failed to read passphrase: ...". The error must wrap
|
||||||
// secret.ErrMnemonicNotRead and name the mnemonic only. The message is
|
// secret.ErrMnemonicNotRead and name the mnemonic only. The message is
|
||||||
// pinned on the built binary, whose stdin is surely not a terminal.
|
// pinned on the built binary, run in a new session, which has no terminal.
|
||||||
func TestMnemonicNotReadNamesOnlyMnemonic(t *testing.T) {
|
func TestMnemonicNotReadNamesOnlyMnemonic(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -216,14 +217,15 @@ func TestMnemonicNotReadNamesOnlyMnemonic(t *testing.T) {
|
|||||||
"PATH=" + os.Getenv("PATH"),
|
"PATH=" + os.Getenv("PATH"),
|
||||||
"HOME=" + os.Getenv("HOME"),
|
"HOME=" + os.Getenv("HOME"),
|
||||||
}
|
}
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
|
|
||||||
require.Equal(t, "Initialized secrets manager at: "+stateDir+"\n"+
|
require.Equal(t, "Initialized secrets manager at: "+stateDir+"\n"+
|
||||||
"Error: failed to read mnemonic: stdin is not a terminal (piped input "+
|
"Error: failed to read mnemonic: there is no terminal to ask on. "+
|
||||||
"or script). Please set the SB_SECRET_MNEMONIC environment variable "+
|
"Please set the SB_SECRET_MNEMONIC environment variable or run "+
|
||||||
"or run interactively\n", string(output))
|
"interactively\n", string(output))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStopDuringCreateLeavesWholeVaultOrNone is a regression test for
|
// TestStopDuringCreateLeavesWholeVaultOrNone is a regression test for
|
||||||
|
|||||||
+119
-36
@@ -5,9 +5,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
"sneak.berlin/go/secret/internal/secret"
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
"sneak.berlin/go/secret/internal/vault"
|
||||||
@@ -188,36 +191,24 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
|||||||
input = file
|
input = file
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up output writer
|
return cli.writeOutput(outputFile, func(output io.Writer) error {
|
||||||
output := cli.cmd.OutOrStdout()
|
encryptor, err := age.Encrypt(output, recipient)
|
||||||
|
|
||||||
if outputFile != "" {
|
|
||||||
file, err := cli.fs.Create(outputFile)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create output file: %w", err)
|
return fmt.Errorf("failed to create age encryptor: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = file.Close() }()
|
|
||||||
|
|
||||||
output = file
|
_, err = io.Copy(encryptor, input)
|
||||||
}
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Encrypt the data
|
err = encryptor.Close()
|
||||||
encryptor, err := age.Encrypt(output, recipient)
|
if err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("failed to finalize encryption: %w", err)
|
||||||
return fmt.Errorf("failed to create age encryptor: %w", err)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.Copy(encryptor, input)
|
return nil
|
||||||
if err != nil {
|
})
|
||||||
return fmt.Errorf("failed to encrypt data: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = encryptor.Close()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to finalize encryption: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt decrypts data using an age secret key stored in a secret
|
// Decrypt decrypts data using an age secret key stored in a secret
|
||||||
@@ -278,31 +269,123 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
|||||||
input = file
|
input = file
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up output writer
|
return cli.writeOutput(outputFile, func(output io.Writer) error {
|
||||||
output := cli.cmd.OutOrStdout()
|
decryptor, err := age.Decrypt(input, identity)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create age decryptor: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if outputFile != "" {
|
_, err = io.Copy(output, decryptor)
|
||||||
file, err := cli.fs.Create(outputFile)
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decrypt data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// temporaryOutput is the name of the temporary file writeOutput is filling,
|
||||||
|
// until it is renamed into place. The signal handler in Entry removes it,
|
||||||
|
// since for decrypt it holds part of the secret.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // read by the signal handler, which has no Instance
|
||||||
|
var temporaryOutput atomic.Pointer[string]
|
||||||
|
|
||||||
|
// writeOutput calls write with stdout when outputFile is empty, and with
|
||||||
|
// outputFile itself when it is a device or a pipe, such as /dev/null.
|
||||||
|
// Otherwise write fills a new file of mode 0600 that replaces outputFile,
|
||||||
|
// or the file it is a symbolic link to, only once write has succeeded, so a
|
||||||
|
// failure leaves that file as it was, and it may be the input file too.
|
||||||
|
// An existing outputFile that cannot be written fails before write is
|
||||||
|
// called.
|
||||||
|
func (cli *Instance) writeOutput(
|
||||||
|
outputFile string, write func(output io.Writer) error,
|
||||||
|
) error {
|
||||||
|
if outputFile == "" {
|
||||||
|
return write(cli.cmd.OutOrStdout())
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := cli.fs.Stat(outputFile)
|
||||||
|
if err == nil && !info.Mode().IsRegular() {
|
||||||
|
file, err := cli.fs.OpenFile(outputFile, os.O_WRONLY, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create output file: %w", err)
|
return fmt.Errorf("failed to create output file: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = file.Close() }()
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
output = file
|
return write(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt the data
|
// An existing file that cannot be written fails here, as writing into it
|
||||||
decryptor, err := age.Decrypt(input, identity)
|
// directly would. Opened without O_TRUNC, which would empty it.
|
||||||
|
file, err := cli.fs.OpenFile(outputFile, os.O_WRONLY, 0)
|
||||||
|
if err == nil {
|
||||||
|
err = file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("failed to create output file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := outputPath(outputFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create age decryptor: %w", err)
|
return fmt.Errorf("failed to create output file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = secret.WriteFileAtomicFunc(cli.fs, path, func(file afero.File) error {
|
||||||
|
name := file.Name()
|
||||||
|
temporaryOutput.Store(&name)
|
||||||
|
|
||||||
|
return write(file)
|
||||||
|
})
|
||||||
|
|
||||||
|
temporaryOutput.Store(nil)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// outputPath returns the path of the file that writing to outputFile
|
||||||
|
// replaces, with no symbolic link in it, so that filepath.Dir of it is the
|
||||||
|
// directory that file is in. When outputFile is a symbolic link, as
|
||||||
|
// /dev/stdout is when stdout goes to a file, that is the path the link
|
||||||
|
// points to, whether or not a file is there yet, so the link is kept, as
|
||||||
|
// writing through it would keep it. A link that cannot be followed, such as
|
||||||
|
// one that points to itself, is an error.
|
||||||
|
func outputPath(outputFile string) (string, error) {
|
||||||
|
path, err := filepath.EvalSymlinks(outputFile)
|
||||||
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return path, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// No file is at outputFile, or at the end of its links, yet
|
||||||
|
target, err := os.Readlink(outputFile)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
// Nothing is at outputFile itself. Its directory part is resolved
|
||||||
|
// with filepath.EvalSymlinks, since filepath.Dir would remove a link
|
||||||
|
// to a directory followed by ".." as text and give another directory.
|
||||||
|
dir, name := filepath.Split(outputFile)
|
||||||
|
|
||||||
|
dir, err = filepath.EvalSymlinks(dir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(dir, name), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = io.Copy(output, decryptor)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to decrypt data: %w", err)
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
if !filepath.IsAbs(target) {
|
||||||
|
// Not filepath.Join, which would remove a ".." in target together
|
||||||
|
// with the name before it, even when that name is a link to a
|
||||||
|
// directory
|
||||||
|
dir, _ := filepath.Split(outputFile)
|
||||||
|
target = dir + target
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputPath(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
// isValidAgeSecretKey checks if a string is a valid age secret key by
|
// isValidAgeSecretKey checks if a string is a valid age secret key by
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
//nolint:testpackage // sets the unexported fields of Instance
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/secret/internal/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
// plainText is the content of plain.txt in the directory newCryptoTest makes.
|
||||||
|
const plainText = "document"
|
||||||
|
|
||||||
|
// newCryptoTest returns an Instance whose current vault, in a new state
|
||||||
|
// directory on the real filesystem, holds the age key secret "key", and a
|
||||||
|
// new directory holding only plain.txt and plain.txt.age, its encryption
|
||||||
|
// with that key.
|
||||||
|
func newCryptoTest(t *testing.T) (*Instance, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
stateDir := t.TempDir()
|
||||||
|
|
||||||
|
_, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
|
cli.Mnemonic = testMnemonicBuffer(t)
|
||||||
|
cli.cmd = &cobra.Command{}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
plain := filepath.Join(dir, "plain.txt")
|
||||||
|
require.NoError(t, afero.WriteFile(fs, plain, []byte(plainText), 0o600))
|
||||||
|
require.NoError(t, cli.Encrypt("key", plain, plain+".age"))
|
||||||
|
|
||||||
|
return cli, dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFailedCryptoLeavesOutputUnchanged is a regression test for
|
||||||
|
// https://git.eeqj.de/sneak/secret/issues/143, where secret encrypt and
|
||||||
|
// secret decrypt emptied the file named by --output before they failed.
|
||||||
|
// A failed command must leave an existing file as it was and create none,
|
||||||
|
// and give the same error as without --output.
|
||||||
|
func TestFailedCryptoLeavesOutputUnchanged(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
run func(cli *Instance, dir, output string) error
|
||||||
|
}{
|
||||||
|
// Reading a directory fails once encryption has begun
|
||||||
|
{"encrypt", func(cli *Instance, dir, output string) error {
|
||||||
|
return cli.Encrypt("key", dir, output)
|
||||||
|
}},
|
||||||
|
// plain.txt is not an age file
|
||||||
|
{"decrypt", func(cli *Instance, dir, output string) error {
|
||||||
|
return cli.Decrypt("key", filepath.Join(dir, "plain.txt"), output)
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cli, dir := newCryptoTest(t)
|
||||||
|
existing := filepath.Join(dir, "existing.txt")
|
||||||
|
require.NoError(t, afero.WriteFile(cli.fs, existing, []byte("precious"), 0o600))
|
||||||
|
|
||||||
|
cli.cmd.SetOut(io.Discard)
|
||||||
|
toStdout := tt.run(cli, dir, "")
|
||||||
|
require.Error(t, toStdout)
|
||||||
|
|
||||||
|
require.EqualError(t, tt.run(cli, dir, existing), toStdout.Error())
|
||||||
|
require.EqualError(t, tt.run(cli, dir, filepath.Join(dir, "new.txt")),
|
||||||
|
toStdout.Error())
|
||||||
|
|
||||||
|
content, err := afero.ReadFile(cli.fs, existing)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "precious", string(content))
|
||||||
|
|
||||||
|
// No new.txt, and no temporary file left behind
|
||||||
|
entries, err := afero.ReadDir(cli.fs, dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
names := make([]string, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
names = append(names, entry.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Equal(t, []string{"existing.txt", "plain.txt", "plain.txt.age"}, names)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFailFs fails every open of one path for writing, as a file of mode
|
||||||
|
// 0444 does for anyone but root, who runs these tests in Docker.
|
||||||
|
type writeFailFs struct {
|
||||||
|
afero.Fs
|
||||||
|
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
||||||
|
func (f *writeFailFs) OpenFile(
|
||||||
|
name string, flag int, perm os.FileMode,
|
||||||
|
) (afero.File, error) {
|
||||||
|
if name == f.path && flag&(os.O_WRONLY|os.O_RDWR) != 0 {
|
||||||
|
return nil, &os.PathError{Op: "open", Path: name, Err: syscall.EACCES}
|
||||||
|
}
|
||||||
|
|
||||||
|
return f.Fs.OpenFile(name, flag, perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDecryptToUnwritableOutput checks that secret decrypt fails when
|
||||||
|
// --output is a file that cannot be written, as writing into it directly
|
||||||
|
// does, and leaves it as it was, although its directory can be written.
|
||||||
|
func TestDecryptToUnwritableOutput(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cli, dir := newCryptoTest(t)
|
||||||
|
existing := filepath.Join(dir, "existing.txt")
|
||||||
|
require.NoError(t, afero.WriteFile(cli.fs, existing, []byte("precious"), 0o600))
|
||||||
|
cli.fs = &writeFailFs{Fs: cli.fs, path: existing}
|
||||||
|
|
||||||
|
err := cli.Decrypt("key", filepath.Join(dir, "plain.txt.age"), existing)
|
||||||
|
require.ErrorIs(t, err, os.ErrPermission)
|
||||||
|
|
||||||
|
content, err := afero.ReadFile(cli.fs, existing)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "precious", string(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCryptoInPlace checks that secret encrypt and secret decrypt given
|
||||||
|
// one file as both --input and --output replace it with the right result.
|
||||||
|
// Before https://git.eeqj.de/sneak/secret/issues/143 they emptied the file
|
||||||
|
// before reading it.
|
||||||
|
func TestCryptoInPlace(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cli, dir := newCryptoTest(t)
|
||||||
|
file := filepath.Join(dir, "plain.txt")
|
||||||
|
|
||||||
|
require.NoError(t, cli.Encrypt("key", file, file))
|
||||||
|
require.NoError(t, cli.Decrypt("key", file, file))
|
||||||
|
|
||||||
|
content, err := afero.ReadFile(cli.fs, file)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, plainText, string(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDecryptToPipe checks that secret decrypt writes into a named pipe
|
||||||
|
// given as --output, as it must into a device such as /dev/null, instead
|
||||||
|
// of replacing it with a file.
|
||||||
|
func TestDecryptToPipe(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cli, dir := newCryptoTest(t)
|
||||||
|
pipe := filepath.Join(dir, "pipe")
|
||||||
|
require.NoError(t, syscall.Mkfifo(pipe, 0o600))
|
||||||
|
|
||||||
|
received := make(chan []byte, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
content, _ := afero.ReadFile(cli.fs, pipe)
|
||||||
|
received <- content
|
||||||
|
}()
|
||||||
|
|
||||||
|
require.NoError(t, cli.Decrypt("key", filepath.Join(dir, "plain.txt.age"), pipe))
|
||||||
|
|
||||||
|
info, err := os.Lstat(pipe)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, os.ModeNamedPipe, info.Mode().Type())
|
||||||
|
require.Equal(t, plainText, string(<-received))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDecryptThroughLink checks that secret decrypt given a symbolic link
|
||||||
|
// as --output replaces the file the link points to, in another directory,
|
||||||
|
// and keeps the link, as writing through the link would.
|
||||||
|
func TestDecryptThroughLink(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cli, dir := newCryptoTest(t)
|
||||||
|
targetDir := t.TempDir()
|
||||||
|
target := filepath.Join(targetDir, "target.txt")
|
||||||
|
require.NoError(t, afero.WriteFile(cli.fs, target, []byte("old"), 0o600))
|
||||||
|
|
||||||
|
link := filepath.Join(dir, "link")
|
||||||
|
require.NoError(t, os.Symlink(target, link))
|
||||||
|
|
||||||
|
require.NoError(t, cli.Decrypt("key", filepath.Join(dir, "plain.txt.age"), link))
|
||||||
|
|
||||||
|
info, err := os.Lstat(link)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, os.ModeSymlink, info.Mode().Type())
|
||||||
|
|
||||||
|
content, err := afero.ReadFile(cli.fs, target)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, plainText, string(content))
|
||||||
|
|
||||||
|
// No temporary file left beside the target
|
||||||
|
entries, err := afero.ReadDir(cli.fs, targetDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, entries, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDecryptThroughLinkToMissingFile checks that secret decrypt given a
|
||||||
|
// symbolic link to a file that does not exist yet as --output creates that
|
||||||
|
// file and keeps the link, as writing through the link would. Here --output
|
||||||
|
// is a relative link to a second link, which points into another directory.
|
||||||
|
func TestDecryptThroughLinkToMissingFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cli, dir := newCryptoTest(t)
|
||||||
|
targetDir := t.TempDir()
|
||||||
|
target := filepath.Join(targetDir, "target.txt")
|
||||||
|
|
||||||
|
link := filepath.Join(dir, "link")
|
||||||
|
require.NoError(t, os.Symlink("second-link", link))
|
||||||
|
require.NoError(t, os.Symlink(target, filepath.Join(dir, "second-link")))
|
||||||
|
|
||||||
|
require.NoError(t, cli.Decrypt("key", filepath.Join(dir, "plain.txt.age"), link))
|
||||||
|
|
||||||
|
for _, name := range []string{"link", "second-link"} {
|
||||||
|
info, err := os.Lstat(filepath.Join(dir, name))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, os.ModeSymlink, info.Mode().Type(), name)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := afero.ReadFile(cli.fs, target)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, plainText, string(content))
|
||||||
|
|
||||||
|
// No temporary file left beside the target
|
||||||
|
entries, err := afero.ReadDir(cli.fs, targetDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, entries, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDecryptThroughLinkAndParent checks that secret decrypt makes the file
|
||||||
|
// --output names in the directory its path leads to when the path holds a
|
||||||
|
// symbolic link to a directory followed by "..": dir/link/../sub/new.txt is
|
||||||
|
// targetDir/sub/new.txt, and dir/sub, where removing "link/.." as text
|
||||||
|
// leads, does not exist.
|
||||||
|
func TestDecryptThroughLinkAndParent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cli, dir := newCryptoTest(t)
|
||||||
|
targetDir := t.TempDir()
|
||||||
|
require.NoError(t, os.Mkdir(filepath.Join(targetDir, "linked"), 0o700))
|
||||||
|
require.NoError(t, os.Mkdir(filepath.Join(targetDir, "sub"), 0o700))
|
||||||
|
require.NoError(t, os.Symlink(filepath.Join(targetDir, "linked"),
|
||||||
|
filepath.Join(dir, "link")))
|
||||||
|
|
||||||
|
// Not filepath.Join, which would remove "link/.." too
|
||||||
|
output := dir + "/link/../sub/new.txt"
|
||||||
|
|
||||||
|
require.NoError(t, cli.Decrypt("key", filepath.Join(dir, "plain.txt.age"), output))
|
||||||
|
|
||||||
|
content, err := afero.ReadFile(cli.fs, filepath.Join(targetDir, "sub", "new.txt"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, plainText, string(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDecryptThroughLoopingLink checks that secret decrypt given a symbolic
|
||||||
|
// link that points to itself as --output fails, as writing through it
|
||||||
|
// would, and leaves the link as it was.
|
||||||
|
func TestDecryptThroughLoopingLink(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cli, dir := newCryptoTest(t)
|
||||||
|
link := filepath.Join(dir, "link")
|
||||||
|
require.NoError(t, os.Symlink("link", link))
|
||||||
|
|
||||||
|
require.Error(t, cli.Decrypt("key", filepath.Join(dir, "plain.txt.age"), link))
|
||||||
|
|
||||||
|
target, err := os.Readlink(link)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "link", target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDecryptToLongestName checks that secret decrypt writes an --output
|
||||||
|
// whose name is as long as a file name can be, 255 bytes on Linux and
|
||||||
|
// macOS: the temporary file's name must not be longer.
|
||||||
|
func TestDecryptToLongestName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const longestName = 255
|
||||||
|
|
||||||
|
cli, dir := newCryptoTest(t)
|
||||||
|
output := filepath.Join(dir, strings.Repeat("a", longestName))
|
||||||
|
|
||||||
|
require.NoError(t, cli.Decrypt("key", filepath.Join(dir, "plain.txt.age"), output))
|
||||||
|
|
||||||
|
content, err := afero.ReadFile(cli.fs, output)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, plainText, string(content))
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -105,3 +107,115 @@ func TestInterruptExitsThroughMemguard(t *testing.T) {
|
|||||||
require.ErrorAs(t, err, &exitErr)
|
require.ErrorAs(t, err, &exitErr)
|
||||||
assert.Equal(t, 1, exitErr.ExitCode(), "add ended with %v", err)
|
assert.Equal(t, 1, exitErr.ExitCode(), "add ended with %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl-C or a hangup while encrypt or decrypt waits for input must remove
|
||||||
|
// the temporary file it is writing next to --output, which for decrypt
|
||||||
|
// holds part of the secret.
|
||||||
|
func TestInterruptRemovesTemporaryOutput(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
secretPath := secretBinaryPath(t)
|
||||||
|
env := []string{
|
||||||
|
secret.EnvStateDir + "=" + t.TempDir(),
|
||||||
|
secret.EnvMnemonic + "=" + testMnemonic,
|
||||||
|
secret.EnvUnlockPassphrase + "=test-passphrase",
|
||||||
|
"PATH=" + os.Getenv("PATH"),
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||||
|
initCmd := exec.CommandContext(ctx, secretPath, "init")
|
||||||
|
initCmd.Env = env
|
||||||
|
|
||||||
|
output, err := initCmd.CombinedOutput()
|
||||||
|
require.NoError(t, err, "init should succeed: %s", output)
|
||||||
|
|
||||||
|
// encrypt stores the new key before it starts writing, so decrypt has it
|
||||||
|
for _, command := range []string{"encrypt", "decrypt"} {
|
||||||
|
for _, sig := range []os.Signal{os.Interrupt, syscall.SIGHUP} {
|
||||||
|
outputDir := t.TempDir()
|
||||||
|
|
||||||
|
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||||
|
cmd := exec.CommandContext(ctx, secretPath, command, "key",
|
||||||
|
"--output", filepath.Join(outputDir, "out"))
|
||||||
|
cmd.Env = env
|
||||||
|
|
||||||
|
// Held open and never written, so the command keeps waiting for
|
||||||
|
// input. Wait closes it.
|
||||||
|
_, err := cmd.StdinPipe()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, cmd.Start())
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
entries, err := os.ReadDir(outputDir)
|
||||||
|
|
||||||
|
return err == nil && len(entries) > 0
|
||||||
|
}, commandWait, 10*time.Millisecond, "%s made no temporary file", command)
|
||||||
|
require.NoError(t, cmd.Process.Signal(sig))
|
||||||
|
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
|
||||||
|
require.ErrorAs(t, cmd.Wait(), &exitErr)
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(outputDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, entries, "%s left its temporary file on %v", command, sig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// encrypt started with nohup must go on ignoring a hangup, which it catches
|
||||||
|
// otherwise, and finish writing --output.
|
||||||
|
func TestNohupIgnoresHangup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
secretPath := secretBinaryPath(t)
|
||||||
|
env := []string{
|
||||||
|
secret.EnvStateDir + "=" + t.TempDir(),
|
||||||
|
secret.EnvMnemonic + "=" + testMnemonic,
|
||||||
|
secret.EnvUnlockPassphrase + "=test-passphrase",
|
||||||
|
"PATH=" + os.Getenv("PATH"),
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||||
|
initCmd := exec.CommandContext(ctx, secretPath, "init")
|
||||||
|
initCmd.Env = env
|
||||||
|
|
||||||
|
output, err := initCmd.CombinedOutput()
|
||||||
|
require.NoError(t, err, "init should succeed: %s", output)
|
||||||
|
|
||||||
|
outputDir := t.TempDir()
|
||||||
|
outputFile := filepath.Join(outputDir, "out")
|
||||||
|
|
||||||
|
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||||
|
cmd := exec.CommandContext(ctx, "nohup", secretPath, "encrypt", "key",
|
||||||
|
"--output", outputFile)
|
||||||
|
cmd.Env = env
|
||||||
|
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, cmd.Start())
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
entries, err := os.ReadDir(outputDir)
|
||||||
|
|
||||||
|
return err == nil && len(entries) > 0
|
||||||
|
}, commandWait, 10*time.Millisecond, "encrypt made no temporary file")
|
||||||
|
require.NoError(t, cmd.Process.Signal(syscall.SIGHUP))
|
||||||
|
|
||||||
|
// Time for a hangup that is caught to end encrypt before it has its input
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
_, err = stdin.Write([]byte("document"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, stdin.Close())
|
||||||
|
require.NoError(t, cmd.Wait())
|
||||||
|
|
||||||
|
_, err = os.Stat(outputFile)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ func (cli *Instance) GenerateMnemonic(cmd *cobra.Command) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Output mnemonic to stdout
|
// Output mnemonic to stdout
|
||||||
cmd.Println(mnemonic)
|
_, _ = fmt.Fprintln(cmd.OutOrStdout(), mnemonic)
|
||||||
|
|
||||||
// Output helpful information to stderr
|
// Output helpful information to stderr
|
||||||
fmt.Fprintln(os.Stderr, "")
|
fmt.Fprintln(os.Stderr, "")
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
@@ -57,13 +56,11 @@ func (cli *Instance) promptMnemonic() (*memguard.LockedBuffer, func(), error) {
|
|||||||
// Read mnemonic securely without echo
|
// Read mnemonic securely without echo
|
||||||
mnemonicBuffer, err := secret.ReadMnemonic("Enter your BIP39 mnemonic phrase: ")
|
mnemonicBuffer, err := secret.ReadMnemonic("Enter your BIP39 mnemonic phrase: ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
secret.Debug("Failed to read mnemonic", "error", err)
|
||||||
|
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
|
||||||
|
|
||||||
return mnemonicBuffer, mnemonicBuffer.Destroy, nil
|
return mnemonicBuffer, mnemonicBuffer.Destroy, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,10 +52,10 @@ func TestLeftoversRemovedByNextChangingCommand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, file := range []string{
|
for _, file := range []string{
|
||||||
testStateDir + "/.currentvault.tmp-4",
|
testStateDir + "/.tmp-4",
|
||||||
vaultDir + "/.current-unlocker.tmp-5",
|
vaultDir + "/.tmp-5",
|
||||||
secretDir + "/.current.tmp-6",
|
secretDir + "/.tmp-6",
|
||||||
secretDir + "/versions/" + versions[0] + "/.metadata.age.tmp-7",
|
secretDir + "/versions/" + versions[0] + "/.tmp-7",
|
||||||
} {
|
} {
|
||||||
require.NoError(t, afero.WriteFile(fs, file,
|
require.NoError(t, afero.WriteFile(fs, file,
|
||||||
[]byte("partial"), secret.FilePerms))
|
[]byte("partial"), secret.FilePerms))
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package cli_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
|
"github.com/creack/pty"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/secret/internal/secret"
|
||||||
|
"sneak.berlin/go/secret/internal/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPassphraseAskedAtTerminalWithPipedStdin is a regression test for
|
||||||
|
// https://git.eeqj.de/sneak/secret/issues/144: run at a terminal with stdin
|
||||||
|
// a pipe, as in `echo v2 | secret add s1 --force`, a command that needed
|
||||||
|
// the passphrase failed instead of asking for it. Each command here must ask
|
||||||
|
// on the terminal and succeed once the passphrase is typed there.
|
||||||
|
func TestPassphraseAskedAtTerminalWithPipedStdin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stateDir := t.TempDir()
|
||||||
|
|
||||||
|
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||||
|
defer passphrase.Destroy()
|
||||||
|
|
||||||
|
vlt, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default",
|
||||||
|
testMnemonicBuffer(t), passphrase)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
value := memguard.NewBufferFromBytes([]byte("v1"))
|
||||||
|
defer value.Destroy()
|
||||||
|
|
||||||
|
require.NoError(t, vlt.AddSecret("s1", value, false))
|
||||||
|
|
||||||
|
identity, err := age.GenerateX25519Identity()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
key := secret.IdentityToLockedBuffer(identity)
|
||||||
|
defer key.Destroy()
|
||||||
|
|
||||||
|
require.NoError(t, vlt.AddSecret("mykey", key, false))
|
||||||
|
|
||||||
|
runAtTerminal(t, stateDir, "v2\n", "add", "s1", "--force")
|
||||||
|
|
||||||
|
vlt.UnlockPassphrase = passphrase
|
||||||
|
|
||||||
|
added, err := vlt.GetSecret("s1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer added.Destroy()
|
||||||
|
|
||||||
|
assert.Equal(t, "v2", added.String())
|
||||||
|
|
||||||
|
encrypted := runAtTerminal(t, stateDir, "a document\n", "encrypt", "mykey")
|
||||||
|
decrypted := runAtTerminal(t, stateDir, encrypted, "decrypt", "mykey")
|
||||||
|
assert.Equal(t, "a document\n", decrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInitWritesNoBlankLineAfterMnemonic runs `secret init 2>log` at a
|
||||||
|
// terminal: the terminal is its controlling terminal and none of stdin,
|
||||||
|
// stdout and stderr. The mnemonic is typed on the terminal and the prompt
|
||||||
|
// ends its line there, so stderr must not get a newline for it as well, a
|
||||||
|
// blank line in the log. pty.Open is described above
|
||||||
|
// TestRemoveIgnoresTerminalOnStdout.
|
||||||
|
func TestInitWritesNoBlankLineAfterMnemonic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
stateDir := t.TempDir()
|
||||||
|
|
||||||
|
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||||
|
cmd := exec.CommandContext(ctx, secretBinaryPath(t), "init")
|
||||||
|
cmd.Env = []string{
|
||||||
|
secret.EnvStateDir + "=" + stateDir,
|
||||||
|
secret.EnvUnlockPassphrase + "=" + testPassphrase,
|
||||||
|
"PATH=" + os.Getenv("PATH"),
|
||||||
|
"HOME=" + os.Getenv("HOME"),
|
||||||
|
}
|
||||||
|
|
||||||
|
ptmx, tty, err := pty.Open()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { _ = ptmx.Close() }()
|
||||||
|
|
||||||
|
deadline, _ := ctx.Deadline()
|
||||||
|
require.NoError(t, ptmx.SetReadDeadline(deadline))
|
||||||
|
|
||||||
|
var stderr strings.Builder
|
||||||
|
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
cmd.ExtraFiles = []*os.File{tty}
|
||||||
|
// Ctty is a descriptor in the new process: 3, the first of ExtraFiles.
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 3}
|
||||||
|
|
||||||
|
require.NoError(t, cmd.Start())
|
||||||
|
|
||||||
|
_ = tty.Close()
|
||||||
|
|
||||||
|
var (
|
||||||
|
shown []byte
|
||||||
|
char byte
|
||||||
|
)
|
||||||
|
|
||||||
|
terminal := bufio.NewReader(ptmx)
|
||||||
|
for !bytes.HasSuffix(shown, []byte("Enter your BIP39 mnemonic phrase: ")) {
|
||||||
|
char, err = terminal.ReadByte()
|
||||||
|
require.NoError(t, err, "secret init did not ask on the terminal: %s",
|
||||||
|
shown)
|
||||||
|
|
||||||
|
shown = append(shown, char)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ptmx.WriteString(testMnemonic + "\n")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// The read ends once the command has exited and so closed the terminal.
|
||||||
|
rest, _ := io.ReadAll(terminal)
|
||||||
|
|
||||||
|
err = cmd.Wait()
|
||||||
|
require.NoError(t, err, "secret init: %s%s%s", shown, rest, stderr.String())
|
||||||
|
|
||||||
|
assert.True(t, strings.HasPrefix(stderr.String(),
|
||||||
|
"Initialized secrets manager at: "+stateDir+"\n\n"+
|
||||||
|
"Default vault created and configured\n"),
|
||||||
|
"stderr: %q", stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAtTerminal runs the secret binary with args and input on stdin, in a
|
||||||
|
// new session whose controlling terminal is a new terminal, which is also
|
||||||
|
// its stderr: a command typed at a terminal with its input piped. pty.Open
|
||||||
|
// is described above TestRemoveIgnoresTerminalOnStdout. runAtTerminal waits
|
||||||
|
// for the passphrase prompt on the terminal, types the passphrase there,
|
||||||
|
// and returns what the command wrote to stdout once it has succeeded.
|
||||||
|
func runAtTerminal(t *testing.T, stateDir, input string, args ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||||
|
cmd := exec.CommandContext(ctx, secretBinaryPath(t), args...)
|
||||||
|
cmd.Env = []string{
|
||||||
|
secret.EnvStateDir + "=" + stateDir,
|
||||||
|
"PATH=" + os.Getenv("PATH"),
|
||||||
|
"HOME=" + os.Getenv("HOME"),
|
||||||
|
}
|
||||||
|
|
||||||
|
ptmx, tty, err := pty.Open()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { _ = ptmx.Close() }()
|
||||||
|
|
||||||
|
deadline, _ := ctx.Deadline()
|
||||||
|
require.NoError(t, ptmx.SetReadDeadline(deadline))
|
||||||
|
|
||||||
|
var stdout strings.Builder
|
||||||
|
|
||||||
|
cmd.Stdin = strings.NewReader(input)
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = tty
|
||||||
|
// Ctty is a descriptor in the new process: 2, its stderr.
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 2}
|
||||||
|
|
||||||
|
require.NoError(t, cmd.Start())
|
||||||
|
|
||||||
|
_ = tty.Close()
|
||||||
|
|
||||||
|
var (
|
||||||
|
shown []byte
|
||||||
|
char byte
|
||||||
|
)
|
||||||
|
|
||||||
|
terminal := bufio.NewReader(ptmx)
|
||||||
|
for !bytes.HasSuffix(shown, []byte("Enter unlock passphrase: ")) {
|
||||||
|
char, err = terminal.ReadByte()
|
||||||
|
require.NoError(t, err, "secret %v did not ask on the terminal: %s",
|
||||||
|
args, shown)
|
||||||
|
|
||||||
|
shown = append(shown, char)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ptmx.WriteString(testPassphrase + "\n")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// The read ends once the command has exited and so closed the terminal.
|
||||||
|
rest, _ := io.ReadAll(terminal)
|
||||||
|
|
||||||
|
require.NoError(t, cmd.Wait(), "secret %v: %s%s", args, shown, rest)
|
||||||
|
|
||||||
|
return stdout.String()
|
||||||
|
}
|
||||||
+38
-10
@@ -2,6 +2,7 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -14,19 +15,46 @@ import (
|
|||||||
// every memguard buffer before it returns, so the caller must do nothing
|
// every memguard buffer before it returns, so the caller must do nothing
|
||||||
// but exit with the code.
|
// but exit with the code.
|
||||||
func Entry() int {
|
func Entry() int {
|
||||||
// On SIGINT or SIGTERM memguard runs this function, wipes every buffer
|
// On SIGINT, SIGTERM or SIGHUP memguard runs this function, wipes every
|
||||||
// and exits with status 1. The passphrase prompt turns terminal echo
|
// buffer and exits with status 1. The function removes the temporary
|
||||||
// off until the read finishes, so a signal there would leave echo off.
|
// file that encrypt or decrypt is writing. The passphrase prompt turns
|
||||||
// Only a process in the terminal's foreground process group may reset
|
// echo off on the controlling terminal until the read finishes, so a
|
||||||
// it: one in the background that tries is stopped instead of exiting.
|
// signal there would leave echo off. Only a process in the terminal's
|
||||||
terminalState, terminalErr := term.GetState(unix.Stdin)
|
// foreground process group may reset it: one in the background that
|
||||||
|
// tries is stopped instead of exiting.
|
||||||
|
terminal, terminalErr := os.Open(secret.TerminalPath)
|
||||||
|
|
||||||
|
var terminalState *term.State
|
||||||
|
|
||||||
|
if terminalErr == nil {
|
||||||
|
defer func() { _ = terminal.Close() }()
|
||||||
|
|
||||||
|
terminalState, terminalErr = term.GetState(int(terminal.Fd()))
|
||||||
|
}
|
||||||
|
|
||||||
|
signals := []os.Signal{os.Interrupt, unix.SIGTERM}
|
||||||
|
|
||||||
|
// A command started with nohup must go on ignoring SIGHUP, which
|
||||||
|
// catching it would undo
|
||||||
|
if !signal.Ignored(unix.SIGHUP) {
|
||||||
|
signals = append(signals, unix.SIGHUP)
|
||||||
|
}
|
||||||
|
|
||||||
memguard.CatchSignal(func(os.Signal) {
|
memguard.CatchSignal(func(os.Signal) {
|
||||||
foreground, err := unix.IoctlGetInt(unix.Stdin, unix.TIOCGPGRP)
|
name := temporaryOutput.Load()
|
||||||
if terminalErr == nil && err == nil && foreground == unix.Getpgrp() {
|
if name != nil {
|
||||||
_ = term.Restore(unix.Stdin, terminalState)
|
_ = os.Remove(*name)
|
||||||
}
|
}
|
||||||
}, os.Interrupt, unix.SIGTERM)
|
|
||||||
|
if terminalErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
foreground, err := unix.IoctlGetInt(int(terminal.Fd()), unix.TIOCGPGRP)
|
||||||
|
if err == nil && foreground == unix.Getpgrp() {
|
||||||
|
_ = term.Restore(int(terminal.Fd()), terminalState)
|
||||||
|
}
|
||||||
|
}, signals...)
|
||||||
|
|
||||||
defer memguard.Purge()
|
defer memguard.Purge()
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package cli_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/secret/internal/bip39"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
"sneak.berlin/go/secret/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -90,3 +92,66 @@ func TestGetCommandOutputsToStdout(t *testing.T) {
|
|||||||
// Nothing should be in stderr
|
// Nothing should be in stderr
|
||||||
assert.Empty(t, stderr.String(), "stderr should be empty")
|
assert.Empty(t, stderr.String(), "stderr should be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMnemonicAndListingsOutputToStdout tests that 'secret generate
|
||||||
|
// mnemonic', 'secret vault list' and 'secret unlocker list' print their
|
||||||
|
// output to stdout, so that it can be piped
|
||||||
|
func TestMnemonicAndListingsOutputToStdout(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
secretPath := secretBinaryPath(t)
|
||||||
|
|
||||||
|
// run runs the secret binary and returns what it wrote to stdout and to
|
||||||
|
// stderr
|
||||||
|
run := func(args ...string) (string, string) {
|
||||||
|
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||||
|
cmd := exec.CommandContext(t.Context(), secretPath, args...)
|
||||||
|
cmd.Env = []string{
|
||||||
|
secret.EnvStateDir + "=" + tempDir,
|
||||||
|
secret.EnvMnemonic + "=" + testMnemonic,
|
||||||
|
secret.EnvUnlockPassphrase + "=test-passphrase",
|
||||||
|
"PATH=/usr/bin:/bin",
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
require.NoError(t, err, "%v should succeed: %s", args, stderr.String())
|
||||||
|
|
||||||
|
return stdout.String(), stderr.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout, stderr := run("generate", "mnemonic")
|
||||||
|
assert.True(t, bip39.IsMnemonicValid(strings.TrimSpace(stdout)),
|
||||||
|
"stdout should hold the mnemonic, not %q", stdout)
|
||||||
|
assert.Contains(t, stderr, "Save this mnemonic phrase securely",
|
||||||
|
"the warning should be on stderr")
|
||||||
|
|
||||||
|
run("init")
|
||||||
|
|
||||||
|
stdout, _ = run("vault", "list", "--json")
|
||||||
|
|
||||||
|
var vaults map[string]any
|
||||||
|
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(stdout), &vaults),
|
||||||
|
"stdout should hold the vaults as JSON, not %q", stdout)
|
||||||
|
assert.Equal(t, "default", vaults["currentVault"])
|
||||||
|
|
||||||
|
stdout, _ = run("unlocker", "list", "--json")
|
||||||
|
|
||||||
|
var unlockers map[string]any
|
||||||
|
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(stdout), &unlockers),
|
||||||
|
"stdout should hold the unlockers as JSON, not %q", stdout)
|
||||||
|
assert.Len(t, unlockers["unlockers"], 1)
|
||||||
|
|
||||||
|
stdout, _ = run("vault", "list")
|
||||||
|
assert.Contains(t, stdout, "default (current)")
|
||||||
|
|
||||||
|
stdout, _ = run("unlocker", "list")
|
||||||
|
assert.Contains(t, stdout, "Total: 1 unlocker(s)")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
// Unlock Failure Tests
|
// Unlock Failure Tests
|
||||||
//
|
//
|
||||||
// When a vault cannot be opened through its current unlocker, because a
|
// When a vault cannot be opened through its current unlocker, because a
|
||||||
// file the unlocker needs is missing or the passphrase is wrong, the error
|
// file the unlocker needs is missing, the error keeps its cause and ends by
|
||||||
// keeps its cause and ends by saying that the mnemonic still opens that
|
// saying that the mnemonic still opens that vault, but only for a vault that
|
||||||
// vault, but only for a vault that the mnemonic does open, and not when the
|
// the mnemonic does open, and not when the passphrase could not be read at
|
||||||
// passphrase could not be read at all. When a secret's current file is
|
// all. A wrong passphrase gives an error that says so, without that advice.
|
||||||
// missing, the error says how to make a version current again. Each test
|
// When the current unlocker does not exist and the vault has another that
|
||||||
// that pins such advice also follows it.
|
// `secret unlocker select` accepts, the error says to select one instead.
|
||||||
|
// When a secret's current file is missing, the error says how to make a
|
||||||
|
// version current again. Each test that pins such advice also follows it.
|
||||||
|
|
||||||
package cli_test
|
package cli_test
|
||||||
|
|
||||||
@@ -16,9 +18,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"filippo.io/age"
|
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -162,26 +164,48 @@ func TestUnlockFailureNamesMnemonic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWrongPassphraseNamesMnemonic checks the error of `secret get` given a
|
// TestWrongPassphraseNamesPassphrase checks the error of `secret get` given
|
||||||
// passphrase that does not decrypt the passphrase unlocker: it keeps age's
|
// a passphrase that does not decrypt the passphrase unlocker: it says that
|
||||||
// error and ends with the advice that the mnemonic still opens the vault.
|
// the passphrase is wrong, without the advice to use the mnemonic, since the
|
||||||
func TestWrongPassphraseNamesMnemonic(t *testing.T) {
|
// unlocker still opens the vault with the right passphrase. The test then
|
||||||
|
// gives the right passphrase.
|
||||||
|
func TestWrongPassphraseNamesPassphrase(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, c := newUnlockTestCLI(t, newTwoVaultFs(t))
|
unlockerDir, c := newUnlockTestCLI(t, newTwoVaultFs(t))
|
||||||
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte("wrong passphrase"))
|
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte("wrong passphrase"))
|
||||||
t.Cleanup(c.UnlockPassphrase.Destroy)
|
t.Cleanup(c.UnlockPassphrase.Destroy)
|
||||||
|
|
||||||
err := c.GetSecret(discardCmd(), "x")
|
err := c.GetSecret(discardCmd(), "x")
|
||||||
|
|
||||||
var noMatch *age.NoIdentityMatchError
|
require.ErrorIs(t, err, secret.ErrWrongPassphrase)
|
||||||
|
assert.NotContains(t, err.Error(), mnemonicAdvice)
|
||||||
require.ErrorAs(t, err, &noMatch)
|
|
||||||
|
|
||||||
require.EqualError(t, err, "failed to unlock vault: "+
|
require.EqualError(t, err, "failed to unlock vault: "+
|
||||||
"failed to get long-term key: failed to get unlocker identity: "+
|
"failed to get long-term key: failed to get unlocker identity: "+
|
||||||
"failed to decrypt unlocker private key: failed to create decryptor: "+
|
"wrong passphrase for unlocker "+filepath.Base(unlockerDir))
|
||||||
noMatch.Error()+mnemonicAdvice)
|
|
||||||
|
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||||
|
t.Cleanup(c.UnlockPassphrase.Destroy)
|
||||||
|
assert.Equal(t, "value", getSecret(t, c, "x"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDamagedUnlockerNamesMnemonic checks the error of `secret get` when the
|
||||||
|
// passphrase unlocker's priv.age is not an age file: the passphrase is not
|
||||||
|
// blamed, and the error ends with the advice that the mnemonic still opens
|
||||||
|
// the vault.
|
||||||
|
func TestDamagedUnlockerNamesMnemonic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := newTwoVaultFs(t)
|
||||||
|
unlockerDir, c := newUnlockTestCLI(t, fs)
|
||||||
|
|
||||||
|
require.NoError(t, afero.WriteFile(fs, filepath.Join(unlockerDir, "priv.age"),
|
||||||
|
[]byte("damaged"), secret.FilePerms))
|
||||||
|
|
||||||
|
err := c.GetSecret(discardCmd(), "x")
|
||||||
|
|
||||||
|
require.NotErrorIs(t, err, secret.ErrWrongPassphrase)
|
||||||
|
require.ErrorContains(t, err, mnemonicAdvice)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMoveUnlockFailureNamesVault checks the error of `secret move` into
|
// TestMoveUnlockFailureNamesVault checks the error of `secret move` into
|
||||||
@@ -222,8 +246,71 @@ func TestMoveUnlockFailureNamesVault(t *testing.T) {
|
|||||||
assert.Equal(t, "value", getSecret(t, c, "y"))
|
assert.Equal(t, "value", getSecret(t, c, "y"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMissingCurrentUnlockerNamesSelect checks the error of `secret get`
|
||||||
|
// when current-unlocker names an unlocker that does not exist and the vault
|
||||||
|
// has another: it names the missing unlocker and says to select another
|
||||||
|
// one, without the advice to use the mnemonic. The test then follows that
|
||||||
|
// advice.
|
||||||
|
func TestMissingCurrentUnlockerNamesSelect(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := newTwoVaultFs(t)
|
||||||
|
unlockerDir, c := newUnlockTestCLI(t, fs)
|
||||||
|
|
||||||
|
require.NoError(t, afero.WriteFile(fs,
|
||||||
|
filepath.Join(unlockTestVaultDir, "current-unlocker"),
|
||||||
|
[]byte("missing"), secret.FilePerms))
|
||||||
|
|
||||||
|
err := c.GetSecret(discardCmd(), "x")
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, vault.ErrUnlockerNotFound)
|
||||||
|
require.EqualError(t, err, "failed to unlock vault: "+
|
||||||
|
"failed to get long-term key: failed to get current unlocker: "+
|
||||||
|
"unlocker with ID missing not found; the vault 'default' has other "+
|
||||||
|
"unlockers: run 'secret unlocker select' with the ID of one of them "+
|
||||||
|
"to make it the current unlocker")
|
||||||
|
|
||||||
|
require.NoError(t, c.UnlockerSelect(filepath.Base(unlockerDir)))
|
||||||
|
assert.Equal(t, "value", getSecret(t, c, "x"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMissingCurrentUnlockerNamesMnemonic checks the error of `secret get`
|
||||||
|
// when the unlocker that current-unlocker names does not exist and the
|
||||||
|
// vault's only other unlocker is the directory "odd", whose metadata names a
|
||||||
|
// type that `secret unlocker select` refuses: the error ends with the advice
|
||||||
|
// that the mnemonic still opens the vault, not with advice to select another
|
||||||
|
// unlocker. The test then follows that advice.
|
||||||
|
func TestMissingCurrentUnlockerNamesMnemonic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := newTwoVaultFs(t)
|
||||||
|
unlockerDir, c := newUnlockTestCLI(t, fs)
|
||||||
|
|
||||||
|
require.NoError(t, fs.RemoveAll(unlockerDir))
|
||||||
|
require.NoError(t, afero.WriteFile(fs,
|
||||||
|
filepath.Join(unlockTestVaultDir, "unlockers.d", "odd",
|
||||||
|
"unlocker-metadata.json"),
|
||||||
|
[]byte(`{"type": "odd"}`), secret.FilePerms))
|
||||||
|
|
||||||
|
err := c.GetSecret(discardCmd(), "x")
|
||||||
|
|
||||||
|
var cause *os.PathError
|
||||||
|
|
||||||
|
require.ErrorAs(t, err, &cause)
|
||||||
|
require.NotErrorIs(t, err, vault.ErrUnlockerNotFound)
|
||||||
|
require.EqualError(t, err, "failed to unlock vault: "+
|
||||||
|
"failed to get long-term key: failed to get current unlocker: "+
|
||||||
|
"failed to read unlocker metadata: "+cause.Error()+mnemonicAdvice)
|
||||||
|
|
||||||
|
c.Mnemonic = testMnemonicBuffer(t)
|
||||||
|
require.NoError(t, c.UnlockersAdd("passphrase", discardCmd()))
|
||||||
|
|
||||||
|
c.Mnemonic = nil
|
||||||
|
assert.Equal(t, "value", getSecret(t, c, "x"))
|
||||||
|
}
|
||||||
|
|
||||||
// TestPassphraseNotReadNamesNoMnemonic runs `secret get x` on the built
|
// TestPassphraseNotReadNamesNoMnemonic runs `secret get x` on the built
|
||||||
// binary without SB_UNLOCK_PASSPHRASE and with a stdin that is not a
|
// binary without SB_UNLOCK_PASSPHRASE and in a new session, which has no
|
||||||
// terminal, so the passphrase cannot be read. The unlocker was not tried,
|
// terminal, so the passphrase cannot be read. The unlocker was not tried,
|
||||||
// and adding one would need a passphrase read the same way, so the error
|
// and adding one would need a passphrase read the same way, so the error
|
||||||
// is the cause alone, without the advice to use the mnemonic.
|
// is the cause alone, without the advice to use the mnemonic.
|
||||||
@@ -254,15 +341,16 @@ func TestPassphraseNotReadNamesNoMnemonic(t *testing.T) {
|
|||||||
"PATH=" + os.Getenv("PATH"),
|
"PATH=" + os.Getenv("PATH"),
|
||||||
"HOME=" + os.Getenv("HOME"),
|
"HOME=" + os.Getenv("HOME"),
|
||||||
}
|
}
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
|
|
||||||
assert.Equal(t, "Error: failed to unlock vault: "+
|
assert.Equal(t, "Error: failed to unlock vault: "+
|
||||||
"failed to get long-term key: failed to get unlocker identity: "+
|
"failed to get long-term key: failed to get unlocker identity: "+
|
||||||
"failed to read passphrase: stdin is not a terminal (piped input or "+
|
"failed to read passphrase: there is no terminal to ask on. Please "+
|
||||||
"script). Please set the SB_UNLOCK_PASSPHRASE environment variable or "+
|
"set the SB_UNLOCK_PASSPHRASE environment variable or run "+
|
||||||
"run interactively\n", string(output))
|
"interactively\n", string(output))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCryptoUnlockFailureNamesMnemonic checks that `secret encrypt` and
|
// TestCryptoUnlockFailureNamesMnemonic checks that `secret encrypt` and
|
||||||
|
|||||||
+20
-10
@@ -222,8 +222,11 @@ func newUnlockerRemoveCmd() *cobra.Command {
|
|||||||
Long: `Remove an unlocker from the current vault. Asks for ` +
|
Long: `Remove an unlocker from the current vault. Asks for ` +
|
||||||
`confirmation first, saying whether it is the vault's last ` +
|
`confirmation first, saying whether it is the vault's last ` +
|
||||||
`unlocker; when stdin is not a terminal, fails unless --force ` +
|
`unlocker; when stdin is not a terminal, fails unless --force ` +
|
||||||
`is given. Warning: Without unlockers and without your ` +
|
`is given. Removing the current unlocker makes another of the ` +
|
||||||
`mnemonic, vault data will be permanently inaccessible.`,
|
`vault's unlockers current, if it has one that ` +
|
||||||
|
`'secret unlocker select' accepts. Warning: Without ` +
|
||||||
|
`unlockers and without your mnemonic, vault data will be ` +
|
||||||
|
`permanently inaccessible.`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
|
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -323,22 +326,25 @@ func (cli *Instance) printUnlockersJSON(
|
|||||||
return fmt.Errorf("failed to marshal JSON: %w", err)
|
return fmt.Errorf("failed to marshal JSON: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cli.cmd.Println(string(jsonBytes))
|
_, _ = fmt.Fprintln(cli.cmd.OutOrStdout(), string(jsonBytes))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// printUnlockersTable prints unlockers in a formatted table
|
// printUnlockersTable prints unlockers in a formatted table
|
||||||
func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
|
func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
|
||||||
|
out := cli.cmd.OutOrStdout()
|
||||||
|
|
||||||
if len(unlockers) == 0 {
|
if len(unlockers) == 0 {
|
||||||
cli.cmd.Println("No unlockers found in current vault.")
|
_, _ = fmt.Fprintln(out, "No unlockers found in current vault.")
|
||||||
cli.cmd.Println("Run 'secret unlocker add passphrase' to create one.")
|
_, _ = fmt.Fprintln(out, "Run 'secret unlocker add passphrase' to create one.")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cli.cmd.Printf(" %-40s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
|
_, _ = fmt.Fprintf(out, " %-40s %-12s %-20s %s\n",
|
||||||
cli.cmd.Printf(" %-40s %-12s %-20s %s\n",
|
"UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
|
||||||
|
_, _ = fmt.Fprintf(out, " %-40s %-12s %-20s %s\n",
|
||||||
strings.Repeat("-", unlockerIDWidth), strings.Repeat("-", unlockerTypeWidth),
|
strings.Repeat("-", unlockerIDWidth), strings.Repeat("-", unlockerTypeWidth),
|
||||||
strings.Repeat("-", unlockerDateWidth), strings.Repeat("-", unlockerFlagsWidth))
|
strings.Repeat("-", unlockerDateWidth), strings.Repeat("-", unlockerFlagsWidth))
|
||||||
|
|
||||||
@@ -353,7 +359,7 @@ func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
|
|||||||
prefix = "* "
|
prefix = "* "
|
||||||
}
|
}
|
||||||
|
|
||||||
cli.cmd.Printf("%s%-40s %-12s %-20s %s\n",
|
_, _ = fmt.Fprintf(out, "%s%-40s %-12s %-20s %s\n",
|
||||||
prefix,
|
prefix,
|
||||||
unlocker.ID,
|
unlocker.ID,
|
||||||
unlocker.Type,
|
unlocker.Type,
|
||||||
@@ -361,7 +367,7 @@ func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
|
|||||||
flags)
|
flags)
|
||||||
}
|
}
|
||||||
|
|
||||||
cli.cmd.Printf("\nTotal: %d unlocker(s)\n", len(unlockers))
|
_, _ = fmt.Fprintf(out, "\nTotal: %d unlocker(s)\n", len(unlockers))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -612,13 +618,17 @@ func (cli *Instance) removeUnlocker(
|
|||||||
"have your mnemonic phrase to access this vault again!")
|
"have your mnemonic phrase to access this vault again!")
|
||||||
}
|
}
|
||||||
|
|
||||||
err := found.vlt.RemoveUnlocker(unlockerID)
|
newCurrentID, err := found.vlt.RemoveUnlocker(unlockerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Printf("Removed unlocker '%s'\n", unlockerID)
|
cmd.Printf("Removed unlocker '%s'\n", unlockerID)
|
||||||
|
|
||||||
|
if newCurrentID != "" {
|
||||||
|
cmd.Printf("Switched current unlocker to '%s'\n", newCurrentID)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
//nolint:testpackage // white-box test of unexported internals
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/secret/internal/secret"
|
||||||
|
"sneak.berlin/go/secret/internal/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestUnlockerRemoveCurrent is a regression test for
|
||||||
|
// https://git.eeqj.de/sneak/secret/issues/137. A vault has a passphrase
|
||||||
|
// unlocker and a PGP unlocker, the current one. Removing the PGP unlocker
|
||||||
|
// makes the passphrase unlocker current and says so, and the vault then
|
||||||
|
// opens through it. Removing the passphrase unlocker too removes
|
||||||
|
// current-unlocker.
|
||||||
|
//
|
||||||
|
// The second case also gives the vault the unlocker directory "odd", whose
|
||||||
|
// metadata names a type that `secret unlocker select` refuses, and which
|
||||||
|
// sorts before the passphrase unlocker: neither removal makes it current.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
|
||||||
|
func TestUnlockerRemoveCurrent(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
withOdd bool
|
||||||
|
}{
|
||||||
|
{name: "two unlockers", withOdd: false},
|
||||||
|
{name: "and the directory odd", withOdd: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
newTestGPGKey(t)
|
||||||
|
|
||||||
|
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||||
|
t.Cleanup(passphrase.Destroy)
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
vlt, err := vault.CreateVault(fs, listTestStateDir,
|
||||||
|
listTestVaultName, testMnemonicBuffer(t), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphrase)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
if tt.withOdd {
|
||||||
|
require.NoError(t, afero.WriteFile(fs,
|
||||||
|
filepath.Join(testVaultDir(listTestVaultName), "unlockers.d",
|
||||||
|
"odd", "unlocker-metadata.json"),
|
||||||
|
[]byte(`{"type": "odd"}`), secret.FilePerms))
|
||||||
|
}
|
||||||
|
|
||||||
|
instance, cmd := newTestInstance(fs)
|
||||||
|
instance.UnlockPassphrase = passphrase
|
||||||
|
|
||||||
|
cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
|
||||||
|
require.NoError(t, instance.UnlockersAdd(unlockerTypePGP, cmd))
|
||||||
|
|
||||||
|
pgpUnlocker, err := vlt.GetCurrentUnlocker()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, unlockerTypePGP, pgpUnlocker.GetType())
|
||||||
|
|
||||||
|
var output bytes.Buffer
|
||||||
|
|
||||||
|
cmd.SetOut(&output)
|
||||||
|
require.NoError(t,
|
||||||
|
instance.UnlockersRemove(pgpUnlocker.GetID(), true, cmd))
|
||||||
|
assert.Contains(t, output.String(),
|
||||||
|
"Switched current unlocker to '"+passphraseUnlocker.GetID()+"'")
|
||||||
|
|
||||||
|
reopened := vault.NewVault(fs, listTestStateDir, listTestVaultName)
|
||||||
|
reopened.UnlockPassphrase = passphrase
|
||||||
|
|
||||||
|
_, err = reopened.GetOrDeriveLongTermKey()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t,
|
||||||
|
instance.UnlockersRemove(passphraseUnlocker.GetID(), true, cmd))
|
||||||
|
|
||||||
|
exists, err := afero.Exists(fs, filepath.Join(
|
||||||
|
testVaultDir(listTestVaultName), "current-unlocker"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, exists,
|
||||||
|
"current-unlocker is left after the passphrase unlocker")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -186,6 +186,8 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
out := cmd.OutOrStdout()
|
||||||
|
|
||||||
if jsonOutput { //nolint:nestif // Separate JSON and text output formatting logic
|
if jsonOutput { //nolint:nestif // Separate JSON and text output formatting logic
|
||||||
// Get current vault name for context
|
// Get current vault name for context
|
||||||
currentVault := ""
|
currentVault := ""
|
||||||
@@ -205,13 +207,13 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Println(string(jsonBytes))
|
_, _ = fmt.Fprintln(out, string(jsonBytes))
|
||||||
} else {
|
} else {
|
||||||
// Text output
|
// Text output
|
||||||
cmd.Println("Available vaults:")
|
_, _ = fmt.Fprintln(out, "Available vaults:")
|
||||||
|
|
||||||
if len(vaults) == 0 {
|
if len(vaults) == 0 {
|
||||||
cmd.Println(" (none)")
|
_, _ = fmt.Fprintln(out, " (none)")
|
||||||
} else {
|
} else {
|
||||||
// Try to get current vault for marking
|
// Try to get current vault for marking
|
||||||
currentVault := ""
|
currentVault := ""
|
||||||
@@ -223,9 +225,9 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
|||||||
|
|
||||||
for _, vaultName := range vaults {
|
for _, vaultName := range vaults {
|
||||||
if vaultName == currentVault {
|
if vaultName == currentVault {
|
||||||
cmd.Printf(" %s (current)\n", vaultName)
|
_, _ = fmt.Fprintf(out, " %s (current)\n", vaultName)
|
||||||
} else {
|
} else {
|
||||||
cmd.Printf(" %s\n", vaultName)
|
_, _ = fmt.Fprintf(out, " %s\n", vaultName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(versions) == 0 {
|
if len(versions) == 0 {
|
||||||
cmd.Println("No versions found")
|
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "No versions found")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
// tempNamePart is in the name of every temporary file WriteFileAtomic makes,
|
// tempNamePart starts the name of every temporary file WriteFileAtomicFunc
|
||||||
// ".NAME.tmp-123", and every temporary directory TempDirFor makes, ".tmp-123".
|
// makes and every temporary directory TempDirFor makes, ".tmp-123".
|
||||||
const tempNamePart = ".tmp-"
|
const tempNamePart = ".tmp-"
|
||||||
|
|
||||||
// WriteFileAtomic replaces the file at path with data so that a reader, or
|
// WriteFileAtomic replaces the file at path with data so that a reader, or
|
||||||
@@ -21,17 +21,38 @@ const tempNamePart = ".tmp-"
|
|||||||
// within one filesystem), is synced to disk, and is renamed over path. The
|
// within one filesystem), is synced to disk, and is renamed over path. The
|
||||||
// temporary file is removed if any step fails.
|
// temporary file is removed if any step fails.
|
||||||
func WriteFileAtomic(fs afero.Fs, path string, data []byte) error {
|
func WriteFileAtomic(fs afero.Fs, path string, data []byte) error {
|
||||||
tmp, err := afero.TempFile(fs, filepath.Dir(path),
|
return WriteFileAtomicFunc(fs, path, func(file afero.File) error {
|
||||||
"."+filepath.Base(path)+tempNamePart+"*")
|
_, err := file.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFileAtomicFunc is WriteFileAtomic for content that write streams
|
||||||
|
// into the temporary file, which it is given, instead of holding it in
|
||||||
|
// memory. An error from write is returned as it is. The temporary file's
|
||||||
|
// name leaves out path's, which may already be as long as a name can be.
|
||||||
|
func WriteFileAtomicFunc(
|
||||||
|
fs afero.Fs, path string, write func(file afero.File) error,
|
||||||
|
) error {
|
||||||
|
tmp, err := afero.TempFile(fs, filepath.Dir(path), tempNamePart)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create temporary file for %s: %w", path, err)
|
return fmt.Errorf("failed to create temporary file for %s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tmp.Write(data)
|
err = write(tmp)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
err = tmp.Sync()
|
_ = tmp.Close()
|
||||||
|
_ = fs.Remove(tmp.Name())
|
||||||
|
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = tmp.Sync()
|
||||||
|
|
||||||
closeErr := tmp.Close()
|
closeErr := tmp.Close()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = closeErr
|
err = closeErr
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ const (
|
|||||||
// EnvGPGKeyID is the environment variable for providing the GPG key ID
|
// EnvGPGKeyID is the environment variable for providing the GPG key ID
|
||||||
EnvGPGKeyID = "SB_GPG_KEY_ID"
|
EnvGPGKeyID = "SB_GPG_KEY_ID"
|
||||||
|
|
||||||
|
// TerminalPath opens the controlling terminal of the process that opens
|
||||||
|
// it, where the passphrase and mnemonic prompts ask.
|
||||||
|
TerminalPath = "/dev/tty"
|
||||||
|
|
||||||
// UnlockerTimeFormat is the layout of the time, in UTC, in the name of a
|
// UnlockerTimeFormat is the layout of the time, in UTC, in the name of a
|
||||||
// new unlocker's directory. It runs to the nanosecond, so that every new
|
// new unlocker's directory. It runs to the nanosecond, so that every new
|
||||||
// unlocker, even one added right after another, gets a directory of its
|
// unlocker, even one added right after another, gets a directory of its
|
||||||
|
|||||||
+17
-26
@@ -6,7 +6,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"syscall"
|
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
@@ -16,11 +15,8 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
errNilPassphraseBuffer = errors.New("passphrase buffer is nil")
|
errNilPassphraseBuffer = errors.New("passphrase buffer is nil")
|
||||||
errStdinNotTerminal = errors.New(
|
errNoTerminal = errors.New("there is no terminal to ask on")
|
||||||
"stdin is not a terminal (piped input or script)")
|
errNothingEntered = errors.New("nothing was entered")
|
||||||
errStderrNotTerminal = errors.New(
|
|
||||||
"stderr is not a terminal (running in non-interactive mode)")
|
|
||||||
errNothingEntered = errors.New("nothing was entered")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrMnemonicNotRead is wrapped in every error of ReadMnemonic: there is no
|
// ErrMnemonicNotRead is wrapped in every error of ReadMnemonic: there is no
|
||||||
@@ -193,38 +189,33 @@ func ReadMnemonic(prompt string) (*memguard.LockedBuffer, error) {
|
|||||||
return readFromTerminal(prompt, ErrMnemonicNotRead, EnvMnemonic)
|
return readFromTerminal(prompt, ErrMnemonicNotRead, EnvMnemonic)
|
||||||
}
|
}
|
||||||
|
|
||||||
// readFromTerminal reads input from the terminal without echoing it. Every
|
// readFromTerminal asks on the controlling terminal and reads the answer
|
||||||
// error it returns wraps notRead; without a terminal, the error says to set
|
// there without echoing it, whatever stdin is: in
|
||||||
// envVar instead.
|
// `echo value | secret add name --force`, stdin is a pipe. Every error it
|
||||||
|
// returns wraps notRead; without a terminal, the error says to set envVar
|
||||||
|
// instead.
|
||||||
func readFromTerminal(
|
func readFromTerminal(
|
||||||
prompt string, notRead error, envVar string,
|
prompt string, notRead error, envVar string,
|
||||||
) (*memguard.LockedBuffer, error) {
|
) (*memguard.LockedBuffer, error) {
|
||||||
// Check if stdin is a terminal
|
terminal, err := os.OpenFile(TerminalPath, os.O_RDWR, 0)
|
||||||
if !term.IsTerminal(syscall.Stdin) {
|
if err != nil {
|
||||||
// Not a terminal - never read secrets from piped input
|
Debug("Failed to open the controlling terminal", "error", err)
|
||||||
// for security reasons
|
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"%w: %w. Please set the %s environment variable or run interactively",
|
"%w: %w. Please set the %s environment variable or run interactively",
|
||||||
notRead, errStdinNotTerminal, envVar)
|
notRead, errNoTerminal, envVar)
|
||||||
}
|
}
|
||||||
|
defer func() { _ = terminal.Close() }()
|
||||||
|
|
||||||
// stdin is a terminal, check if stderr is also a terminal for
|
_, _ = fmt.Fprint(terminal, prompt)
|
||||||
// interactive prompting
|
|
||||||
if !term.IsTerminal(syscall.Stderr) {
|
|
||||||
return nil, fmt.Errorf("%w: %w. Please set the %s environment variable",
|
|
||||||
notRead, errStderrNotTerminal, envVar)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both stdin and stderr are terminals - use secure password reading
|
input, err := term.ReadPassword(int(terminal.Fd()))
|
||||||
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
|
|
||||||
|
|
||||||
input, err := term.ReadPassword(syscall.Stdin)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %w", notRead, err)
|
return nil, fmt.Errorf("%w: %w", notRead, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print newline to stderr since ReadPassword doesn't echo
|
// The newline typed after the input, which ReadPassword did not echo
|
||||||
fmt.Fprintln(os.Stderr)
|
_, _ = fmt.Fprintln(terminal)
|
||||||
|
|
||||||
if len(input) == 0 {
|
if len(input) == 0 {
|
||||||
return nil, fmt.Errorf("%w: %w", notRead, errNothingEntered)
|
return nil, fmt.Errorf("%w: %w", notRead, errNothingEntered)
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import (
|
|||||||
// empty. A passphrase unlocker that fails with it was not tried.
|
// empty. A passphrase unlocker that fails with it was not tried.
|
||||||
var ErrPassphraseNotRead = errors.New("failed to read passphrase")
|
var ErrPassphraseNotRead = errors.New("failed to read passphrase")
|
||||||
|
|
||||||
|
// ErrWrongPassphrase is wrapped in the error of GetIdentity when the
|
||||||
|
// passphrase does not decrypt the unlocker's private key.
|
||||||
|
var ErrWrongPassphrase = errors.New("wrong passphrase")
|
||||||
|
|
||||||
// PassphraseUnlocker represents a passphrase-protected unlocker
|
// PassphraseUnlocker represents a passphrase-protected unlocker
|
||||||
type PassphraseUnlocker struct {
|
type PassphraseUnlocker struct {
|
||||||
Directory string
|
Directory string
|
||||||
@@ -73,6 +77,12 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
Debug("Failed to decrypt unlocker private key",
|
Debug("Failed to decrypt unlocker private key",
|
||||||
"error", err, "unlocker_id", p.GetID())
|
"error", err, "unlocker_id", p.GetID())
|
||||||
|
|
||||||
|
// age says that no identity matched when the passphrase is wrong
|
||||||
|
var noMatch *age.NoIdentityMatchError
|
||||||
|
if errors.As(err, &noMatch) {
|
||||||
|
return nil, fmt.Errorf("%w for unlocker %s", ErrWrongPassphrase, p.GetID())
|
||||||
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to decrypt unlocker private key: %w", err)
|
return nil, fmt.Errorf("failed to decrypt unlocker private key: %w", err)
|
||||||
}
|
}
|
||||||
defer privKeyBuffer.Destroy()
|
defer privKeyBuffer.Destroy()
|
||||||
|
|||||||
@@ -81,7 +81,9 @@ func TestVaultErrors(t *testing.T) {
|
|||||||
return vlt.CopySecretAllVersions(vlt, versionless, "copy", false)
|
return vlt.CopySecretAllVersions(vlt, versionless, "copy", false)
|
||||||
}, vault.ErrNoVersions},
|
}, vault.ErrNoVersions},
|
||||||
{"remove a missing unlocker", func(vlt *vault.Vault) error {
|
{"remove a missing unlocker", func(vlt *vault.Vault) error {
|
||||||
return vlt.RemoveUnlocker(missingName)
|
_, err := vlt.RemoveUnlocker(missingName)
|
||||||
|
|
||||||
|
return err
|
||||||
}, vault.ErrUnlockerNotFound},
|
}, vault.ErrUnlockerNotFound},
|
||||||
{"select a missing unlocker", func(vlt *vault.Vault) error {
|
{"select a missing unlocker", func(vlt *vault.Vault) error {
|
||||||
return vlt.SelectUnlocker(missingName)
|
return vlt.SelectUnlocker(missingName)
|
||||||
|
|||||||
+39
-12
@@ -219,6 +219,15 @@ func (v *Vault) addVersion(
|
|||||||
name, secretDir string, value *memguard.LockedBuffer,
|
name, secretDir string, value *memguard.LockedBuffer,
|
||||||
previousVersion *secret.Version,
|
previousVersion *secret.Version,
|
||||||
) error {
|
) error {
|
||||||
|
// Unlock the vault before writing anything, so that a wrong passphrase
|
||||||
|
// or mnemonic leaves the secret as it was
|
||||||
|
err := v.loadPreviousVersionMetadata(previousVersion)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taken after the unlock, which can wait for the user to type the
|
||||||
|
// passphrase, so that the new version starts when it is created
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
// Create the new version and save the encrypted value
|
// Create the new version and save the encrypted value
|
||||||
@@ -632,8 +641,35 @@ func (v *Vault) checkExistingSecret(
|
|||||||
return true, previousVersion, nil
|
return true, previousVersion, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadPreviousVersionMetadata unlocks the vault and loads the metadata of
|
||||||
|
// the version being superseded. It is a no-op when previousVersion is nil.
|
||||||
|
func (v *Vault) loadPreviousVersionMetadata(
|
||||||
|
previousVersion *secret.Version,
|
||||||
|
) error {
|
||||||
|
if previousVersion == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to get long-term key for previous version", "error", err)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = previousVersion.LoadMetadata(ltIdentity)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to load previous version metadata", "error", err)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to load previous version metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// updatePreviousVersion sets the notAfter timestamp on the version being
|
// updatePreviousVersion sets the notAfter timestamp on the version being
|
||||||
// superseded. It is a no-op when previousVersion is nil.
|
// superseded, whose metadata loadPreviousVersionMetadata has loaded. It is
|
||||||
|
// a no-op when previousVersion is nil.
|
||||||
func (v *Vault) updatePreviousVersion(
|
func (v *Vault) updatePreviousVersion(
|
||||||
previousVersion *secret.Version, now *time.Time,
|
previousVersion *secret.Version, now *time.Time,
|
||||||
) error {
|
) error {
|
||||||
@@ -641,22 +677,13 @@ func (v *Vault) updatePreviousVersion(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get long-term key to decrypt/encrypt metadata
|
// loadPreviousVersionMetadata unlocked the vault, so this returns the
|
||||||
|
// key it keeps in memory
|
||||||
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get long-term key for metadata update", "error", err)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to get long-term key: %w", err)
|
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load previous version metadata
|
|
||||||
err = previousVersion.LoadMetadata(ltIdentity)
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to load previous version metadata", "error", err)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to load previous version metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update notAfter timestamp
|
// Update notAfter timestamp
|
||||||
previousVersion.Metadata.NotAfter = now
|
previousVersion.Metadata.NotAfter = now
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
// version
|
// version
|
||||||
// - TestVaultVersionTimestamps: Tests timestamp logic (notBefore/notAfter)
|
// - TestVaultVersionTimestamps: Tests timestamp logic (notBefore/notAfter)
|
||||||
// across versions
|
// across versions
|
||||||
|
// - TestVaultVersionTimestampsAfterUnlock: Tests that those timestamps are
|
||||||
|
// taken after AddSecret unlocks the vault
|
||||||
// - TestVaultGetNonExistentVersion: Tests error handling for invalid versions
|
// - TestVaultGetNonExistentVersion: Tests error handling for invalid versions
|
||||||
// - TestUpdateVersionMetadata: Tests metadata update functionality
|
// - TestUpdateVersionMetadata: Tests metadata update functionality
|
||||||
//
|
//
|
||||||
@@ -295,6 +297,70 @@ func TestVaultVersionTimestamps(t *testing.T) {
|
|||||||
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
|
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// longTermKeyReadFs records when longterm.age, the last file an unlock
|
||||||
|
// reads, was last opened.
|
||||||
|
type longTermKeyReadFs struct {
|
||||||
|
afero.Fs
|
||||||
|
|
||||||
|
readAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
||||||
|
func (f *longTermKeyReadFs) Open(name string) (afero.File, error) {
|
||||||
|
if filepath.Base(name) == "longterm.age" {
|
||||||
|
f.readAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
return f.Fs.Open(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVaultVersionTimestampsAfterUnlock checks that when AddSecret has to
|
||||||
|
// unlock the vault to replace a version, the new version's notBefore and the
|
||||||
|
// replaced version's notAfter are taken after the unlock, which can wait for
|
||||||
|
// the user to type the passphrase.
|
||||||
|
func TestVaultVersionTimestampsAfterUnlock(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := &longTermKeyReadFs{Fs: afero.NewMemMapFs()}
|
||||||
|
|
||||||
|
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
||||||
|
defer mnemonic.Destroy()
|
||||||
|
|
||||||
|
passphrase := memguard.NewBufferFromBytes([]byte("test-passphrase"))
|
||||||
|
defer passphrase.Destroy()
|
||||||
|
|
||||||
|
created, err := CreateVault(fs, testStateDir, "test", mnemonic, passphrase)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
addTestSecretToVault(t, created, testSecretPath, []byte("version-1"), false)
|
||||||
|
|
||||||
|
// A new instance is locked; AddSecret unlocks it with the passphrase
|
||||||
|
vault := NewVault(fs, testStateDir, "test")
|
||||||
|
vault.SetUnlockPassphrase(passphrase)
|
||||||
|
|
||||||
|
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
|
||||||
|
require.False(t, fs.readAt.IsZero(), "AddSecret did not unlock the vault")
|
||||||
|
|
||||||
|
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
vaultDir, _ := vault.GetDirectory()
|
||||||
|
secretDir := filepath.Join(vaultDir, "secrets.d", "test%secret")
|
||||||
|
|
||||||
|
versions, err := secret.ListVersions(fs, secretDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, versions, 2)
|
||||||
|
|
||||||
|
newVersion := secret.NewVersion(vault, testSecretPath, versions[0])
|
||||||
|
require.NoError(t, newVersion.LoadMetadata(ltIdentity))
|
||||||
|
|
||||||
|
replacedVersion := secret.NewVersion(vault, testSecretPath, versions[1])
|
||||||
|
require.NoError(t, replacedVersion.LoadMetadata(ltIdentity))
|
||||||
|
|
||||||
|
assert.False(t, newVersion.Metadata.NotBefore.Before(fs.readAt))
|
||||||
|
assert.False(t, replacedVersion.Metadata.NotAfter.Before(fs.readAt))
|
||||||
|
}
|
||||||
|
|
||||||
func TestVaultGetNonExistentVersion(t *testing.T) {
|
func TestVaultGetNonExistentVersion(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+126
-9
@@ -5,8 +5,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ import (
|
|||||||
// Unlocker metadata type strings.
|
// Unlocker metadata type strings.
|
||||||
const (
|
const (
|
||||||
unlockerTypePassphrase = "passphrase"
|
unlockerTypePassphrase = "passphrase"
|
||||||
|
unlockerTypePGP = "pgp"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetCurrentUnlocker returns the current unlocker for this vault
|
// GetCurrentUnlocker returns the current unlocker for this vault
|
||||||
@@ -52,6 +55,11 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = v.checkCurrentUnlockerExists(unlockerDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
secret.DebugWith("Resolved unlocker directory",
|
secret.DebugWith("Resolved unlocker directory",
|
||||||
slog.String("unlocker_dir", unlockerDir),
|
slog.String("unlocker_dir", unlockerDir),
|
||||||
slog.String("vault_name", v.Name),
|
slog.String("vault_name", v.Name),
|
||||||
@@ -74,7 +82,7 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
|||||||
passphraseUnlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
passphraseUnlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
||||||
passphraseUnlocker.Passphrase = v.UnlockPassphrase
|
passphraseUnlocker.Passphrase = v.UnlockPassphrase
|
||||||
unlocker = passphraseUnlocker
|
unlocker = passphraseUnlocker
|
||||||
case "pgp":
|
case unlockerTypePGP:
|
||||||
secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type)
|
secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type)
|
||||||
|
|
||||||
unlocker = secret.NewPGPUnlocker(v.fs, unlockerDir, metadata)
|
unlocker = secret.NewPGPUnlocker(v.fs, unlockerDir, metadata)
|
||||||
@@ -119,6 +127,37 @@ func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, er
|
|||||||
return absolutePath, nil
|
return absolutePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkCurrentUnlockerExists fails with ErrUnlockerNotFound, saying to
|
||||||
|
// select another unlocker, when unlockerDir, the unlocker directory that
|
||||||
|
// current-unlocker names, does not exist and the vault has another unlocker
|
||||||
|
// that SelectUnlocker accepts. When it has none, it returns nil: reading the
|
||||||
|
// missing unlocker then fails, with the advice that the mnemonic still opens
|
||||||
|
// the vault.
|
||||||
|
func (v *Vault) checkCurrentUnlockerExists(unlockerDir string) error {
|
||||||
|
exists, err := afero.DirExists(v.fs, unlockerDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check for the current unlocker: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
otherID, err := v.firstOtherUnlockerID(filepath.Base(unlockerDir))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if otherID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("unlocker with ID %s %w; the vault '%s' has other "+
|
||||||
|
"unlockers: run %s with the ID of one of them to make it the current "+
|
||||||
|
"unlocker", filepath.Base(unlockerDir), ErrUnlockerNotFound, v.Name,
|
||||||
|
v.withVaultSelect("'secret unlocker select'"))
|
||||||
|
}
|
||||||
|
|
||||||
// findUnlockerByID finds an unlocker by its ID and returns the unlocker
|
// findUnlockerByID finds an unlocker by its ID and returns the unlocker
|
||||||
// instance and its directory path. A directory that ListUnlockers skips is
|
// instance and its directory path. A directory that ListUnlockers skips is
|
||||||
// skipped here too, with the same warning. Such a directory has no ID: if
|
// skipped here too, with the same warning. Such a directory has no ID: if
|
||||||
@@ -159,7 +198,7 @@ func (v *Vault) findUnlockerByID(
|
|||||||
switch metadata.Type {
|
switch metadata.Type {
|
||||||
case unlockerTypePassphrase:
|
case unlockerTypePassphrase:
|
||||||
tempUnlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, metadata)
|
tempUnlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, metadata)
|
||||||
case "pgp":
|
case unlockerTypePGP:
|
||||||
tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, metadata)
|
tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, metadata)
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
@@ -282,10 +321,15 @@ func (v *Vault) HasUnlocker(unlockerID string) (bool, error) {
|
|||||||
// RemoveUnlocker removes an unlocker from this vault. An unlocker
|
// RemoveUnlocker removes an unlocker from this vault. An unlocker
|
||||||
// directory that ListUnlockers skips is removed by its directory name; its
|
// directory that ListUnlockers skips is removed by its directory name; its
|
||||||
// type is unknown, so only the directory is removed.
|
// type is unknown, so only the directory is removed.
|
||||||
func (v *Vault) RemoveUnlocker(unlockerID string) error {
|
//
|
||||||
|
// It returns the ID of the unlocker it made current, or "" when it made none
|
||||||
|
// current. If the removed unlocker is the current one, the unlocker
|
||||||
|
// firstOtherUnlockerID returns is made current before the removal; with
|
||||||
|
// none, current-unlocker is removed after it.
|
||||||
|
func (v *Vault) RemoveUnlocker(unlockerID string) (string, error) {
|
||||||
vaultDir, err := v.GetDirectory()
|
vaultDir, err := v.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the unlocker directory and create the unlocker instance
|
// Find the unlocker directory and create the unlocker instance
|
||||||
@@ -294,19 +338,92 @@ func (v *Vault) RemoveUnlocker(unlockerID string) error {
|
|||||||
// Find the unlocker by ID
|
// Find the unlocker by ID
|
||||||
unlocker, unlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID)
|
unlocker, unlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if unlockerDir == "" {
|
if unlockerDir == "" {
|
||||||
return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
|
return "", fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
||||||
|
|
||||||
|
currentDir, err := v.resolveUnlockerDirectory(currentUnlockerPath)
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
isCurrent := currentDir == unlockerDir
|
||||||
|
newCurrentID := ""
|
||||||
|
|
||||||
|
if isCurrent {
|
||||||
|
newCurrentID, err = v.selectOtherUnlocker(unlockerID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if unlocker == nil {
|
if unlocker == nil {
|
||||||
return secret.RemoveDirAtomic(v.fs, unlockerDir)
|
err = secret.RemoveDirAtomic(v.fs, unlockerDir)
|
||||||
|
} else {
|
||||||
|
err = unlocker.Remove()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the unlocker's Remove method
|
if err != nil {
|
||||||
return unlocker.Remove()
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if isCurrent && newCurrentID == "" {
|
||||||
|
err = v.fs.Remove(currentUnlockerPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to remove current-unlocker file: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newCurrentID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectOtherUnlocker makes the unlocker firstOtherUnlockerID returns for
|
||||||
|
// unlockerID the current unlocker and returns its ID. It returns "" when
|
||||||
|
// there is no such unlocker.
|
||||||
|
func (v *Vault) selectOtherUnlocker(unlockerID string) (string, error) {
|
||||||
|
otherID, err := v.firstOtherUnlockerID(unlockerID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if otherID == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = v.SelectUnlocker(otherID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return otherID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstOtherUnlockerID returns the ID of the vault's first unlocker other
|
||||||
|
// than unlockerID, in ID order, the order `secret unlocker list` shows, that
|
||||||
|
// SelectUnlocker accepts, or "" when there is none. SelectUnlocker refuses
|
||||||
|
// an unlocker whose metadata names a type other than passphrase or pgp,
|
||||||
|
// which ListUnlockers still returns.
|
||||||
|
func (v *Vault) firstOtherUnlockerID(unlockerID string) (string, error) {
|
||||||
|
unlockers, err := v.ListUnlockers()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range slices.Sorted(maps.Keys(unlockers)) {
|
||||||
|
unlockerType := unlockers[id].Type
|
||||||
|
|
||||||
|
if id != unlockerID && (unlockerType == unlockerTypePassphrase ||
|
||||||
|
unlockerType == unlockerTypePGP) {
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SelectUnlocker selects an unlocker as current for this vault
|
// SelectUnlocker selects an unlocker as current for this vault
|
||||||
|
|||||||
+22
-11
@@ -291,9 +291,14 @@ func (v *Vault) unlockLongTermKey(
|
|||||||
// without a mnemonic records none, and without its metadata the key cannot
|
// without a mnemonic records none, and without its metadata the key cannot
|
||||||
// be derived. It is not added when the passphrase could not be read: the
|
// be derived. It is not added when the passphrase could not be read: the
|
||||||
// unlocker was not tried, and adding one would need a passphrase read the
|
// unlocker was not tried, and adding one would need a passphrase read the
|
||||||
// same way.
|
// same way. Nor is it added when the passphrase is wrong: the unlocker still
|
||||||
|
// opens the vault with the right one. Nor is it added when the current
|
||||||
|
// unlocker does not exist and the vault has others: the error already says
|
||||||
|
// to select one of them.
|
||||||
func (v *Vault) withMnemonicAdvice(err error) error {
|
func (v *Vault) withMnemonicAdvice(err error) error {
|
||||||
if errors.Is(err, secret.ErrPassphraseNotRead) {
|
if errors.Is(err, secret.ErrPassphraseNotRead) ||
|
||||||
|
errors.Is(err, secret.ErrWrongPassphrase) ||
|
||||||
|
errors.Is(err, ErrUnlockerNotFound) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,15 +309,21 @@ func (v *Vault) withMnemonicAdvice(err error) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 'secret unlocker add' acts on the current vault only.
|
|
||||||
steps := "'secret unlocker add passphrase'"
|
|
||||||
|
|
||||||
current, currentErr := GetCurrentVault(v.fs, v.stateDir)
|
|
||||||
if currentErr != nil || current.Name != v.Name {
|
|
||||||
steps = fmt.Sprintf("'secret vault select %s', then %s", v.Name, steps)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("%w; the vault '%s' still opens with its mnemonic: run "+
|
return fmt.Errorf("%w; the vault '%s' still opens with its mnemonic: run "+
|
||||||
"%s with %s set to the mnemonic to give it a new unlocker",
|
"%s with %s set to the mnemonic to give it a new unlocker",
|
||||||
err, v.Name, steps, secret.EnvMnemonic)
|
err, v.Name, v.withVaultSelect("'secret unlocker add passphrase'"),
|
||||||
|
secret.EnvMnemonic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// withVaultSelect returns command, a quoted 'secret unlocker' command, for
|
||||||
|
// advice about this vault. Such a command acts on the current vault only,
|
||||||
|
// so when this vault is not the current one, the command that selects it
|
||||||
|
// comes first.
|
||||||
|
func (v *Vault) withVaultSelect(command string) string {
|
||||||
|
current, err := GetCurrentVault(v.fs, v.stateDir)
|
||||||
|
if err != nil || current.Name != v.Name {
|
||||||
|
return fmt.Sprintf("'secret vault select %s', then %s", v.Name, command)
|
||||||
|
}
|
||||||
|
|
||||||
|
return command
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,3 +103,76 @@ func TestAddSecretCleansUpOnFailure(t *testing.T) {
|
|||||||
require.Len(t, entries, 1)
|
require.Len(t, entries, 1)
|
||||||
assert.Equal(t, "pub.age", entries[0].Name())
|
assert.Equal(t, "pub.age", entries[0].Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAddSecretForceWithWrongPassphraseChangesNothing checks that adding a
|
||||||
|
// version to an existing secret, with a passphrase that does not open the
|
||||||
|
// vault, fails and leaves the secret's directory as it was.
|
||||||
|
func TestAddSecretForceWithWrongPassphraseChangesNothing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||||
|
t.Cleanup(passphrase.Destroy)
|
||||||
|
|
||||||
|
created, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||||
|
testMnemonicBuffer(t), passphrase)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
first := memguard.NewBufferFromBytes([]byte("first"))
|
||||||
|
t.Cleanup(first.Destroy)
|
||||||
|
|
||||||
|
require.NoError(t, created.AddSecret(testSecretName, first, false))
|
||||||
|
|
||||||
|
wrong := memguard.NewBufferFromBytes([]byte("wrong passphrase"))
|
||||||
|
t.Cleanup(wrong.Destroy)
|
||||||
|
|
||||||
|
vlt := vault.NewVault(fs, testStateDir, testVaultName)
|
||||||
|
vlt.SetUnlockPassphrase(wrong)
|
||||||
|
|
||||||
|
secretDir := filepath.Join(testStateDir, "vaults.d", testVaultName,
|
||||||
|
"secrets.d", testSecretName)
|
||||||
|
before := dirContents(t, fs, secretDir)
|
||||||
|
|
||||||
|
second := memguard.NewBufferFromBytes([]byte("second"))
|
||||||
|
t.Cleanup(second.Destroy)
|
||||||
|
|
||||||
|
err = vlt.AddSecret(testSecretName, second, true)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, secret.ErrWrongPassphrase)
|
||||||
|
assert.Equal(t, before, dirContents(t, fs, secretDir))
|
||||||
|
}
|
||||||
|
|
||||||
|
// dirContents maps every file under dir to its contents, and every
|
||||||
|
// directory, written with a trailing "/", to "".
|
||||||
|
func dirContents(t *testing.T, fs afero.Fs, dir string) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
contents := map[string]string{}
|
||||||
|
|
||||||
|
err := afero.Walk(fs, dir, func(
|
||||||
|
path string, info os.FileInfo, err error,
|
||||||
|
) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
contents[path+"/"] = ""
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := afero.ReadFile(fs, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
contents[path] = string(content)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return contents
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user