package secret import ( "bytes" "errors" "fmt" "io" "os" "syscall" "unsafe" "filippo.io/age" "github.com/awnumar/memguard" "golang.org/x/term" ) var ( errNilPassphraseBuffer = errors.New("passphrase buffer is nil") errStdinNotTerminal = errors.New( "stdin is not a terminal (piped input or script)") errStderrNotTerminal = errors.New( "stderr is not a terminal (running in non-interactive mode)") errNothingEntered = errors.New("nothing was entered") errEmptyPassphrase = errors.New("passphrase cannot be empty") ) // ErrMnemonicNotRead is wrapped in every error of ReadMnemonic: there is no // terminal to read the mnemonic from, reading it failed, or it was empty. var ErrMnemonicNotRead = errors.New("failed to read mnemonic") // ScryptWorkFactor is, when not zero, the scrypt work factor that // EncryptWithPassphrase uses instead of age's, 18: log2 of scrypt's cost // parameter N. Deriving a key with age's takes about a second and 256 MiB, on // purpose, since so does every guess at the passphrase. Only tests set it, // lower, before any test runs, so that the passphrase unlockers they create // cost nothing; the program leaves it zero. Decryption takes the work factor // from the encrypted data, so it needs no setting. // //nolint:gochecknoglobals // set by the tests of the packages that use this one var ScryptWorkFactor int // EncryptToRecipient encrypts data to a recipient using age // The data parameter should be a LockedBuffer for secure memory handling func EncryptToRecipient( data *memguard.LockedBuffer, recipient age.Recipient, ) ([]byte, error) { if data == nil { return nil, errNilDataBuffer } Debug("EncryptToRecipient starting", "data_length", data.Size()) var buf bytes.Buffer Debug("Creating age encryptor") w, err := age.Encrypt(&buf, recipient) if err != nil { Debug("Failed to create encryptor", "error", err) return nil, fmt.Errorf("failed to create encryptor: %w", err) } Debug("Created age encryptor successfully") Debug("Writing data to encryptor") _, err = w.Write(data.Bytes()) if err != nil { Debug("Failed to write data to encryptor", "error", err) return nil, fmt.Errorf("failed to write data: %w", err) } Debug("Wrote data to encryptor successfully") Debug("Closing encryptor") err = w.Close() if err != nil { Debug("Failed to close encryptor", "error", err) return nil, fmt.Errorf("failed to close encryptor: %w", err) } Debug("Closed encryptor successfully") result := buf.Bytes() Debug("EncryptToRecipient completed successfully", "result_length", len(result)) return result, nil } // DecryptWithIdentity decrypts data with an identity using age func DecryptWithIdentity( data []byte, identity age.Identity, ) (*memguard.LockedBuffer, error) { r, err := age.Decrypt(bytes.NewReader(data), identity) if err != nil { return nil, fmt.Errorf("failed to create decryptor: %w", err) } result, err := io.ReadAll(r) if err != nil { return nil, fmt.Errorf("failed to read decrypted data: %w", err) } // Create a secure buffer for the decrypted data resultBuffer := memguard.NewBufferFromBytes(result) // Zero out the original slice to prevent plaintext from lingering // in unprotected memory for i := range result { result[i] = 0 } return resultBuffer, nil } // IdentityToLockedBuffer returns the private key of id, in age's text form, in // a new locked buffer. The caller must destroy it. // // This is best effort. age gives the key only as a string in ordinary memory. // The bytes of that string are moved into the buffer, which overwrites them, // although Go otherwise never changes a string; nothing else holds this one. // The copies age makes while building the string are left in ordinary memory. // Avoiding those would mean encoding the key here, straight into the buffer. func IdentityToLockedBuffer(id *age.X25519Identity) *memguard.LockedBuffer { key := id.String() //nolint:gosec // G103: the string's own bytes, which NewBufferFromBytes wipes keyBytes := unsafe.Slice(unsafe.StringData(key), len(key)) return memguard.NewBufferFromBytes(keyBytes) } // EncryptWithPassphrase encrypts data using a passphrase with age's // scrypt-based encryption. Both data and passphrase parameters should // be LockedBuffers for secure memory handling func EncryptWithPassphrase( data *memguard.LockedBuffer, passphrase *memguard.LockedBuffer, ) ([]byte, error) { if data == nil { return nil, errNilDataBuffer } if passphrase == nil { return nil, errNilPassphraseBuffer } // Create recipient directly from passphrase - unavoidable string // conversion due to age API recipient, err := age.NewScryptRecipient(passphrase.String()) if err != nil { return nil, fmt.Errorf("failed to create scrypt recipient: %w", err) } if ScryptWorkFactor != 0 { recipient.SetWorkFactor(ScryptWorkFactor) } return EncryptToRecipient(data, recipient) } // DecryptWithPassphrase decrypts data using a passphrase with age's // scrypt-based decryption. The passphrase parameter should be a // LockedBuffer for secure memory handling func DecryptWithPassphrase( encryptedData []byte, passphrase *memguard.LockedBuffer, ) (*memguard.LockedBuffer, error) { if passphrase == nil { return nil, errNilPassphraseBuffer } // Create identity directly from passphrase - unavoidable string // conversion due to age API identity, err := age.NewScryptIdentity(passphrase.String()) if err != nil { return nil, fmt.Errorf("failed to create scrypt identity: %w", err) } return DecryptWithIdentity(encryptedData, identity) } // ReadPassphrase reads a passphrase securely from the terminal without echoing // This version is for unlocking and doesn't require confirmation // Returns a LockedBuffer containing the passphrase for secure memory handling. // Every error it returns wraps ErrPassphraseNotRead. func ReadPassphrase(prompt string) (*memguard.LockedBuffer, error) { return readFromTerminal(prompt, ErrPassphraseNotRead, EnvUnlockPassphrase) } // ReadMnemonic reads a mnemonic from the terminal as ReadPassphrase reads a // passphrase. Every error it returns wraps ErrMnemonicNotRead. func ReadMnemonic(prompt string) (*memguard.LockedBuffer, error) { return readFromTerminal(prompt, ErrMnemonicNotRead, EnvMnemonic) } // readFromTerminal reads input from the terminal without echoing it. Every // error it returns wraps notRead; without a terminal, the error says to set // envVar instead. func readFromTerminal( prompt string, notRead error, envVar string, ) (*memguard.LockedBuffer, error) { // Check if stdin is a terminal if !term.IsTerminal(syscall.Stdin) { // Not a terminal - never read secrets from piped input // for security reasons return nil, fmt.Errorf( "%w: %w. Please set the %s environment variable or run interactively", notRead, errStdinNotTerminal, envVar) } // stdin is a terminal, check if stderr is also a terminal for // interactive prompting if !term.IsTerminal(syscall.Stderr) { return nil, fmt.Errorf("%w: %w. Please set the %s environment variable", notRead, errStderrNotTerminal, envVar) } // Both stdin and stderr are terminals - use secure password reading fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout input, err := term.ReadPassword(syscall.Stdin) if err != nil { return nil, fmt.Errorf("%w: %w", notRead, err) } // Print newline to stderr since ReadPassword doesn't echo fmt.Fprintln(os.Stderr) if len(input) == 0 { return nil, fmt.Errorf("%w: %w", notRead, errNothingEntered) } // Create a secure buffer and copy the input secureBuffer := memguard.NewBufferFromBytes(input) // Clear the original input slice for i := range input { input[i] = 0 } return secureBuffer, nil }