package cli import ( "errors" "fmt" "io" "os" "path/filepath" "sync/atomic" "filippo.io/age" "github.com/awnumar/memguard" "github.com/spf13/afero" "github.com/spf13/cobra" "sneak.berlin/go/secret/internal/secret" "sneak.berlin/go/secret/internal/vault" ) // Sentinel errors for encrypt/decrypt operations var ( errNotAgeSecretKey = errors.New( "does not contain a valid age secret key") ) // 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 ", "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 ", "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 } return cli.writeOutput(outputFile, func(output io.Writer) error { 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, vault.ErrSecretNotFound) } // 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 } return cli.writeOutput(outputFile, func(output io.Writer) error { 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 }) } // 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 { return fmt.Errorf("failed to create output file: %w", err) } defer func() { _ = file.Close() }() return write(file) } // An existing file that cannot be written fails here, as writing into it // 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 { 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 } if err != nil { return "", err } 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 // attempting to parse it func isValidAgeSecretKey(key string) bool { _, err := age.ParseX25519Identity(key) return err == nil }