check / check (push) Waiting to run
init, vault create, unlocker add, and add, import and generate secret with --force asked for the mnemonic or passphrase, or unlocked the vault, while holding the state directory lock, so every other command that changes anything waited at the prompt. They now ask and unlock first and lock after. Under the lock, unlocker add fails if the vault was removed, or created again with another key, and refuses a GPG key added meanwhile; a forced add of a secret created meanwhile lets go of the lock, gets the key and locks again. secret.CreatePGPUnlocker takes the unlocked vault; SetMnemonic and SetUnlockPassphrase are gone. Not done: secret move between vaults still unlocks both under the lock (#189). Model: opus-5-5
467 lines
14 KiB
Go
467 lines
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
|
|
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 directory in vault for a new PGP unlocker,
|
|
// named after the host and the time.
|
|
func pgpUnlockerDir(vault VaultInterface) (string, error) {
|
|
// Generate the unlocker name based on hostname and time
|
|
unlockerName, err := generatePGPUnlockerName()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to generate unlocker name: %w", err)
|
|
}
|
|
|
|
vaultDir, err := vault.GetDirectory()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get vault directory: %w", err)
|
|
}
|
|
|
|
return filepath.Join(vaultDir, "unlockers.d", unlockerName), nil
|
|
}
|
|
|
|
// CreatePGPUnlocker creates a new PGP unlocker and stores it in 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 vault's
|
|
// GetOrDeriveLongTermKey: the key it holds when it is unlocked, else the
|
|
// key from its mnemonic or its current unlocker.
|
|
func CreatePGPUnlocker(
|
|
vault VaultInterface, gpgKeyID, fingerprint string,
|
|
) (*PGPUnlocker, error) {
|
|
err := checkGPGAvailable()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
unlockerDir, err := pgpUnlockerDir(vault)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
fs := vault.GetFilesystem()
|
|
|
|
// 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) {
|
|
// The key vault holds, or else from its mnemonic or current 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", withGPGStderr(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
|
|
}
|
|
|
|
// withGPGStderr appends gpg's stderr, which says why gpg failed, to err
|
|
// from cmd.Output. cmd.Output keeps that stderr in the *exec.ExitError.
|
|
// err is returned unchanged when gpg wrote nothing to stderr.
|
|
func withGPGStderr(err error) error {
|
|
var exitErr *exec.ExitError
|
|
|
|
if !errors.As(err, &exitErr) {
|
|
return err
|
|
}
|
|
|
|
stderr := strings.TrimSpace(string(exitErr.Stderr))
|
|
if stderr == "" {
|
|
return err
|
|
}
|
|
|
|
return fmt.Errorf("%w: %s", err, stderr)
|
|
}
|
|
|
|
// 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", withGPGStderr(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", withGPGStderr(err))
|
|
}
|
|
|
|
// Create a secure buffer for the decrypted data
|
|
outputBuffer := memguard.NewBufferFromBytes(output)
|
|
|
|
return outputBuffer, nil
|
|
}
|