check / check (push) Failing after 3s
secret.IdentityToLockedBuffer replaces the eight places that converted an age identity's String() to bytes for a locked buffer and left the string, which holds the private key, in ordinary memory. It moves the string's own bytes into the buffer, which overwrites them. The copies age makes while encoding the key remain; the function's comment says so. TODO.md drops these places from the 1.0 memory-security entry, along with its stale version.go reference. Model: opus-5-5
333 lines
8.5 KiB
Go
333 lines
8.5 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 := 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
|
|
}
|
|
|
|
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 := 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 with the vault's mnemonic
|
|
// when it has one, else with the current unlocker
|
|
func (cli *Instance) getSecretValue(
|
|
vlt *vault.Vault, secretObj *secret.Secret,
|
|
) (*memguard.LockedBuffer, error) {
|
|
if vlt.Mnemonic != nil {
|
|
return secretObj.GetValue(nil, vlt.Mnemonic)
|
|
}
|
|
|
|
unlocker, err := vlt.GetCurrentUnlocker()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
|
}
|
|
|
|
return secretObj.GetValue(unlocker, nil)
|
|
}
|