package cryptokit import ( "bytes" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" ) const maxYubiCryptMessageBytes = 64 << 20 var yubiCryptCandidates = []string{ "/home/gabriel1/bin/yubicrypt", "/home/gabriel1/bin/yubicrpt-cli", "/home/gabriel1/Projects/yubicrpt-cli/yubicrypt", "/home/gabriel1/Projects/yubicrpt-cli/yubicrpt-cli", "/usr/local/bin/yubicrypt", "/usr/bin/yubicrypt", } // FindYubiCryptCLI returns the optional yubicrypt-cli executable. An explicit // AEGIS_YUBICRYPT_CLI path takes precedence over the standard locations. func FindYubiCryptCLI() (string, error) { if configured := strings.TrimSpace(os.Getenv("AEGIS_YUBICRYPT_CLI")); configured != "" { if isExecutableBinary(configured) { return configured, nil } return "", fmt.Errorf("AEGIS_YUBICRYPT_CLI is not executable: %s", configured) } for _, candidate := range yubiCryptCandidates { if isExecutableBinary(candidate) { return candidate, nil } } if path, err := exec.LookPath("yubicrypt"); err == nil { return path, nil } return "", errors.New("yubicrypt-cli executable not found; set AEGIS_YUBICRYPT_CLI or install yubicrypt") } func isExecutableBinary(path string) bool { info, err := os.Stat(path) return err == nil && info.Mode().IsRegular() && info.Mode()&0o111 != 0 } func runYubiCrypt(args []string, input []byte) ([]byte, error) { executable, err := FindYubiCryptCLI() if err != nil { return nil, err } command := exec.Command(executable, args...) command.Stdin = bytes.NewReader(input) var stdout limitedBuffer var stderr bytes.Buffer stdout.limit = maxYubiCryptMessageBytes + 1 command.Stdout = &stdout command.Stderr = &stderr if err := command.Run(); err != nil { return nil, fmt.Errorf("yubicrypt %s: %w: %s", firstCommandArg(args), err, cleanCommandError(stderr.String())) } if stdout.buffer.Len() > maxYubiCryptMessageBytes { return nil, errors.New("yubicrypt output exceeds the 64 MiB limit") } return stdout.buffer.Bytes(), nil } func firstCommandArg(args []string) string { if len(args) == 0 { return "operation" } return args[0] } func cleanCommandError(value string) string { value = strings.TrimSpace(value) if len(value) > 1000 { return value[:1000] + "..." } if value == "" { return "command failed" } return value } func withYubiTemp(operation func(directory string) error) error { directory, err := os.MkdirTemp("", "aegis-yubicrypt-") if err != nil { return fmt.Errorf("create temporary YubiCrypt directory: %w", err) } defer os.RemoveAll(directory) if err := os.Chmod(directory, 0o700); err != nil { return fmt.Errorf("protect temporary YubiCrypt directory: %w", err) } return operation(directory) } func writeYubiTemp(directory, name string, data []byte) (string, error) { path := filepath.Join(directory, name) file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil { return "", fmt.Errorf("create temporary YubiCrypt input: %w", err) } if _, err := file.Write(data); err != nil { _ = file.Close() return "", fmt.Errorf("write temporary YubiCrypt input: %w", err) } if err := file.Close(); err != nil { return "", fmt.Errorf("close temporary YubiCrypt input: %w", err) } return path, nil } func checkYubiMessageSize(message []byte) error { if len(message) > maxYubiCryptMessageBytes { return errors.New("message exceeds the 64 MiB limit") } return nil } // EncryptYubiCrypt encrypts with an RSA public certificate/key accepted by // yubicrypt-cli. The private decryption key remains inside the YubiKey PIV // slot 9d and is never read by Aegis. func EncryptYubiCrypt(message []byte, recipientKeyPEM string) ([]byte, error) { if err := checkYubiMessageSize(message); err != nil { return nil, err } if strings.TrimSpace(recipientKeyPEM) == "" { return nil, errors.New("YubiCrypt RSA recipient certificate is required") } var result []byte err := withYubiTemp(func(directory string) error { messagePath, err := writeYubiTemp(directory, "message.bin", message) if err != nil { return err } keyPath, err := writeYubiTemp(directory, "recipient.pem", []byte(recipientKeyPEM)) if err != nil { return err } result, err = runYubiCrypt([]string{ "encrypt", "--quiet", "--key", keyPath, "--input", messagePath, "--output", "-", }, nil) if err != nil { return fmt.Errorf("YubiCrypt encryption failed: %w", err) } return nil }) return result, err } func requireYubiPIN(pin string) ([]byte, error) { pin = strings.TrimRight(pin, "\r\n") if pin == "" { return nil, errors.New("YubiKey PIV PIN is required") } return []byte(pin + "\n"), nil } // SignYubiCrypt signs with the YubiKey PIV slot 9c. The PIN is sent through // stdin to yubicrypt-cli and is not placed in command arguments or logs. func SignYubiCrypt(message []byte, pin string) ([]byte, error) { if err := checkYubiMessageSize(message); err != nil { return nil, err } pinInput, err := requireYubiPIN(pin) if err != nil { return nil, err } var result []byte err = withYubiTemp(func(directory string) error { messagePath, err := writeYubiTemp(directory, "message.bin", message) if err != nil { return err } result, err = runYubiCrypt([]string{ "sign", "--quiet", "--pin-stdin", "--input", messagePath, "--output", "-", }, pinInput) if err != nil { return fmt.Errorf("YubiCrypt signing failed: %w", err) } return nil }) return result, err } // DecryptYubiCrypt decrypts with the YubiKey PIV slot 9d. The ciphertext is // kept in a temporary file while the PIN is supplied only on stdin. func DecryptYubiCrypt(ciphertext []byte, pin string) ([]byte, error) { if err := checkYubiMessageSize(ciphertext); err != nil { return nil, err } pinInput, err := requireYubiPIN(pin) if err != nil { return nil, err } var result []byte err = withYubiTemp(func(directory string) error { ciphertextPath, err := writeYubiTemp(directory, "ciphertext.yc", ciphertext) if err != nil { return err } result, err = runYubiCrypt([]string{ "decrypt", "--quiet", "--pin-stdin", "--input", ciphertextPath, "--output", "-", }, pinInput) if err != nil { return fmt.Errorf("YubiCrypt decryption failed: %w", err) } return nil }) return result, err } // VerifyYubiCrypt verifies a yubicrypt signature block and returns the // original message only after the CLI has authenticated it. func VerifyYubiCrypt(signedMessage []byte) ([]byte, error) { if err := checkYubiMessageSize(signedMessage); err != nil { return nil, err } var result []byte err := withYubiTemp(func(directory string) error { signedPath, err := writeYubiTemp(directory, "signed.yc", signedMessage) if err != nil { return err } result, err = runYubiCrypt([]string{ "verify", "--quiet", "--input", signedPath, "--message-output", "-", }, nil) if err != nil { return fmt.Errorf("YubiCrypt verification failed: %w", err) } return nil }) return result, err }