Files
secret/internal/secret/crypto.go
T
clawbot 67c2829f58
check / check (push) Waiting to run
Ask for the new passphrase in unlocker add when the variable is set (closes #154)
With SB_UNLOCK_PASSPHRASE set, `secret unlocker add passphrase` gave the
new unlocker the passphrase in the variable without asking. It now asks
on the terminal for the new passphrase and uses the variable to unlock
the vault. With no controlling terminal the new unlocker takes the
variable's passphrase, and the output says so.

secret.ErrNoTerminal is exported so that the command can tell a missing
terminal from other failures to read, such as nothing typed or a
mismatched confirmation, which still fail the command.

The in-process tests that add a passphrase unlocker with the variable
set rely on the Docker test phase having no controlling terminal; at a
terminal they would ask.

Model: opus-5-5
2026-10-15 06:02:31 +02:00

237 lines
7.3 KiB
Go

package secret
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"unsafe"
"filippo.io/age"
"github.com/awnumar/memguard"
"golang.org/x/term"
)
var (
errNilPassphraseBuffer = errors.New("passphrase buffer is nil")
errNothingEntered = errors.New("nothing was entered")
)
// ErrNoTerminal is wrapped in the error of ReadPassphrase and ReadMnemonic
// when the process has no controlling terminal to ask on.
var ErrNoTerminal = errors.New("there is no terminal to ask on")
// 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 asks on the controlling terminal and reads the answer
// there without echoing it, whatever stdin is: in
// `echo value | secret add name --force`, stdin is a pipe. 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) {
terminal, err := os.OpenFile(TerminalPath, os.O_RDWR, 0)
if err != nil {
Debug("Failed to open the controlling terminal", "error", err)
return nil, fmt.Errorf(
"%w: %w. Please set the %s environment variable or run interactively",
notRead, ErrNoTerminal, envVar)
}
defer func() { _ = terminal.Close() }()
_, _ = fmt.Fprint(terminal, prompt)
input, err := term.ReadPassword(int(terminal.Fd()))
if err != nil {
return nil, fmt.Errorf("%w: %w", notRead, err)
}
// The newline typed after the input, which ReadPassword did not echo
_, _ = fmt.Fprintln(terminal)
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
}