check / check (push) Successful in 1m13s
`secret rm ..` resolved to the vault directory and deleted the whole vault; `secret rm .` and `secret rm ""` deleted every secret. rm, mv and import built paths from the name without checking it, and so did the version commands, encrypt and decrypt. vault.ValidateSecretName wraps the existing name rule and returns ErrInvalidSecretName. Each of those commands calls it on the name as given, both names for a move, before building any path. AddSecret and GetSecretVersion use it too, so the rule has one implementation. The regression test snapshots every file under the state directory of two in-memory vaults and requires it unchanged after each rejected command. Model: opus-5-5
304 lines
7.4 KiB
Go
304 lines
7.4 KiB
Go
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
|
|
"filippo.io/age"
|
|
"git.eeqj.de/sneak/secret/internal/secret"
|
|
"git.eeqj.de/sneak/secret/internal/vault"
|
|
"github.com/awnumar/memguard"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// Sentinel errors for encrypt/decrypt operations
|
|
var (
|
|
errNotAgeSecretKey = errors.New(
|
|
"does not contain a valid age secret key")
|
|
errSecretDoesNotExist = errors.New("does not exist")
|
|
)
|
|
|
|
// newCryptoCmd builds an encrypt/decrypt command with input/output flags
|
|
func newCryptoCmd(
|
|
use, short, long string,
|
|
run func(cli *Instance, secretName, inputFile, outputFile string) error,
|
|
) *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: use,
|
|
Short: short,
|
|
Long: long,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
inputFile, _ := cmd.Flags().GetString("input")
|
|
outputFile, _ := cmd.Flags().GetString("output")
|
|
|
|
cli, err := NewCLIInstance()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
|
}
|
|
|
|
cli.cmd = cmd
|
|
|
|
return run(cli, args[0], inputFile, outputFile)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().StringP("input", "i", "", "Input file (default: stdin)")
|
|
cmd.Flags().StringP("output", "o", "", "Output file (default: stdout)")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func newEncryptCmd() *cobra.Command {
|
|
return newCryptoCmd(
|
|
"encrypt <secret-name>",
|
|
"Encrypt data using an age secret key stored in a secret",
|
|
"Encrypt data using an age secret key. If the secret doesn't "+
|
|
"exist, a new age key is generated and stored.",
|
|
(*Instance).Encrypt,
|
|
)
|
|
}
|
|
|
|
func newDecryptCmd() *cobra.Command {
|
|
return newCryptoCmd(
|
|
"decrypt <secret-name>",
|
|
"Decrypt data using an age secret key stored in a secret",
|
|
"Decrypt data using an age secret key stored in the specified secret.",
|
|
(*Instance).Decrypt,
|
|
)
|
|
}
|
|
|
|
// resolveEncryptionKey returns a secure buffer holding the age secret key
|
|
// for the named secret, generating and storing a new key if the secret
|
|
// does not exist. The caller must destroy the returned buffer.
|
|
func (cli *Instance) resolveEncryptionKey(
|
|
vlt *vault.Vault, secretName string,
|
|
) (*memguard.LockedBuffer, error) {
|
|
// Check if secret exists
|
|
secretObj := secret.NewSecret(vlt, secretName)
|
|
|
|
exists, err := secretObj.Exists()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
|
}
|
|
|
|
if !exists {
|
|
// Secret doesn't exist, generate new age key and store it
|
|
identity, err := age.GenerateX25519Identity()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate age key: %w", err)
|
|
}
|
|
|
|
// Store the generated key directly in a secure buffer
|
|
secureBuffer := memguard.NewBufferFromBytes([]byte(identity.String()))
|
|
|
|
err = vlt.AddSecret(secretName, secureBuffer, false)
|
|
if err != nil {
|
|
secureBuffer.Destroy()
|
|
|
|
return nil, fmt.Errorf("failed to store age key: %w", err)
|
|
}
|
|
|
|
return secureBuffer, nil
|
|
}
|
|
|
|
// Secret exists, get the age secret key from it
|
|
secretBuffer, err := cli.getSecretValue(vlt, secretObj)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get secret value: %w", err)
|
|
}
|
|
|
|
// Validate that it's a valid age secret key
|
|
if !isValidAgeSecretKey(secretBuffer.String()) {
|
|
secretBuffer.Destroy()
|
|
|
|
return nil, fmt.Errorf("secret '%s' %w", secretName, errNotAgeSecretKey)
|
|
}
|
|
|
|
return secretBuffer, nil
|
|
}
|
|
|
|
// Encrypt encrypts data using an age secret key stored in a secret
|
|
func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
|
err := vault.ValidateSecretName(secretName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get current vault
|
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get or create the age secret key for this secret
|
|
keyBuffer, err := cli.resolveEncryptionKey(vlt, secretName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer keyBuffer.Destroy()
|
|
|
|
// Parse the secret key
|
|
identity, err := age.ParseX25519Identity(keyBuffer.String())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse age secret key: %w", err)
|
|
}
|
|
|
|
// Get recipient from identity
|
|
recipient := identity.Recipient()
|
|
|
|
// Set up input reader
|
|
var input io.Reader = os.Stdin
|
|
|
|
if inputFile != "" {
|
|
file, err := cli.fs.Open(inputFile)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open input file: %w", err)
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
input = file
|
|
}
|
|
|
|
// Set up output writer
|
|
output := cli.cmd.OutOrStdout()
|
|
|
|
if outputFile != "" {
|
|
file, err := cli.fs.Create(outputFile)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create output file: %w", err)
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
output = file
|
|
}
|
|
|
|
// Encrypt the data
|
|
encryptor, err := age.Encrypt(output, recipient)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create age encryptor: %w", err)
|
|
}
|
|
|
|
_, err = io.Copy(encryptor, input)
|
|
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
|
|
func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
|
err := vault.ValidateSecretName(secretName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get current vault
|
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Check if secret exists
|
|
secretObj := secret.NewSecret(vlt, secretName)
|
|
|
|
exists, err := secretObj.Exists()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check if secret exists: %w", err)
|
|
}
|
|
|
|
if !exists {
|
|
return fmt.Errorf("secret '%s' %w", secretName, errSecretDoesNotExist)
|
|
}
|
|
|
|
// Get the age secret key from the secret
|
|
secretBuffer, err := cli.getSecretValue(vlt, secretObj)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get secret value: %w", err)
|
|
}
|
|
defer secretBuffer.Destroy()
|
|
|
|
// Validate that it's a valid age secret key
|
|
if !isValidAgeSecretKey(secretBuffer.String()) {
|
|
return fmt.Errorf("secret '%s' %w", secretName, errNotAgeSecretKey)
|
|
}
|
|
|
|
// Parse the age secret key to get the identity
|
|
identity, err := age.ParseX25519Identity(secretBuffer.String())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse age secret key: %w", err)
|
|
}
|
|
|
|
// Set up input reader
|
|
var input io.Reader = os.Stdin
|
|
|
|
if inputFile != "" {
|
|
file, err := cli.fs.Open(inputFile)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open input file: %w", err)
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
input = file
|
|
}
|
|
|
|
// Set up output writer
|
|
output := cli.cmd.OutOrStdout()
|
|
|
|
if outputFile != "" {
|
|
file, err := cli.fs.Create(outputFile)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create output file: %w", err)
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
output = file
|
|
}
|
|
|
|
// Decrypt the data
|
|
decryptor, err := age.Decrypt(input, identity)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create age decryptor: %w", err)
|
|
}
|
|
|
|
_, err = io.Copy(output, decryptor)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to decrypt data: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// isValidAgeSecretKey checks if a string is a valid age secret key by
|
|
// attempting to parse it
|
|
func isValidAgeSecretKey(key string) bool {
|
|
_, err := age.ParseX25519Identity(key)
|
|
|
|
return err == nil
|
|
}
|
|
|
|
// getSecretValue retrieves the value of a secret using the appropriate
|
|
// unlocker
|
|
func (cli *Instance) getSecretValue(
|
|
vlt *vault.Vault, secretObj *secret.Secret,
|
|
) (*memguard.LockedBuffer, error) {
|
|
if os.Getenv(secret.EnvMnemonic) != "" {
|
|
return secretObj.GetValue(nil)
|
|
}
|
|
|
|
unlocker, err := vlt.GetCurrentUnlocker()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
|
}
|
|
|
|
return secretObj.GetValue(unlocker)
|
|
}
|