package cryptokit import ( "bytes" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" ) const maxYubiCryptMessageBytes = 64 << 20 type limitedBuffer struct { buffer bytes.Buffer limit int } func (b *limitedBuffer) Write(value []byte) (int, error) { remaining := b.limit - b.buffer.Len() if remaining <= 0 { return len(value), nil } if len(value) > remaining { _, _ = b.buffer.Write(value[:remaining]) return len(value), nil } return b.buffer.Write(value) } 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 } 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 } // 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 }