check / check (push) Failing after 3s
internal/cli drops its copies of vault.ErrSecretNotFound, ErrVaultNotFound, ErrVersionNotFound and ErrSecretExists and of the secret package's keychain and Secure Enclave errors, and its second error for an unknown unlocker type, an invalid mnemonic, a length below 1, an unsupported secret type and an oversized secret. vault.ErrNilValueBuffer becomes secret.ErrNilValueBuffer. Every error of secret.ReadPassphrase wraps ErrPassphraseNotRead. ResolveGPGKeyFingerprint returns ErrGPGKeyNotFound for a key the keyring lacks. storeInKeychain returns errNilDataBuffer. bip85's ErrPasswordTooShort and ErrEncodedTooShort go with their unreachable checks, as does the macOS check in macOS-only code. Tests that matched these errors' text use errors.Is. Model: opus-5-5
462 wiersze
14 KiB
Go
462 wiersze
14 KiB
Go
package secret
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"filippo.io/age"
|
|
"github.com/awnumar/memguard"
|
|
"github.com/spf13/afero"
|
|
)
|
|
|
|
// gpgNoPublicKeyStatus is the status line gpg writes when it has no key for
|
|
// the ID it was asked to list: 9 is gpg's error code for "No public key".
|
|
const gpgNoPublicKeyStatus = "[GNUPG:] ERROR keylist.getkey 9\n"
|
|
|
|
var (
|
|
errGPGKeyIDEmpty = errors.New("GPG key ID cannot be empty")
|
|
errInvalidGPGKeyID = errors.New("invalid GPG key ID format")
|
|
errNoGPGFingerprint = errors.New("could not find fingerprint for GPG key")
|
|
errNilDataBuffer = errors.New("data buffer is nil")
|
|
)
|
|
|
|
// ErrGPGKeyNotFound is returned by ResolveGPGKeyFingerprint for a key ID
|
|
// that matches no key in the GPG keyring.
|
|
var ErrGPGKeyNotFound = errors.New("GPG key not found")
|
|
|
|
// Variables to allow overriding in tests
|
|
var (
|
|
// GPGEncryptFunc is the function used for GPG encryption
|
|
// Can be overridden in tests to provide a non-interactive implementation
|
|
//nolint:gochecknoglobals // Required for test mocking
|
|
GPGEncryptFunc func(
|
|
data *memguard.LockedBuffer, keyID string,
|
|
) ([]byte, error) = gpgEncryptDefault
|
|
|
|
// GPGDecryptFunc is the function used for GPG decryption
|
|
// Can be overridden in tests to provide a non-interactive implementation
|
|
//nolint:gochecknoglobals // Required for test mocking
|
|
GPGDecryptFunc func(
|
|
encryptedData []byte,
|
|
) (*memguard.LockedBuffer, error) = gpgDecryptDefault
|
|
|
|
// gpgKeyIDRegex validates GPG key IDs
|
|
// Allows either:
|
|
// 1. Email addresses (user@domain.tld format)
|
|
// 2. Short key IDs (8 hex characters)
|
|
// 3. Long key IDs (16 hex characters)
|
|
// 4. Full fingerprints (40 hex characters)
|
|
gpgKeyIDRegex = regexp.MustCompile(
|
|
`^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$|` +
|
|
`^[A-Fa-f0-9]{8}$|` +
|
|
`^[A-Fa-f0-9]{16}$|` +
|
|
`^[A-Fa-f0-9]{40}$`,
|
|
)
|
|
)
|
|
|
|
// PGPUnlockerMetadata extends UnlockerMetadata with PGP-specific data
|
|
type PGPUnlockerMetadata struct {
|
|
UnlockerMetadata
|
|
|
|
// GPG key ID used for encryption
|
|
GPGKeyID string `json:"gpgKeyId"`
|
|
}
|
|
|
|
// PGPUnlocker represents a PGP-protected unlocker
|
|
type PGPUnlocker struct {
|
|
Directory string
|
|
Metadata UnlockerMetadata
|
|
fs afero.Fs
|
|
}
|
|
|
|
// NewPGPUnlocker creates a new PGPUnlocker instance
|
|
func NewPGPUnlocker(
|
|
fs afero.Fs, directory string, metadata UnlockerMetadata,
|
|
) *PGPUnlocker {
|
|
return &PGPUnlocker{
|
|
Directory: directory,
|
|
Metadata: metadata,
|
|
fs: fs,
|
|
}
|
|
}
|
|
|
|
// GetIdentity implements Unlocker interface for PGP-based unlockers
|
|
func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|
DebugWith("Getting PGP unlocker identity",
|
|
slog.String("unlocker_id", p.GetID()),
|
|
slog.String("unlocker_type", p.GetType()),
|
|
)
|
|
|
|
// Step 1: Read the encrypted age private key from filesystem
|
|
agePrivKeyPath := filepath.Join(p.Directory, "priv.age.gpg")
|
|
Debug("Reading PGP-encrypted age private key", "path", agePrivKeyPath)
|
|
|
|
encryptedAgePrivKeyData, err := afero.ReadFile(p.fs, agePrivKeyPath)
|
|
if err != nil {
|
|
Debug("Failed to read PGP-encrypted age private key",
|
|
"error", err, "path", agePrivKeyPath)
|
|
|
|
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
|
|
}
|
|
|
|
DebugWith("Read PGP-encrypted age private key",
|
|
slog.String("unlocker_id", p.GetID()),
|
|
slog.Int("encrypted_length", len(encryptedAgePrivKeyData)),
|
|
)
|
|
|
|
// Step 2: Decrypt the age private key using GPG
|
|
Debug("Decrypting age private key with GPG", "unlocker_id", p.GetID())
|
|
|
|
agePrivKeyBuffer, err := GPGDecryptFunc(encryptedAgePrivKeyData)
|
|
if err != nil {
|
|
Debug("Failed to decrypt age private key with GPG",
|
|
"error", err, "unlocker_id", p.GetID())
|
|
|
|
return nil, fmt.Errorf("failed to decrypt age private key with GPG: %w", err)
|
|
}
|
|
defer agePrivKeyBuffer.Destroy()
|
|
|
|
DebugWith("Successfully decrypted age private key with GPG",
|
|
slog.String("unlocker_id", p.GetID()),
|
|
slog.Int("decrypted_length", agePrivKeyBuffer.Size()),
|
|
)
|
|
|
|
// Step 3: Parse the decrypted age private key
|
|
Debug("Parsing decrypted age private key", "unlocker_id", p.GetID())
|
|
|
|
ageIdentity, err := age.ParseX25519Identity(agePrivKeyBuffer.String())
|
|
if err != nil {
|
|
Debug("Failed to parse age private key", "error", err, "unlocker_id", p.GetID())
|
|
|
|
return nil, fmt.Errorf("failed to parse age private key: %w", err)
|
|
}
|
|
|
|
DebugWith("Successfully parsed PGP age identity",
|
|
slog.String("unlocker_id", p.GetID()),
|
|
slog.String("public_key", ageIdentity.Recipient().String()),
|
|
)
|
|
|
|
return ageIdentity, nil
|
|
}
|
|
|
|
// GetType implements Unlocker interface
|
|
func (p *PGPUnlocker) GetType() string {
|
|
return "pgp"
|
|
}
|
|
|
|
// GetMetadata implements Unlocker interface
|
|
func (p *PGPUnlocker) GetMetadata() UnlockerMetadata {
|
|
return p.Metadata
|
|
}
|
|
|
|
// GetDirectory implements Unlocker interface
|
|
func (p *PGPUnlocker) GetDirectory() string {
|
|
return p.Directory
|
|
}
|
|
|
|
// GetID implements Unlocker interface: the name of the unlocker's directory
|
|
func (p *PGPUnlocker) GetID() string {
|
|
return filepath.Base(p.Directory)
|
|
}
|
|
|
|
// Remove implements Unlocker interface - removes the PGP unlocker
|
|
func (p *PGPUnlocker) Remove() error {
|
|
// For PGP unlockers, we just need to remove the directory
|
|
// No external resources (like keychain items) to clean up
|
|
err := RemoveDirAtomic(p.fs, p.Directory)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to remove PGP unlocker directory: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// generatePGPUnlockerName generates a unique name for the PGP unlocker
|
|
// based on hostname and time
|
|
func generatePGPUnlockerName() (string, error) {
|
|
hostname, err := os.Hostname()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get hostname: %w", err)
|
|
}
|
|
|
|
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
|
|
|
|
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentTime), nil
|
|
}
|
|
|
|
// pgpUnlockerDir returns the current vault and the directory in it for a
|
|
// new PGP unlocker, named after the host and the time.
|
|
//
|
|
//nolint:ireturn // the vault is only available behind VaultInterface
|
|
func pgpUnlockerDir(
|
|
fs afero.Fs, stateDir string,
|
|
) (VaultInterface, string, error) {
|
|
// Get current vault
|
|
vault, err := GetCurrentVault(fs, stateDir)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to get current vault: %w", err)
|
|
}
|
|
|
|
// Generate the unlocker name based on hostname and time
|
|
unlockerName, err := generatePGPUnlockerName()
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to generate unlocker name: %w", err)
|
|
}
|
|
|
|
vaultDir, err := vault.GetDirectory()
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to get vault directory: %w", err)
|
|
}
|
|
|
|
return vault, filepath.Join(vaultDir, "unlockers.d", unlockerName), nil
|
|
}
|
|
|
|
// CreatePGPUnlocker creates a new PGP unlocker and stores it in the vault.
|
|
// It encrypts to the GPG key gpgKeyID and records fingerprint, that key's
|
|
// fingerprint as ResolveGPGKeyFingerprint returns it, in the metadata.
|
|
// Everything that can fail short of writing a file is done before anything
|
|
// is written, and the files are written through WriteDir, so a failure
|
|
// leaves no partial unlocker. The long-term key comes from mnemonic when it
|
|
// is not nil, else from the current unlocker, which is given passphrase when
|
|
// it is a passphrase unlocker.
|
|
func CreatePGPUnlocker(
|
|
fs afero.Fs, stateDir, gpgKeyID, fingerprint string,
|
|
mnemonic, passphrase *memguard.LockedBuffer,
|
|
) (*PGPUnlocker, error) {
|
|
err := checkGPGAvailable()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
vault, unlockerDir, err := pgpUnlockerDir(fs, stateDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// The vault's GetOrDeriveLongTermKey, in step 2, uses both
|
|
vault.SetMnemonic(mnemonic)
|
|
vault.SetUnlockPassphrase(passphrase)
|
|
|
|
// Step 1: Generate a new age keypair for the PGP unlocker
|
|
ageIdentity, err := age.GenerateX25519Identity()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
|
|
}
|
|
|
|
// Step 2: Encrypt the long-term private key to the new keypair, and the
|
|
// keypair's private key to the GPG key
|
|
encryptedLtPrivKey, encryptedAgePrivKey, err := encryptPGPUnlockerKeys(
|
|
vault, ageIdentity, gpgKeyID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
pgpMetadata := PGPUnlockerMetadata{
|
|
UnlockerMetadata: UnlockerMetadata{
|
|
Type: "pgp",
|
|
CreatedAt: time.Now(),
|
|
Flags: []string{"gpg", "encrypted"},
|
|
},
|
|
GPGKeyID: fingerprint,
|
|
}
|
|
|
|
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
|
}
|
|
|
|
// Step 3: Write the unlocker's files, the metadata last
|
|
err = WriteDir(fs, unlockerDir, func(dir string) error {
|
|
return writePGPUnlockerFiles(fs, dir, ageIdentity.Recipient(),
|
|
encryptedLtPrivKey, encryptedAgePrivKey, metadataBytes)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &PGPUnlocker{
|
|
Directory: unlockerDir,
|
|
Metadata: pgpMetadata.UnlockerMetadata,
|
|
fs: fs,
|
|
}, nil
|
|
}
|
|
|
|
// encryptPGPUnlockerKeys returns the vault's long-term private key encrypted
|
|
// to the new PGP unlocker's age keypair, and that keypair's private key
|
|
// encrypted to the GPG key gpgKeyID.
|
|
func encryptPGPUnlockerKeys(
|
|
vault VaultInterface, ageIdentity *age.X25519Identity, gpgKeyID string,
|
|
) ([]byte, []byte, error) {
|
|
// From the mnemonic or the current unlocker, as for a passphrase unlocker
|
|
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to get long-term key: %w", err)
|
|
}
|
|
|
|
ltPrivKeyData := IdentityToLockedBuffer(ltIdentity)
|
|
defer ltPrivKeyData.Destroy()
|
|
|
|
encryptedLtPrivKey, err := EncryptToRecipient(
|
|
ltPrivKeyData, ageIdentity.Recipient())
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf(
|
|
"failed to encrypt long-term private key to age unlocker: %w", err)
|
|
}
|
|
|
|
agePrivateKeyBuffer := IdentityToLockedBuffer(ageIdentity)
|
|
defer agePrivateKeyBuffer.Destroy()
|
|
|
|
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer, gpgKeyID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf(
|
|
"failed to encrypt age private key with GPG: %w", err)
|
|
}
|
|
|
|
return encryptedLtPrivKey, encryptedAgePrivKey, nil
|
|
}
|
|
|
|
// writePGPUnlockerFiles writes the files of a PGP unlocker into dir, the
|
|
// metadata last.
|
|
func writePGPUnlockerFiles(
|
|
fs afero.Fs, dir string, ageRecipient *age.X25519Recipient,
|
|
encryptedLtPrivKey, encryptedAgePrivKey, metadataBytes []byte,
|
|
) error {
|
|
err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"),
|
|
[]byte(ageRecipient.String()))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to write age recipient: %w", err)
|
|
}
|
|
|
|
err = WriteFileAtomic(fs, filepath.Join(dir, "longterm.age"), encryptedLtPrivKey)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
|
}
|
|
|
|
err = WriteFileAtomic(fs, filepath.Join(dir, "priv.age.gpg"), encryptedAgePrivKey)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to write encrypted age private key: %w", err)
|
|
}
|
|
|
|
err = WriteFileAtomic(fs,
|
|
filepath.Join(dir, "unlocker-metadata.json"), metadataBytes)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to write unlocker metadata: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateGPGKeyID validates that a GPG key ID is safe for command execution
|
|
func validateGPGKeyID(keyID string) error {
|
|
if keyID == "" {
|
|
return errGPGKeyIDEmpty
|
|
}
|
|
|
|
if !gpgKeyIDRegex.MatchString(keyID) {
|
|
return fmt.Errorf("%w: %s", errInvalidGPGKeyID, keyID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ResolveGPGKeyFingerprint resolves any GPG key identifier to its full fingerprint
|
|
func ResolveGPGKeyFingerprint(keyID string) (string, error) {
|
|
err := validateGPGKeyID(keyID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid GPG key ID: %w", err)
|
|
}
|
|
|
|
// Use GPG to get the full fingerprint for the key. --status-fd 1 adds
|
|
// gpg's status lines to the output.
|
|
cmd := exec.CommandContext( //nolint:gosec // G204: keyID validated above
|
|
context.Background(),
|
|
"gpg", "--status-fd", "1",
|
|
"--list-keys", "--with-colons", "--fingerprint", keyID,
|
|
)
|
|
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
if strings.Contains(string(output), gpgNoPublicKeyStatus) {
|
|
return "", fmt.Errorf("%w: %s", ErrGPGKeyNotFound, keyID)
|
|
}
|
|
|
|
return "", fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
|
}
|
|
|
|
// Parse the output to extract the fingerprint
|
|
for line := range strings.SplitSeq(string(output), "\n") {
|
|
if strings.HasPrefix(line, "fpr:") {
|
|
fields := strings.Split(line, ":")
|
|
if len(fields) >= 10 && fields[9] != "" {
|
|
return fields[9], nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("%w: %s", errNoGPGFingerprint, keyID)
|
|
}
|
|
|
|
// checkGPGAvailable verifies that GPG is available
|
|
func checkGPGAvailable() error {
|
|
cmd := exec.CommandContext(context.Background(), "gpg", "--version")
|
|
|
|
err := cmd.Run()
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"GPG not available: %w (make sure 'gpg' command is installed and in PATH)",
|
|
err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// gpgEncryptDefault is the default implementation of GPG encryption
|
|
func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
|
|
if data == nil {
|
|
return nil, errNilDataBuffer
|
|
}
|
|
|
|
err := validateGPGKeyID(keyID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid GPG key ID: %w", err)
|
|
}
|
|
|
|
cmd := exec.CommandContext( //nolint:gosec // G204: keyID validated above
|
|
context.Background(),
|
|
"gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID,
|
|
)
|
|
cmd.Stdin = strings.NewReader(data.String())
|
|
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GPG encryption failed: %w", err)
|
|
}
|
|
|
|
return output, nil
|
|
}
|
|
|
|
// gpgDecryptDefault is the default implementation of GPG decryption
|
|
func gpgDecryptDefault(encryptedData []byte) (*memguard.LockedBuffer, error) {
|
|
cmd := exec.CommandContext(context.Background(), "gpg", "--quiet", "--decrypt")
|
|
cmd.Stdin = strings.NewReader(string(encryptedData))
|
|
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GPG decryption failed: %w", err)
|
|
}
|
|
|
|
// Create a secure buffer for the decrypted data
|
|
outputBuffer := memguard.NewBufferFromBytes(output)
|
|
|
|
return outputBuffer, nil
|
|
}
|