diff options
| author | Gab Virebent <gabriel1@virebent.art> | 2026-07-13 00:40:19 +0200 |
|---|---|---|
| committer | Gab Virebent <gabriel1@virebent.art> | 2026-07-13 00:40:19 +0200 |
| commit | 5fa37b6b395981799924ecf0e432e45998169225 (patch) | |
| tree | 76e5a3d6ee04eefc5189510e6b993271386a9563 /cli.go | |
| parent | c69a9d6af8aec07cc22ac15228a4b99825ac19c7 (diff) | |
| download | yubicrpt-cli-main.tar.gz yubicrpt-cli-main.tar.xz yubicrpt-cli-main.zip | |
Diffstat (limited to 'cli.go')
| -rw-r--r-- | cli.go | 1041 |
1 files changed, 1041 insertions, 0 deletions
@@ -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 +} |
