From c1decadb590c4d79d92bcc4df7772119a5c91244 Mon Sep 17 00:00:00 2001 From: Gab Virebent Date: Sat, 22 Aug 2026 20:36:56 +0200 Subject: Initial Aegis Usenet client release --- internal/cryptokit/yubicrypt.go | 237 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 internal/cryptokit/yubicrypt.go (limited to 'internal/cryptokit/yubicrypt.go') diff --git a/internal/cryptokit/yubicrypt.go b/internal/cryptokit/yubicrypt.go new file mode 100644 index 0000000..851d564 --- /dev/null +++ b/internal/cryptokit/yubicrypt.go @@ -0,0 +1,237 @@ +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 +} -- cgit v1.2.3