check / check (push) Failing after 2s
Keychain and Secure Enclave unlocker IDs were the creation time to the minute plus the host name, and passphrase unlocker IDs the time to the minute, so two created within one minute shared an ID, and `unlocker select`, `unlocker remove` and the selection after `unlocker add` acted on the older one. Every unlocker's ID is now its directory name, unique in its vault. `vault.ListUnlockers` returns each unlocker's metadata keyed by that name, so `unlocker list` and shell completion no longer find IDs by matching metadata. PGP unlocker IDs were `pgp-<fingerprint>`; a second PGP unlocker for one key is refused by comparing fingerprints in metadata. Model: opus-5-5
448 lines
13 KiB
Go
448 lines
13 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"
|
|
)
|
|
|
|
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")
|
|
)
|
|
|
|
// 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
|
|
cmd := exec.CommandContext( //nolint:gosec // G204: keyID validated above
|
|
context.Background(),
|
|
"gpg", "--list-keys", "--with-colons", "--fingerprint", keyID,
|
|
)
|
|
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
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
|
|
}
|