summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGab Virebent <gabriel1@virebent.art>2026-07-13 00:40:19 +0200
committerGab Virebent <gabriel1@virebent.art>2026-07-13 00:40:19 +0200
commit5fa37b6b395981799924ecf0e432e45998169225 (patch)
tree76e5a3d6ee04eefc5189510e6b993271386a9563
parentc69a9d6af8aec07cc22ac15228a4b99825ac19c7 (diff)
downloadyubicrpt-cli-main.tar.gz
yubicrpt-cli-main.tar.xz
yubicrpt-cli-main.zip
Add CLI-first yubicrypt buildHEADmain
-rw-r--r--README.md51
-rw-r--r--cli.go1041
-rw-r--r--yubicrypt.go5
3 files changed, 1092 insertions, 5 deletions
diff --git a/README.md b/README.md
index f554d81..fc98d94 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-# yubicrypt
-
-An easy to use public key encryption program for your YubiKey.
+# yubicrypt
+
+An easy to use command-line public key encryption program for your YubiKey.
Supported YubiKey algorithms:
@@ -33,7 +33,50 @@ sudo systemctl enable pcscd
Check the status to ensure it's running
sudo systemctl status pcscd
-![yubicrypt](img/1.png)
+## CLI usage
+
+Build the CLI:
+
+```sh
+go build -o yubicrypt .
+```
+
+Commands:
+
+```sh
+./yubicrypt help
+./yubicrypt cards
+./yubicrypt encrypt --key alice.crt -i msg.txt -o msg.yc
+printf '%s\n' "$PIV_PIN" | ./yubicrypt decrypt --pin-stdin -i msg.yc -o msg.txt
+printf '%s\n' "$PIV_PIN" | ./yubicrypt sign --pin-stdin -i msg.txt -o msg.sig.txt
+./yubicrypt verify -i msg.sig.txt --public-key
+./yubicrypt pad --text "message" -o padded.txt
+./yubicrypt unpad -i padded.txt -o message.txt
+```
+
+PIN options for `decrypt` and `sign`:
+
+- `--pin-stdin`: read the PIV PIN from the first stdin line.
+- `--pin-file FILE`: read the PIV PIN from the first line of a file.
+- `--pin PIN`: pass the PIV PIN directly. This is less safe because it can leak through shell history or process listings.
+- `--card-index N`: select the YubiKey index shown by `./yubicrypt cards`. The default is `0`.
+
+Common IO options:
+
+- `-i, --input FILE`: input file, or `-` for stdin.
+- `-o, --output FILE`: output file, or `-` for stdout.
+- `--text TEXT`: use literal text instead of reading input.
+- `--quiet`: suppress status output on stderr.
+
+## GUI build
+
+The old Fyne GUI is still available as an explicit build target:
+
+```sh
+go build -tags gui -o yubicrypt-gui .
+```
+
+![yubicrypt](img/1.png)
The copy signature component is intended for usage of my [identicons](https://github.com/Ch1ffr3punk/identicons) program,
so that you can create your yubicrypt identicon for websites etc.
diff --git a/cli.go b/cli.go
new file mode 100644
index 0000000..26f577f
--- /dev/null
+++ b/cli.go
@@ -0,0 +1,1041 @@
+//go:build !gui
+// +build !gui
+
+package main
+
+import (
+ "bufio"
+ "crypto"
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/ecdsa"
+ "crypto/ed25519"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "crypto/sha512"
+ "crypto/x509"
+ "encoding/asn1"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/pem"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "math/big"
+ "os"
+ "regexp"
+ "strings"
+
+ "github.com/awnumar/memguard"
+ "github.com/go-piv/piv-go/v2/piv"
+)
+
+type ecSignature struct{ R, S *big.Int }
+
+const (
+ AlgorithmECCP256 = "ECCP256"
+ AlgorithmECCP384 = "ECCP384"
+ AlgorithmED25519 = "ED25519"
+)
+
+const (
+ Ed25519SignatureSize = 64
+ Ed25519PublicKeySize = 32
+ Ed25519CombinedSize = Ed25519SignatureSize + Ed25519PublicKeySize
+ minRSABits = 2048
+)
+
+var supportedAlgorithms = map[string]bool{
+ AlgorithmECCP256: true,
+ AlgorithmECCP384: true,
+ AlgorithmED25519: true,
+}
+
+var curveToAlgorithm = map[elliptic.Curve]string{
+ elliptic.P256(): AlgorithmECCP256,
+ elliptic.P384(): AlgorithmECCP384,
+}
+
+var curveToHash = map[elliptic.Curve]crypto.Hash{
+ elliptic.P256(): crypto.SHA256,
+ elliptic.P384(): crypto.SHA384,
+}
+
+var supportedRSASizes = map[int]string{
+ 2048: "RSA2048",
+ 3072: "RSA3072",
+ 4096: "RSA4096",
+}
+
+var yubiKeyIndex int
+
+type signatureInfo struct {
+ Algorithm string
+ OriginalMessage []byte
+ PublicKey []byte
+}
+
+func main() {
+ defer memguard.Purge()
+
+ if len(os.Args) == 1 {
+ printCLIHelp(os.Stdout)
+ return
+ }
+ if err := runCLI(os.Args[1:]); err != nil {
+ fmt.Fprintln(os.Stderr, "Error:", err)
+ os.Exit(1)
+ }
+}
+
+func runCLI(args []string) error {
+ if len(args) == 0 {
+ printCLIHelp(os.Stdout)
+ return nil
+ }
+
+ switch args[0] {
+ case "help", "-h", "--help":
+ printCLIHelp(os.Stdout)
+ return nil
+ case "version", "-v", "--version":
+ fmt.Println("yubicrypt v0.2.0")
+ return nil
+ case "encrypt", "enc":
+ return runEncryptCLI(args[1:])
+ case "decrypt", "dec":
+ return runDecryptCLI(args[1:])
+ case "sign":
+ return runSignCLI(args[1:])
+ case "verify":
+ return runVerifyCLI(args[1:])
+ case "pad":
+ return runPadCLI(args[1:])
+ case "unpad":
+ return runUnpadCLI(args[1:])
+ case "cards":
+ return runCardsCLI()
+ default:
+ return fmt.Errorf("unknown command %q; run `yubicrypt help`", args[0])
+ }
+}
+
+func printCLIHelp(w io.Writer) {
+ _, _ = io.WriteString(w, `Usage:
+ yubicrypt <command> [options]
+
+Commands:
+ encrypt Encrypt with an RSA public certificate/key
+ decrypt Decrypt with YubiKey PIV slot 9d
+ sign Sign with YubiKey PIV slot 9c
+ verify Verify an embedded yubicrypt signature
+ pad ISO/IEC 7816-4 pad and Base64 encode
+ unpad Base64 decode and remove padding
+ cards List visible YubiKeys
+ version Print version
+
+Common IO options:
+ -i, --input FILE input file, or - for stdin (default -)
+ -o, --output FILE output file, or - for stdout (default -)
+ --text TEXT use TEXT instead of reading input
+ --quiet suppress status lines on stderr
+
+PIN options for decrypt/sign:
+ --pin-stdin read the PIV PIN from the first stdin line
+ --pin-file FILE read the PIV PIN from the first line of FILE
+ --pin PIN use PIN directly (less safe: shell history/process list)
+ --card-index N YubiKey index from yubicrypt cards (default 0)
+
+Examples:
+ yubicrypt encrypt --key alice.crt -i msg.txt -o msg.yc
+ printf '%s\n' "$PIV_PIN" | yubicrypt decrypt --pin-stdin -i msg.yc -o msg.txt
+ printf '%s\n' "$PIV_PIN" | yubicrypt sign --pin-stdin -i msg.txt -o msg.sig.txt
+ yubicrypt verify -i msg.sig.txt --public-key
+`)
+}
+
+func newFlagSet(name string) *flag.FlagSet {
+ fs := flag.NewFlagSet(name, flag.ContinueOnError)
+ fs.SetOutput(os.Stderr)
+ return fs
+}
+
+func addIOFlags(fs *flag.FlagSet, input, output, text *string, quiet *bool) {
+ fs.StringVar(input, "input", "-", "input file, or - for stdin")
+ fs.StringVar(input, "i", "-", "input file, or - for stdin")
+ fs.StringVar(output, "output", "-", "output file, or - for stdout")
+ fs.StringVar(output, "o", "-", "output file, or - for stdout")
+ fs.StringVar(text, "text", "", "literal input text")
+ fs.BoolVar(quiet, "quiet", false, "suppress status lines on stderr")
+}
+
+func addPINFlags(fs *flag.FlagSet, pin, pinFile *string, pinStdin *bool) {
+ fs.StringVar(pin, "pin", "", "PIV PIN (less safe: shell history/process list)")
+ fs.StringVar(pinFile, "pin-file", "", "file containing the PIV PIN on the first line")
+ fs.BoolVar(pinStdin, "pin-stdin", false, "read the PIV PIN from the first stdin line")
+ fs.IntVar(&yubiKeyIndex, "card-index", 0, "YubiKey index from `yubicrypt cards`")
+}
+
+func runEncryptCLI(args []string) error {
+ var input, output, text, key string
+ var quiet bool
+ fs := newFlagSet("encrypt")
+ addIOFlags(fs, &input, &output, &text, &quiet)
+ fs.StringVar(&key, "key", "", "RSA public certificate/key file")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ if key == "" {
+ return fmt.Errorf("encrypt requires --key")
+ }
+
+ stdin := bufio.NewReader(os.Stdin)
+ data, err := readInput(input, text, stdin)
+ if err != nil {
+ return fmt.Errorf("read input: %w", err)
+ }
+ result, err := encryptData(data, key)
+ if err != nil {
+ return err
+ }
+ if err := writeOutput(output, []byte(result)); err != nil {
+ return fmt.Errorf("write output: %w", err)
+ }
+ logStatus(quiet, "encrypted %d bytes", len(data))
+ return nil
+}
+
+func runDecryptCLI(args []string) error {
+ var input, output, text, pin, pinFile string
+ var quiet, pinStdin bool
+ fs := newFlagSet("decrypt")
+ addIOFlags(fs, &input, &output, &text, &quiet)
+ addPINFlags(fs, &pin, &pinFile, &pinStdin)
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+
+ stdin := bufio.NewReader(os.Stdin)
+ pivPIN, err := readPIN(pin, pinFile, pinStdin, stdin)
+ if err != nil {
+ return err
+ }
+ data, err := readInput(input, text, stdin)
+ if err != nil {
+ return fmt.Errorf("read input: %w", err)
+ }
+ result, err := decryptData(data, pivPIN)
+ if err != nil {
+ return err
+ }
+ if err := writeOutput(output, result); err != nil {
+ return fmt.Errorf("write output: %w", err)
+ }
+ logStatus(quiet, "decrypted %d bytes", len(result))
+ return nil
+}
+
+func runSignCLI(args []string) error {
+ var input, output, text, pin, pinFile string
+ var quiet, pinStdin, force bool
+ fs := newFlagSet("sign")
+ addIOFlags(fs, &input, &output, &text, &quiet)
+ addPINFlags(fs, &pin, &pinFile, &pinStdin)
+ fs.BoolVar(&force, "force", false, "allow signing input that already contains a yubicrypt signature")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+
+ stdin := bufio.NewReader(os.Stdin)
+ pivPIN, err := readPIN(pin, pinFile, pinStdin, stdin)
+ if err != nil {
+ return err
+ }
+ data, err := readInput(input, text, stdin)
+ if err != nil {
+ return fmt.Errorf("read input: %w", err)
+ }
+ if !force && containsSignatureBlock(data) {
+ return fmt.Errorf("input already contains a yubicrypt signature; use --force to sign anyway")
+ }
+ result, err := signData(data, pivPIN)
+ if err != nil {
+ return err
+ }
+ if err := writeOutput(output, []byte(result)); err != nil {
+ return fmt.Errorf("write output: %w", err)
+ }
+ logStatus(quiet, "signed %d bytes", len(data))
+ return nil
+}
+
+func runVerifyCLI(args []string) error {
+ var input, output, text, messageOutput string
+ var quiet, publicKeyOnly bool
+ fs := newFlagSet("verify")
+ fs.StringVar(&input, "input", "-", "input file, or - for stdin")
+ fs.StringVar(&input, "i", "-", "input file, or - for stdin")
+ fs.StringVar(&text, "text", "", "literal input text")
+ fs.BoolVar(&quiet, "quiet", false, "suppress success output")
+ fs.BoolVar(&publicKeyOnly, "public-key", false, "print embedded public key hex only")
+ fs.StringVar(&messageOutput, "message-output", "", "write verified original message to file")
+ fs.StringVar(&output, "output", "", "alias for --message-output")
+ fs.StringVar(&output, "o", "", "alias for --message-output")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ if output != "" && messageOutput == "" {
+ messageOutput = output
+ }
+
+ stdin := bufio.NewReader(os.Stdin)
+ data, err := readInput(input, text, stdin)
+ if err != nil {
+ return fmt.Errorf("read input: %w", err)
+ }
+ info, err := verifySignedData(data)
+ if err != nil {
+ return err
+ }
+ if messageOutput != "" {
+ if err := writeOutput(messageOutput, info.OriginalMessage); err != nil {
+ return fmt.Errorf("write verified message: %w", err)
+ }
+ }
+ if publicKeyOnly {
+ fmt.Fprintln(os.Stdout, hex.EncodeToString(info.PublicKey))
+ return nil
+ }
+ if !quiet {
+ fmt.Fprintf(os.Stdout, "OK %s signature valid; message=%d bytes; public_key=%s\n",
+ info.Algorithm, len(info.OriginalMessage), hex.EncodeToString(info.PublicKey))
+ }
+ return nil
+}
+
+func runPadCLI(args []string) error {
+ var input, output, text string
+ var quiet bool
+ fs := newFlagSet("pad")
+ addIOFlags(fs, &input, &output, &text, &quiet)
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+
+ stdin := bufio.NewReader(os.Stdin)
+ data, err := readInput(input, text, stdin)
+ if err != nil {
+ return fmt.Errorf("read input: %w", err)
+ }
+ padded := securePadMessage(data)
+ encoded := formatBase64RFC(base64.StdEncoding.EncodeToString(padded))
+ if err := writeOutput(output, []byte(encoded)); err != nil {
+ return fmt.Errorf("write output: %w", err)
+ }
+ logStatus(quiet, "padded %d -> %d bytes", len(data), len(padded))
+ return nil
+}
+
+func runUnpadCLI(args []string) error {
+ var input, output, text string
+ var quiet bool
+ fs := newFlagSet("unpad")
+ addIOFlags(fs, &input, &output, &text, &quiet)
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+
+ stdin := bufio.NewReader(os.Stdin)
+ data, err := readInput(input, text, stdin)
+ if err != nil {
+ return fmt.Errorf("read input: %w", err)
+ }
+ decoded, err := decodeBase64Clean(data)
+ if err != nil {
+ return err
+ }
+ defer memguard.WipeBytes(decoded)
+ result, err := secureUnpadMessage(decoded)
+ if err != nil {
+ return err
+ }
+ if err := writeOutput(output, result); err != nil {
+ return fmt.Errorf("write output: %w", err)
+ }
+ logStatus(quiet, "unpadded %d bytes", len(result))
+ return nil
+}
+
+func runCardsCLI() error {
+ cards, err := piv.Cards()
+ if err != nil {
+ return fmt.Errorf("list cards: %w", err)
+ }
+ if len(cards) == 0 {
+ return fmt.Errorf("no smart card found")
+ }
+ for i, card := range cards {
+ fmt.Fprintf(os.Stdout, "%d\t%s\n", i, card)
+ }
+ return nil
+}
+
+func readInput(path, text string, stdin *bufio.Reader) ([]byte, error) {
+ if text != "" {
+ return []byte(text), nil
+ }
+ if path == "" || path == "-" {
+ return io.ReadAll(stdin)
+ }
+ return os.ReadFile(path)
+}
+
+func writeOutput(path string, data []byte) error {
+ if path == "" || path == "-" {
+ _, err := os.Stdout.Write(data)
+ return err
+ }
+ return os.WriteFile(path, data, 0600)
+}
+
+func firstLine(data []byte) string {
+ line := string(data)
+ if i := strings.IndexAny(line, "\r\n"); i >= 0 {
+ line = line[:i]
+ }
+ return strings.TrimSpace(line)
+}
+
+func readPIN(pin, pinFile string, pinStdin bool, stdin *bufio.Reader) (string, error) {
+ switch {
+ case pin != "":
+ return pin, nil
+ case pinFile != "":
+ data, err := os.ReadFile(pinFile)
+ if err != nil {
+ return "", fmt.Errorf("read PIN file: %w", err)
+ }
+ defer memguard.WipeBytes(data)
+ p := firstLine(data)
+ if p == "" {
+ return "", fmt.Errorf("PIN file is empty")
+ }
+ return p, nil
+ case pinStdin:
+ line, err := stdin.ReadString('\n')
+ if err != nil && err != io.EOF {
+ return "", fmt.Errorf("read PIN from stdin: %w", err)
+ }
+ p := strings.TrimRight(line, "\r\n")
+ if p == "" {
+ return "", fmt.Errorf("stdin PIN is empty")
+ }
+ return p, nil
+ default:
+ return "", fmt.Errorf("PIV PIN required; use --pin-stdin, --pin-file, or --pin")
+ }
+}
+
+func logStatus(quiet bool, format string, args ...any) {
+ if quiet {
+ return
+ }
+ fmt.Fprintf(os.Stderr, format+"\n", args...)
+}
+
+func containsSignatureBlock(data []byte) bool {
+ s := string(data)
+ for algo := range supportedAlgorithms {
+ if strings.Contains(s, "-----BEGIN "+algo+" SIGNATURE-----") {
+ return true
+ }
+ }
+ return false
+}
+
+func signData(data []byte, pin string) (string, error) {
+ pinGuard := memguard.NewBufferFromBytes([]byte(pin))
+ defer pinGuard.Destroy()
+
+ normalizedData := normalizeToRFCCompliantCRLF(data)
+ sig, algo, err := signDataInternal(pinGuard.Bytes(), normalizedData)
+ if err != nil {
+ return "", fmt.Errorf("signing failed: %w", err)
+ }
+
+ sep := "\r\n"
+ if len(normalizedData) > 0 {
+ last := string(normalizedData[len(normalizedData)-1:])
+ if last == "\n" && !(len(normalizedData) >= 2 && string(normalizedData[len(normalizedData)-2:]) == "\r\n") {
+ sep = "\n"
+ }
+ }
+
+ return string(normalizedData) + sep +
+ "-----BEGIN " + algo + " SIGNATURE-----" + sep +
+ formatSignatureRFC(sig) +
+ "-----END " + algo + " SIGNATURE-----" + sep, nil
+}
+
+func signDataInternal(pin, data []byte) (string, string, error) {
+ yk, err := openYubiKey(yubiKeyIndex)
+ if err != nil {
+ return "", "", err
+ }
+ defer yk.Close()
+
+ cert, err := yk.Certificate(piv.SlotSignature)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to get certificate from signature slot: %w", err)
+ }
+
+ if ed25519PubKey, ok := cert.PublicKey.(ed25519.PublicKey); ok {
+ hash := sha256.Sum256(data)
+ return signEd25519Data(string(pin), hash[:], ed25519PubKey, yk)
+ }
+
+ pubKey, ok := cert.PublicKey.(*ecdsa.PublicKey)
+ if !ok {
+ return "", "", fmt.Errorf("public key is not ECDSA or Ed25519")
+ }
+
+ algorithm, exists := curveToAlgorithm[pubKey.Curve]
+ if !exists {
+ return "", "", fmt.Errorf("unsupported curve: %v", pubKey.Curve)
+ }
+
+ hashFunc := curveToHash[pubKey.Curve]
+ var digest []byte
+ switch hashFunc {
+ case crypto.SHA256:
+ h := sha256.New()
+ h.Write(data)
+ digest = h.Sum(nil)
+ case crypto.SHA384:
+ h := sha512.New384()
+ h.Write(data)
+ digest = h.Sum(nil)
+ default:
+ return "", "", fmt.Errorf("unsupported hash algorithm for curve")
+ }
+
+ auth := piv.KeyAuth{PIN: string(pin)}
+ priv, err := yk.PrivateKey(piv.SlotSignature, cert.PublicKey, auth)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to get private key: %w", err)
+ }
+
+ signer, ok := priv.(crypto.Signer)
+ if !ok {
+ return "", "", fmt.Errorf("key does not implement crypto.Signer")
+ }
+
+ asn1sig, err := signer.Sign(rand.Reader, digest, nil)
+ if err != nil {
+ return "", "", fmt.Errorf("signing failed: %w", err)
+ }
+
+ var sig ecSignature
+ if _, err := asn1.Unmarshal(asn1sig, &sig); err != nil {
+ return "", "", fmt.Errorf("ASN.1 unmarshal failed: %w", err)
+ }
+
+ curveSize := (pubKey.Curve.Params().BitSize + 7) / 8
+ var raw []byte
+ raw = append(raw, safePad(pubKey.X.Bytes(), curveSize)...)
+ raw = append(raw, safePad(pubKey.Y.Bytes(), curveSize)...)
+ raw = append(raw, safePad(sig.R.Bytes(), curveSize)...)
+ raw = append(raw, safePad(sig.S.Bytes(), curveSize)...)
+
+ return hex.EncodeToString(raw), algorithm, nil
+}
+
+func signEd25519Data(pin string, hash []byte, pubKey ed25519.PublicKey, yk *piv.YubiKey) (string, string, error) {
+ auth := piv.KeyAuth{PIN: pin}
+ priv, err := yk.PrivateKey(piv.SlotSignature, pubKey, auth)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to get private key: %w", err)
+ }
+
+ signer, ok := priv.(crypto.Signer)
+ if !ok {
+ return "", "", fmt.Errorf("key does not implement crypto.Signer")
+ }
+
+ signature, err := signer.Sign(rand.Reader, hash, crypto.Hash(0))
+ if err != nil {
+ return "", "", fmt.Errorf("Ed25519 signing failed: %w", err)
+ }
+
+ combined := append(pubKey, signature...)
+ return hex.EncodeToString(combined), AlgorithmED25519, nil
+}
+
+func verifySignedData(data []byte) (*signatureInfo, error) {
+ s := string(normalizeToRFCCompliantCRLF(data))
+ var algorithm string
+ beginIndex := -1
+ beginMarker := ""
+
+ for algo := range supportedAlgorithms {
+ marker := "-----BEGIN " + algo + " SIGNATURE-----"
+ if idx := strings.Index(s, marker); idx >= 0 {
+ algorithm = algo
+ beginIndex = idx
+ beginMarker = marker
+ break
+ }
+ }
+ if algorithm == "" {
+ return nil, fmt.Errorf("no supported signature block found")
+ }
+
+ endMarker := "-----END " + algorithm + " SIGNATURE-----"
+ hexStart := beginIndex + len(beginMarker)
+ endRel := strings.Index(s[hexStart:], endMarker)
+ if endRel < 0 {
+ return nil, fmt.Errorf("invalid signature block format")
+ }
+
+ original := []byte(s[:beginIndex])
+ original = bytesTrimOneLineEnding(original)
+ hexPart := s[hexStart : hexStart+endRel]
+ hexPart = regexp.MustCompile(`[\r\n\s\t]+`).ReplaceAllString(hexPart, "")
+
+ combined, err := hex.DecodeString(hexPart)
+ if err != nil {
+ return nil, fmt.Errorf("hex decode failed: %w", err)
+ }
+
+ var verificationErr error
+ switch algorithm {
+ case AlgorithmED25519:
+ hash := sha256.Sum256(original)
+ verificationErr = verifyEd25519(hash[:], combined)
+ case AlgorithmECCP256, AlgorithmECCP384:
+ verificationErr = verifyECDSA(original, combined, algorithm)
+ default:
+ verificationErr = fmt.Errorf("unsupported algorithm: %s", algorithm)
+ }
+ if verificationErr != nil {
+ return nil, verificationErr
+ }
+
+ publicKey, err := extractPublicKeyFromSignature(combined, algorithm)
+ if err != nil {
+ return nil, err
+ }
+ return &signatureInfo{
+ Algorithm: algorithm,
+ OriginalMessage: original,
+ PublicKey: publicKey,
+ }, nil
+}
+
+func extractPublicKeyFromSignature(combined []byte, algorithm string) ([]byte, error) {
+ switch algorithm {
+ case AlgorithmED25519:
+ if len(combined) != Ed25519CombinedSize {
+ return nil, fmt.Errorf("invalid Ed25519 signature block")
+ }
+ return combined[:Ed25519PublicKeySize], nil
+ case AlgorithmECCP256, AlgorithmECCP384:
+ var curve elliptic.Curve
+ switch algorithm {
+ case AlgorithmECCP256:
+ curve = elliptic.P256()
+ case AlgorithmECCP384:
+ curve = elliptic.P384()
+ }
+ curveSize := (curve.Params().BitSize + 7) / 8
+ expectedBytes := 4 * curveSize
+ if len(combined) != expectedBytes {
+ return nil, fmt.Errorf("invalid signature block size: expected %d, got %d", expectedBytes, len(combined))
+ }
+ return combined[:2*curveSize], nil
+ default:
+ return nil, fmt.Errorf("unsupported algorithm: %s", algorithm)
+ }
+}
+
+func verifyEd25519(dataHash, combined []byte) error {
+ if len(combined) != Ed25519CombinedSize {
+ return fmt.Errorf("invalid Ed25519 signature block")
+ }
+ publicKey := combined[:Ed25519PublicKeySize]
+ signature := combined[Ed25519PublicKeySize:]
+ if !ed25519.Verify(ed25519.PublicKey(publicKey), dataHash, signature) {
+ return fmt.Errorf("Ed25519 signature verification failed")
+ }
+ return nil
+}
+
+func verifyECDSA(data, combined []byte, algorithm string) error {
+ var curve elliptic.Curve
+ var hashFunc crypto.Hash
+ switch algorithm {
+ case AlgorithmECCP256:
+ curve = elliptic.P256()
+ hashFunc = crypto.SHA256
+ case AlgorithmECCP384:
+ curve = elliptic.P384()
+ hashFunc = crypto.SHA384
+ default:
+ return fmt.Errorf("unsupported ECDSA algorithm: %s", algorithm)
+ }
+
+ curveSize := (curve.Params().BitSize + 7) / 8
+ expectedBytes := 4 * curveSize
+ if len(combined) != expectedBytes {
+ return fmt.Errorf("invalid signature block size: expected %d, got %d", expectedBytes, len(combined))
+ }
+
+ x := new(big.Int).SetBytes(safePad(combined[0:curveSize], curveSize))
+ y := new(big.Int).SetBytes(safePad(combined[curveSize:2*curveSize], curveSize))
+ r := new(big.Int).SetBytes(safePad(combined[2*curveSize:3*curveSize], curveSize))
+ s := new(big.Int).SetBytes(safePad(combined[3*curveSize:], curveSize))
+
+ if !curve.IsOnCurve(x, y) {
+ return fmt.Errorf("public key point (X,Y) is not on the curve %s", curve.Params().Name)
+ }
+
+ pub := &ecdsa.PublicKey{Curve: curve, X: x, Y: y}
+ var digest []byte
+ switch hashFunc {
+ case crypto.SHA256:
+ h := sha256.New()
+ h.Write(data)
+ digest = h.Sum(nil)
+ case crypto.SHA384:
+ h := sha512.New384()
+ h.Write(data)
+ digest = h.Sum(nil)
+ }
+
+ if !ecdsa.Verify(pub, digest, r, s) {
+ return fmt.Errorf("signature verification failed")
+ }
+ return nil
+}
+
+func encryptData(data []byte, pubKeyFile string) (string, error) {
+ pubKey, err := loadRSAPublicKey(pubKeyFile)
+ if err != nil {
+ return "", fmt.Errorf("failed to load public key: %w", err)
+ }
+
+ aesKeyGuard := memguard.NewBuffer(32)
+ defer aesKeyGuard.Destroy()
+ if _, err := rand.Read(aesKeyGuard.Bytes()); err != nil {
+ return "", fmt.Errorf("failed to generate AES key: %w", err)
+ }
+
+ encryptedKey, err := rsa.EncryptPKCS1v15(rand.Reader, pubKey, aesKeyGuard.Bytes())
+ if err != nil {
+ return "", fmt.Errorf("RSA encryption failed: %w", err)
+ }
+ defer memguard.WipeBytes(encryptedKey)
+
+ encryptedData, err := encryptAES(data, aesKeyGuard.Bytes())
+ if err != nil {
+ return "", fmt.Errorf("AES encryption failed: %w", err)
+ }
+ defer memguard.WipeBytes(encryptedData)
+
+ combined := append(encryptedKey, encryptedData...)
+ defer memguard.WipeBytes(combined)
+
+ return formatBase64RFC(base64.StdEncoding.EncodeToString(combined)), nil
+}
+
+func decryptData(data []byte, pin string) ([]byte, error) {
+ pinGuard := memguard.NewBufferFromBytes([]byte(pin))
+ defer pinGuard.Destroy()
+
+ combined, err := decodeBase64Clean(data)
+ if err != nil {
+ return nil, err
+ }
+ defer memguard.WipeBytes(combined)
+
+ yk, err := openYubiKey(yubiKeyIndex)
+ if err != nil {
+ return nil, fmt.Errorf("failed to open YubiKey: %w", err)
+ }
+ defer yk.Close()
+
+ cert, err := yk.Certificate(piv.SlotKeyManagement)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get certificate from slot 9d: %w", err)
+ }
+
+ rsaPubKey, ok := cert.PublicKey.(*rsa.PublicKey)
+ if !ok {
+ return nil, fmt.Errorf("certificate does not contain RSA public key")
+ }
+ if err := checkRSASecurity(rsaPubKey, "on YubiKey"); err != nil {
+ return nil, err
+ }
+
+ keySize := rsaPubKey.Size()
+ if len(combined) < keySize {
+ return nil, fmt.Errorf("ciphertext too short")
+ }
+
+ encryptedKey := combined[:keySize]
+ encryptedData := combined[keySize:]
+
+ auth := piv.KeyAuth{PIN: string(pinGuard.Bytes())}
+ priv, err := yk.PrivateKey(piv.SlotKeyManagement, cert.PublicKey, auth)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get private key: %w", err)
+ }
+
+ decrypter, ok := priv.(crypto.Decrypter)
+ if !ok {
+ return nil, fmt.Errorf("private key does not support decryption")
+ }
+
+ decryptedPayload, err := decrypter.Decrypt(rand.Reader, encryptedKey, nil)
+ if err != nil {
+ return nil, fmt.Errorf("RSA decryption failed: %w", err)
+ }
+ defer memguard.WipeBytes(decryptedPayload)
+
+ if len(decryptedPayload) != 32 {
+ return nil, fmt.Errorf("invalid AES key size")
+ }
+
+ decryptedData, err := decryptAES(encryptedData, decryptedPayload)
+ if err != nil {
+ return nil, fmt.Errorf("AES decryption failed: %w", err)
+ }
+ return decryptedData, nil
+}
+
+func checkRSASecurity(pubKey *rsa.PublicKey, context string) error {
+ keySize := pubKey.N.BitLen()
+ if keySize < minRSABits {
+ return fmt.Errorf("insecure %d-bit RSA key %s - minimum is %d-bit", keySize, context, minRSABits)
+ }
+ if _, supported := supportedRSASizes[keySize]; !supported {
+ fmt.Fprintf(os.Stderr, "WARNING: %d-bit RSA key %s - supported sizes are 2048, 3072, 4096 bits\n", keySize, context)
+ }
+ if keySize == 1024 {
+ fmt.Fprintf(os.Stderr, "CRITICAL WARNING: 1024-bit RSA keys %s are insecure and should not be used!\n", context)
+ }
+ return nil
+}
+
+func loadRSAPublicKey(filename string) (*rsa.PublicKey, error) {
+ data, err := os.ReadFile(filename)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read public key file: %w", err)
+ }
+ defer memguard.WipeBytes(data)
+
+ block, _ := pem.Decode(data)
+ if block == nil {
+ return nil, fmt.Errorf("no data found in file")
+ }
+
+ switch block.Type {
+ case "CERTIFICATE":
+ cert, err := x509.ParseCertificate(block.Bytes)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse certificate: %w", err)
+ }
+ pubKey, ok := cert.PublicKey.(*rsa.PublicKey)
+ if !ok {
+ return nil, fmt.Errorf("certificate does not contain RSA public key")
+ }
+ if err := checkRSASecurity(pubKey, "in certificate "+filename); err != nil {
+ return nil, err
+ }
+ return pubKey, nil
+ case "PUBLIC KEY":
+ pubInterface, err := x509.ParsePKIXPublicKey(block.Bytes)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse public key: %w", err)
+ }
+ pubKey, ok := pubInterface.(*rsa.PublicKey)
+ if !ok {
+ return nil, fmt.Errorf("not an RSA public key")
+ }
+ if err := checkRSASecurity(pubKey, "in file "+filename); err != nil {
+ return nil, err
+ }
+ return pubKey, nil
+ case "RSA PUBLIC KEY":
+ pubKey, err := x509.ParsePKCS1PublicKey(block.Bytes)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse RSA public key: %w", err)
+ }
+ if err := checkRSASecurity(pubKey, "in file "+filename); err != nil {
+ return nil, err
+ }
+ return pubKey, nil
+ default:
+ return nil, fmt.Errorf("unsupported type: %s, expected CERTIFICATE, PUBLIC KEY or RSA PUBLIC KEY", block.Type)
+ }
+}
+
+func encryptAES(data, key []byte) ([]byte, error) {
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, err
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, err
+ }
+ nonce := make([]byte, gcm.NonceSize())
+ if _, err := rand.Read(nonce); err != nil {
+ return nil, err
+ }
+ return gcm.Seal(nonce, nonce, data, nil), nil
+}
+
+func decryptAES(data, key []byte) ([]byte, error) {
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, err
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, err
+ }
+ nonceSize := gcm.NonceSize()
+ if len(data) < nonceSize {
+ return nil, fmt.Errorf("ciphertext too short")
+ }
+ nonce, ciphertext := data[:nonceSize], data[nonceSize:]
+ plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
+ if err != nil {
+ return nil, err
+ }
+ return plaintext, nil
+}
+
+func openYubiKey(index int) (*piv.YubiKey, error) {
+ cards, err := piv.Cards()
+ if err != nil {
+ return nil, fmt.Errorf("failed to list cards: %w", err)
+ }
+ if len(cards) == 0 {
+ return nil, fmt.Errorf("no smart card found")
+ }
+
+ count := 0
+ for _, card := range cards {
+ if strings.Contains(strings.ToLower(card), "yubikey") {
+ if count == index {
+ return piv.Open(card)
+ }
+ count++
+ }
+ }
+ return nil, fmt.Errorf("no YubiKey found at index %d", index)
+}
+
+func normalizeToRFCCompliantCRLF(data []byte) []byte {
+ s := string(data)
+ s = strings.ReplaceAll(s, "\r\n", "\n")
+ s = strings.ReplaceAll(s, "\r", "\n")
+ s = strings.ReplaceAll(s, "\n", "\r\n")
+ return []byte(s)
+}
+
+func formatSignatureRFC(sig string) string {
+ var result strings.Builder
+ for i := 0; i < len(sig); i += 64 {
+ end := i + 64
+ if end > len(sig) {
+ end = len(sig)
+ }
+ result.WriteString(sig[i:end])
+ result.WriteString("\r\n")
+ }
+ return result.String()
+}
+
+func formatBase64RFC(data string) string {
+ var result strings.Builder
+ for i := 0; i < len(data); i += 76 {
+ end := i + 76
+ if end > len(data) {
+ end = len(data)
+ }
+ result.WriteString(data[i:end])
+ result.WriteString("\r\n")
+ }
+ return result.String()
+}
+
+func securePadMessage(data []byte) []byte {
+ const blockSize = 4096
+ paddingNeeded := blockSize - (len(data) % blockSize)
+ if paddingNeeded == blockSize {
+ return data
+ }
+ paddedData := make([]byte, len(data)+paddingNeeded)
+ copy(paddedData, data)
+ paddedData[len(data)] = 0x80
+ return paddedData
+}
+
+func secureUnpadMessage(data []byte) ([]byte, error) {
+ if len(data) == 0 {
+ return nil, errors.New("cannot unpad empty data")
+ }
+ if len(data)%4096 != 0 {
+ return nil, errors.New("invalid block size for unpadding")
+ }
+
+ lastIndex := -1
+ for i := len(data) - 1; i >= 0; i-- {
+ if data[i] == 0x80 {
+ lastIndex = i
+ break
+ }
+ if data[i] != 0x00 {
+ return nil, errors.New("invalid padding format: unexpected non-zero byte")
+ }
+ }
+ if lastIndex == -1 {
+ return nil, errors.New("no padding marker found")
+ }
+ return data[:lastIndex], nil
+}
+
+func safePad(b []byte, size int) []byte {
+ if len(b) > size {
+ return b[len(b)-size:]
+ }
+ return append(make([]byte, size-len(b)), b...)
+}
+
+func bytesTrimOneLineEnding(data []byte) []byte {
+ if len(data) >= 2 && data[len(data)-2] == '\r' && data[len(data)-1] == '\n' {
+ return data[:len(data)-2]
+ }
+ if len(data) >= 1 && data[len(data)-1] == '\n' {
+ return data[:len(data)-1]
+ }
+ return data
+}
+
+func decodeBase64Clean(data []byte) ([]byte, error) {
+ s := string(data)
+ s = strings.ReplaceAll(s, "\r", "")
+ s = strings.ReplaceAll(s, "\n", "")
+ s = strings.ReplaceAll(s, " ", "")
+ decoded, err := base64.StdEncoding.DecodeString(s)
+ if err != nil {
+ return nil, fmt.Errorf("base64 decode failed: %w", err)
+ }
+ return decoded, nil
+}
diff --git a/yubicrypt.go b/yubicrypt.go
index f614750..abc0c74 100644
--- a/yubicrypt.go
+++ b/yubicrypt.go
@@ -1,4 +1,7 @@
-package main
+//go:build gui
+// +build gui
+
+package main
import (
"crypto"