Files
secret/internal/cli/crypto.go
T
sneak 0f5d884eb2
check / check (push) Successful in 49s
Reject invalid secret names before any command builds a path (closes #33)
`secret rm ..` resolved to the vault directory and deleted the whole
vault; `secret rm .` and `secret rm ""` deleted every secret. rm, mv,
the version commands, encrypt and decrypt built paths from the name
without checking it; import checked it only after reading the source
file.

vault.ValidateSecretName wraps the existing name rule and its error
states the rule. Each of those commands calls it on the name as given,
before building any path; a move checks both names before switching
the current vault. AddSecret, GetSecretVersion and GetSecretObject use
it too.

The regression test copies two in-memory vaults for each rejected
command and requires the exact error and an unchanged state directory.

Model: opus-5-5
2026-10-03 14:01:29 +00:00

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)
}