summaryrefslogtreecommitdiffstats
path: root/internal/cryptokit/yubicrypt.go
diff options
context:
space:
mode:
authorGab Virebent <gabriel1@virebent.art>2026-08-22 20:36:56 +0200
committerGab Virebent <gabriel1@virebent.art>2026-08-22 20:36:56 +0200
commitc1decadb590c4d79d92bcc4df7772119a5c91244 (patch)
tree468f9fa37535dbb90cc5d4061eb20de83912131e /internal/cryptokit/yubicrypt.go
downloadaegis-c1decadb590c4d79d92bcc4df7772119a5c91244.tar.gz
aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.tar.xz
aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.zip
Initial Aegis Usenet client release
Diffstat (limited to 'internal/cryptokit/yubicrypt.go')
-rw-r--r--internal/cryptokit/yubicrypt.go237
1 files changed, 237 insertions, 0 deletions
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
+}