check / check (push) Failing after 2s
When a vault cannot be opened through its current unlocker, the error now ends by naming the vault, saying that it still opens with its mnemonic, and that 'secret unlocker add passphrase' run with SB_SECRET_MNEMONIC set gives it a new unlocker, after 'secret vault select' when it is not the current vault. Only when the vault metadata records the key the mnemonic derives, and not when the passphrase could not be read. 'secret encrypt' and 'secret decrypt' read the key secret through vault.GetSecret, and Secret.GetValue with its helpers is removed. An unreadable 'current' file's error names 'secret version list' and 'secret version promote'. Causes stay wrapped. Model: opus-5-5
316 lines
8.0 KiB
Go
316 lines
8.0 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
|
|
|
|
destroySecrets := cli.readSecretEnv()
|
|
defer destroySecrets()
|
|
|
|
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,
|
|
)
|
|
}
|
|
|
|
// storeNewEncryptionKey generates an age secret key and stores it as the
|
|
// named secret, holding the state directory lock while it does. It fails
|
|
// with vault.ErrSecretExists if another command stored the secret first.
|
|
// The caller must destroy the returned buffer.
|
|
func (cli *Instance) storeNewEncryptionKey(
|
|
vlt *vault.Vault, secretName string,
|
|
) (*memguard.LockedBuffer, error) {
|
|
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer release()
|
|
|
|
identity, err := age.GenerateX25519Identity()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate age key: %w", err)
|
|
}
|
|
|
|
secureBuffer := secret.IdentityToLockedBuffer(identity)
|
|
|
|
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
|
|
}
|
|
|
|
// 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. Only storing
|
|
// a new key takes the state directory lock, so that reading an existing key
|
|
// works on a read-only state directory and keeps no other command waiting
|
|
// at the passphrase prompt, and Encrypt streams its input and output
|
|
// unlocked.
|
|
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 {
|
|
key, err := cli.storeNewEncryptionKey(vlt, secretName)
|
|
if !errors.Is(err, vault.ErrSecretExists) {
|
|
return key, err
|
|
}
|
|
// Another command stored the key since the check above: read it
|
|
}
|
|
|
|
// Secret exists, get the age secret key from it
|
|
secretBuffer, err := vlt.GetSecret(secretName)
|
|
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
|
|
}
|
|
|
|
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
|
|
|
// 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
|
|
}
|
|
|
|
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
|
|
|
// 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 := vlt.GetSecret(secretName)
|
|
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
|
|
}
|