summaryrefslogtreecommitdiffstats
path: root/internal/cryptokit
diff options
context:
space:
mode:
Diffstat (limited to 'internal/cryptokit')
-rw-r--r--internal/cryptokit/age.go91
-rw-r--r--internal/cryptokit/cryptokit_test.go156
-rw-r--r--internal/cryptokit/ed25519.go102
-rw-r--r--internal/cryptokit/openpgp.go240
-rw-r--r--internal/cryptokit/yubicrypt.go237
-rw-r--r--internal/cryptokit/yubicrypt_test.go36
6 files changed, 862 insertions, 0 deletions
diff --git a/internal/cryptokit/age.go b/internal/cryptokit/age.go
new file mode 100644
index 0000000..525b1fa
--- /dev/null
+++ b/internal/cryptokit/age.go
@@ -0,0 +1,91 @@
+package cryptokit
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+
+ "filippo.io/age"
+ "filippo.io/age/agessh"
+ agearmor "filippo.io/age/armor"
+)
+
+const maxCryptoMessageBytes = 64 << 20
+
+func parseAgeRecipient(value string) (age.Recipient, error) {
+ value = strings.TrimSpace(value)
+ if recipient, err := age.ParseX25519Recipient(value); err == nil {
+ return recipient, nil
+ }
+ if recipient, err := agessh.ParseRecipient(value); err == nil {
+ return recipient, nil
+ }
+ return nil, errors.New("unsupported age recipient: expected native X25519 or SSH Ed25519/RSA")
+}
+
+func parseAgeIdentity(value string) (age.Identity, error) {
+ value = strings.TrimSpace(value)
+ if identity, err := age.ParseX25519Identity(value); err == nil {
+ return identity, nil
+ }
+ if identity, err := agessh.ParseIdentity([]byte(value)); err == nil {
+ return identity, nil
+ }
+ return nil, errors.New("unsupported age identity: expected native X25519 or SSH Ed25519/RSA private key")
+}
+
+// EncryptAge encrypts to a user-provided native age X25519 recipient or SSH
+// Ed25519/RSA recipient and returns ASCII-armored age text.
+func EncryptAge(message []byte, recipientKey string) ([]byte, error) {
+ recipient, err := parseAgeRecipient(recipientKey)
+ if err != nil {
+ return nil, fmt.Errorf("parse age recipient: %w", err)
+ }
+ var output bytes.Buffer
+ armored := agearmor.NewWriter(&output)
+ writer, err := age.Encrypt(armored, recipient)
+ if err != nil {
+ _ = armored.Close()
+ return nil, fmt.Errorf("create age encryption: %w", err)
+ }
+ if _, err := writer.Write(message); err != nil {
+ _ = writer.Close()
+ _ = armored.Close()
+ return nil, fmt.Errorf("write age message: %w", err)
+ }
+ if err := writer.Close(); err != nil {
+ _ = armored.Close()
+ return nil, fmt.Errorf("close age message: %w", err)
+ }
+ if err := armored.Close(); err != nil {
+ return nil, fmt.Errorf("close age armor: %w", err)
+ }
+ return output.Bytes(), nil
+}
+
+// DecryptAge decrypts an armored or binary age message with a user-provided
+// native X25519 identity or an unencrypted SSH Ed25519/RSA private key.
+func DecryptAge(message []byte, identityKey string) ([]byte, error) {
+ identity, err := parseAgeIdentity(identityKey)
+ if err != nil {
+ return nil, fmt.Errorf("parse age identity: %w", err)
+ }
+ var input io.Reader = bytes.NewReader(message)
+ if bytes.HasPrefix(bytes.TrimSpace(message), []byte(agearmor.Header)) {
+ input = agearmor.NewReader(input)
+ }
+ reader, err := age.Decrypt(input, identity)
+ if err != nil {
+ return nil, fmt.Errorf("create age decryption: %w", err)
+ }
+ plaintext, err := io.ReadAll(io.LimitReader(reader, maxCryptoMessageBytes+1))
+ if err != nil {
+ return nil, fmt.Errorf("read age message: %w", err)
+ }
+ if len(plaintext) > maxCryptoMessageBytes {
+ return nil, errors.New("decrypted age message exceeds the 64 MiB limit")
+ }
+ return plaintext, nil
+}
diff --git a/internal/cryptokit/cryptokit_test.go b/internal/cryptokit/cryptokit_test.go
new file mode 100644
index 0000000..928120e
--- /dev/null
+++ b/internal/cryptokit/cryptokit_test.go
@@ -0,0 +1,156 @@
+package cryptokit
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/rsa"
+ "encoding/base64"
+ "encoding/pem"
+ "os"
+ "os/exec"
+ "testing"
+
+ "filippo.io/age"
+ "golang.org/x/crypto/ssh"
+)
+
+func TestEd25519SignVerifyAcceptsSeedEncoding(t *testing.T) {
+ public, private, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ message := []byte("Aegis cryptographic test")
+ signature, err := SignEd25519(message, base64.StdEncoding.EncodeToString(private.Seed()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := VerifyEd25519(message, signature, base64.StdEncoding.EncodeToString(public)); err != nil {
+ t.Fatal(err)
+ }
+ if err := VerifyEd25519([]byte("tampered"), signature, base64.StdEncoding.EncodeToString(public)); err == nil {
+ t.Fatal("tampered message verified")
+ }
+}
+
+func TestEd25519PublicKeyAndFingerprint(t *testing.T) {
+ public, private, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ derived, err := Ed25519PublicKey(base64.StdEncoding.EncodeToString(private.Seed()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if derived != base64.StdEncoding.EncodeToString(public) {
+ t.Fatalf("derived public key = %q, want %q", derived, base64.StdEncoding.EncodeToString(public))
+ }
+ fingerprint, err := Ed25519PublicKeyFingerprint(derived)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(fingerprint) != len("sha256:")+64 || fingerprint[:len("sha256:")] != "sha256:" {
+ t.Fatalf("unexpected fingerprint %q", fingerprint)
+ }
+}
+
+func TestAgeRoundTrip(t *testing.T) {
+ identity, err := age.GenerateX25519Identity()
+ if err != nil {
+ t.Fatal(err)
+ }
+ ciphertext, err := EncryptAge([]byte("age test"), identity.Recipient().String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ plaintext, err := DecryptAge(ciphertext, identity.String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(plaintext) != "age test" {
+ t.Fatalf("unexpected plaintext %q", plaintext)
+ }
+}
+
+func TestAgeSSHCompatibility(t *testing.T) {
+ testKey := func(private any) {
+ signer, err := ssh.NewSignerFromKey(private)
+ if err != nil {
+ t.Fatal(err)
+ }
+ privatePEM, err := ssh.MarshalPrivateKey(private, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ciphertext, err := EncryptAge([]byte("age SSH test"), string(ssh.MarshalAuthorizedKey(signer.PublicKey())))
+ if err != nil {
+ t.Fatal(err)
+ }
+ plaintext, err := DecryptAge(ciphertext, string(pem.EncodeToMemory(privatePEM)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(plaintext) != "age SSH test" {
+ t.Fatalf("unexpected plaintext %q", plaintext)
+ }
+ }
+ _, edPrivate, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ testKey(edPrivate)
+ rsaPrivate, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+ testKey(rsaPrivate)
+}
+
+func TestOpenPGPRoundTripWithUserKeyMaterial(t *testing.T) {
+ gpg, err := exec.LookPath("gpg")
+ if err != nil {
+ t.Skip("gpg is not installed")
+ }
+ home := t.TempDir()
+ if err := os.Chmod(home, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ run := func(args []string, input []byte) ([]byte, error) {
+ cmd := exec.Command(gpg, append([]string{"--batch", "--no-tty", "--no-options", "--homedir", home}, args...)...)
+ cmd.Stdin = bytes.NewReader(input)
+ return cmd.Output()
+ }
+ if _, err := run([]string{"--pinentry-mode", "loopback", "--passphrase", "", "--quick-generate-key", "Aegis Test <aegis@example.invalid>", "rsa2048", "sign", "1d"}, nil); err != nil {
+ t.Skipf("gpg cannot create an ephemeral test key: %v", err)
+ }
+ if _, err := run([]string{"--pinentry-mode", "loopback", "--passphrase", "", "--quick-add-key", "aegis@example.invalid", "rsa2048", "encrypt", "1d"}, nil); err != nil {
+ t.Skipf("gpg cannot create an ephemeral encryption subkey: %v", err)
+ }
+ publicKey, err := run([]string{"--armor", "--export", "aegis@example.invalid"}, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ privateKey, err := run([]string{"--armor", "--export-secret-keys", "aegis@example.invalid"}, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ message := []byte("OpenPGP compatibility test")
+ signature, err := SignOpenPGPDetached(message, string(privateKey))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := VerifyOpenPGPDetached(message, signature, string(publicKey)); err != nil {
+ t.Fatal(err)
+ }
+ ciphertext, err := EncryptOpenPGP(message, string(publicKey), string(privateKey))
+ if err != nil {
+ t.Fatal(err)
+ }
+ plaintext, err := DecryptOpenPGP([]byte(ciphertext), string(privateKey))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(plaintext) != string(message) {
+ t.Fatalf("unexpected plaintext %q", plaintext)
+ }
+}
diff --git a/internal/cryptokit/ed25519.go b/internal/cryptokit/ed25519.go
new file mode 100644
index 0000000..0e08503
--- /dev/null
+++ b/internal/cryptokit/ed25519.go
@@ -0,0 +1,102 @@
+package cryptokit
+
+import (
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "strings"
+)
+
+func decodeKeyText(value string, expected ...int) ([]byte, error) {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return nil, errors.New("key material is required")
+ }
+ decoded, base64Err := base64.StdEncoding.DecodeString(value)
+ if base64Err != nil {
+ decoded, base64Err = base64.RawStdEncoding.DecodeString(value)
+ }
+ if base64Err != nil {
+ var hexErr error
+ decoded, hexErr = hex.DecodeString(value)
+ if hexErr != nil {
+ return nil, errors.New("key material is not valid base64 or hexadecimal")
+ }
+ }
+ for _, size := range expected {
+ if len(decoded) == size {
+ return decoded, nil
+ }
+ }
+ return nil, fmt.Errorf("unexpected key length %d bytes", len(decoded))
+}
+
+// SignEd25519 signs with a raw Ed25519 private key supplied as base64 or hex.
+// Both the 32-byte seed and 64-byte private-key encodings are accepted.
+func SignEd25519(message []byte, privateKey string) (string, error) {
+ key, err := decodeKeyText(privateKey, ed25519.SeedSize, ed25519.PrivateKeySize)
+ if err != nil {
+ return "", err
+ }
+ if len(key) == ed25519.SeedSize {
+ key = ed25519.NewKeyFromSeed(key)
+ }
+ signature := ed25519.Sign(ed25519.PrivateKey(key), message)
+ return base64.StdEncoding.EncodeToString(signature), nil
+}
+
+// Ed25519PublicKey derives the public key from a raw Ed25519 private key and
+// returns it as standard base64. Both the 32-byte seed and 64-byte private-key
+// encodings are accepted.
+func Ed25519PublicKey(privateKey string) (string, error) {
+ key, err := decodeKeyText(privateKey, ed25519.SeedSize, ed25519.PrivateKeySize)
+ if err != nil {
+ return "", err
+ }
+ if len(key) == ed25519.SeedSize {
+ key = ed25519.NewKeyFromSeed(key)
+ }
+ publicKey := ed25519.PrivateKey(key).Public().(ed25519.PublicKey)
+ return base64.StdEncoding.EncodeToString(publicKey), nil
+}
+
+// Ed25519PublicKeyFingerprint returns a stable SHA-256 fingerprint for a
+// base64 or hexadecimal Ed25519 public key.
+func Ed25519PublicKeyFingerprint(publicKey string) (string, error) {
+ key, err := decodeKeyText(publicKey, ed25519.PublicKeySize)
+ if err != nil {
+ return "", err
+ }
+ digest := sha256.Sum256(key)
+ return "sha256:" + hex.EncodeToString(digest[:]), nil
+}
+
+// CanonicalEd25519PublicKey accepts a raw Ed25519 public key as base64 or
+// hexadecimal and returns the protocol representation used by VFace.
+func CanonicalEd25519PublicKey(publicKey string) (string, error) {
+ key, err := decodeKeyText(publicKey, ed25519.PublicKeySize)
+ if err != nil {
+ return "", fmt.Errorf("decode Ed25519 public key: %w", err)
+ }
+ return base64.StdEncoding.EncodeToString(key), nil
+}
+
+// VerifyEd25519 verifies a base64 or hexadecimal Ed25519 signature against a
+// raw public key supplied as base64 or hex.
+func VerifyEd25519(message []byte, signature, publicKey string) error {
+ sig, err := decodeKeyText(signature, ed25519.SignatureSize)
+ if err != nil {
+ return fmt.Errorf("decode Ed25519 signature: %w", err)
+ }
+ key, err := decodeKeyText(publicKey, ed25519.PublicKeySize)
+ if err != nil {
+ return fmt.Errorf("decode Ed25519 public key: %w", err)
+ }
+ if !ed25519.Verify(ed25519.PublicKey(key), message, sig) {
+ return errors.New("Ed25519 signature verification failed")
+ }
+ return nil
+}
diff --git a/internal/cryptokit/openpgp.go b/internal/cryptokit/openpgp.go
new file mode 100644
index 0000000..bf766b5
--- /dev/null
+++ b/internal/cryptokit/openpgp.go
@@ -0,0 +1,240 @@
+package cryptokit
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+const maxOpenPGPMessageBytes = 64 << 20
+
+type limitedBuffer struct {
+ buffer bytes.Buffer
+ limit int
+}
+
+func (b *limitedBuffer) Write(value []byte) (int, error) {
+ if b.buffer.Len() < b.limit {
+ remaining := b.limit - b.buffer.Len()
+ if len(value) > remaining {
+ _, _ = b.buffer.Write(value[:remaining])
+ } else {
+ _, _ = b.buffer.Write(value)
+ }
+ }
+ return len(value), nil
+}
+
+// OpenPGP support is delegated to the system GnuPG binary. This keeps Aegis
+// compatible with the user's existing OpenPGP installation, including RSA,
+// Ed25519 and X25519 keys, without embedding another keyring. Key material is
+// imported into a temporary 0700 GNUPGHOME and removed after each operation.
+
+func gpgBinary() (string, error) {
+ path, err := exec.LookPath("gpg")
+ if err != nil {
+ return "", errors.New("gpg is required for OpenPGP operations")
+ }
+ return path, nil
+}
+
+func withGPG(operation func(home string) error) error {
+ if _, err := gpgBinary(); err != nil {
+ return err
+ }
+ home, err := os.MkdirTemp("", "aegis-gpg-")
+ if err != nil {
+ return fmt.Errorf("create temporary OpenPGP home: %w", err)
+ }
+ defer os.RemoveAll(home)
+ if err := os.Chmod(home, 0o700); err != nil {
+ return fmt.Errorf("protect temporary OpenPGP home: %w", err)
+ }
+ return operation(home)
+}
+
+func runGPG(home string, args []string, input []byte, outputLimit int) ([]byte, string, error) {
+ binary, err := gpgBinary()
+ if err != nil {
+ return nil, "", err
+ }
+ base := []string{
+ "--batch", "--no-tty", "--no-options", "--no-auto-check-trustdb",
+ "--homedir", home,
+ }
+ cmd := exec.Command(binary, append(base, args...)...)
+ cmd.Stdin = bytes.NewReader(input)
+ var stdout limitedBuffer
+ var stderr bytes.Buffer
+ if outputLimit <= 0 {
+ outputLimit = maxOpenPGPMessageBytes
+ }
+ stdout.limit = outputLimit + 1
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ return stdout.buffer.Bytes(), stderr.String(), fmt.Errorf("gpg %s: %w: %s", args[0], err, cleanGPGError(stderr.String()))
+ }
+ if stdout.buffer.Len() > outputLimit {
+ return nil, stderr.String(), fmt.Errorf("OpenPGP output exceeds the %d MiB limit", outputLimit/(1<<20))
+ }
+ return stdout.buffer.Bytes(), stderr.String(), nil
+}
+
+func cleanGPGError(value string) string {
+ value = strings.TrimSpace(value)
+ if len(value) > 1000 {
+ return value[:1000] + "..."
+ }
+ return value
+}
+
+func importOpenPGP(home, keyMaterial string) error {
+ if strings.TrimSpace(keyMaterial) == "" {
+ return errors.New("OpenPGP key material is required")
+ }
+ _, stderr, err := runGPG(home, []string{"--import"}, []byte(keyMaterial), 1<<20)
+ if err != nil {
+ return fmt.Errorf("import OpenPGP key: %w", err)
+ }
+ if strings.Contains(stderr, "no valid OpenPGP data found") {
+ return errors.New("no valid OpenPGP key found")
+ }
+ return nil
+}
+
+func listFingerprints(home string) ([]string, error) {
+ output, _, err := runGPG(home, []string{"--with-colons", "--list-keys"}, nil, 1<<20)
+ if err != nil {
+ return nil, fmt.Errorf("list OpenPGP keys: %w", err)
+ }
+ var fingerprints []string
+ for _, line := range strings.Split(string(output), "\n") {
+ fields := strings.Split(line, ":")
+ if len(fields) > 9 && fields[0] == "fpr" && fields[9] != "" {
+ fingerprints = append(fingerprints, fields[9])
+ }
+ }
+ if len(fingerprints) == 0 {
+ return nil, errors.New("no usable OpenPGP key found")
+ }
+ return fingerprints, nil
+}
+
+func writeGPGFile(home, name string, content []byte) (string, error) {
+ path := filepath.Join(home, name)
+ file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
+ if err != nil {
+ return "", fmt.Errorf("create temporary OpenPGP file: %w", err)
+ }
+ if _, err := file.Write(content); err != nil {
+ _ = file.Close()
+ return "", fmt.Errorf("write temporary OpenPGP file: %w", err)
+ }
+ if err := file.Close(); err != nil {
+ return "", fmt.Errorf("close temporary OpenPGP file: %w", err)
+ }
+ return path, nil
+}
+
+// SignOpenPGPDetached returns an ASCII-armored detached signature. The input
+// must contain a private signing key supplied by the user.
+func SignOpenPGPDetached(message []byte, privateKeyArmor string) (string, error) {
+ var signature []byte
+ err := withGPG(func(home string) error {
+ if err := importOpenPGP(home, privateKeyArmor); err != nil {
+ return err
+ }
+ output, _, err := runGPG(home, []string{"--armor", "--detach-sign", "--output", "-"}, message, 1<<20)
+ if err != nil {
+ return fmt.Errorf("sign OpenPGP message: %w", err)
+ }
+ signature = output
+ return nil
+ })
+ return string(signature), err
+}
+
+// VerifyOpenPGPDetached verifies an ASCII-armored or binary detached
+// signature using public or private OpenPGP key material supplied by the user.
+func VerifyOpenPGPDetached(message []byte, signature, publicKeyArmor string) error {
+ return withGPG(func(home string) error {
+ if err := importOpenPGP(home, publicKeyArmor); err != nil {
+ return err
+ }
+ signaturePath, err := writeGPGFile(home, "signature.asc", []byte(signature))
+ if err != nil {
+ return err
+ }
+ messagePath, err := writeGPGFile(home, "message.bin", message)
+ if err != nil {
+ return err
+ }
+ if _, _, err := runGPG(home, []string{"--verify", signaturePath, messagePath}, nil, 1<<20); err != nil {
+ return fmt.Errorf("verify OpenPGP signature: %w", err)
+ }
+ return nil
+ })
+}
+
+// EncryptOpenPGP encrypts a message to every entity in the supplied armored
+// public key ring. If privateSignerArmor is non-empty, it also signs the
+// message with the supplied private key. The result is ASCII armored.
+func EncryptOpenPGP(message []byte, recipientKeyArmor, privateSignerArmor string) (string, error) {
+ var encrypted []byte
+ err := withGPG(func(home string) error {
+ if err := importOpenPGP(home, recipientKeyArmor); err != nil {
+ return err
+ }
+ recipientFingerprints, err := listFingerprints(home)
+ if err != nil {
+ return err
+ }
+ args := []string{"--armor", "--trust-model", "always", "--encrypt", "--output", "-"}
+ for _, fingerprint := range recipientFingerprints {
+ args = append(args, "--recipient", fingerprint)
+ }
+ if strings.TrimSpace(privateSignerArmor) != "" {
+ if err := importOpenPGP(home, privateSignerArmor); err != nil {
+ return err
+ }
+ signerFingerprints, err := listFingerprints(home)
+ if err != nil {
+ return err
+ }
+ args = append(args, "--sign", "--local-user", signerFingerprints[0])
+ }
+ output, _, err := runGPG(home, args, message, maxOpenPGPMessageBytes)
+ if err != nil {
+ return fmt.Errorf("encrypt OpenPGP message: %w", err)
+ }
+ encrypted = output
+ return nil
+ })
+ return string(encrypted), err
+}
+
+// DecryptOpenPGP decrypts an ASCII-armored or binary OpenPGP message with a
+// user-provided private key. A bad embedded signature is rejected.
+func DecryptOpenPGP(message []byte, privateKeyArmor string) ([]byte, error) {
+ var plaintext []byte
+ err := withGPG(func(home string) error {
+ if err := importOpenPGP(home, privateKeyArmor); err != nil {
+ return err
+ }
+ output, status, err := runGPG(home, []string{"--status-fd", "2", "--decrypt", "--output", "-"}, message, maxOpenPGPMessageBytes)
+ if strings.Contains(status, "[GNUPG:] BADSIG") || strings.Contains(status, "[GNUPG:] ERRSIG") {
+ return errors.New("authenticate OpenPGP message: invalid signature")
+ }
+ if err != nil {
+ return fmt.Errorf("decrypt OpenPGP message: %w", err)
+ }
+ plaintext = output
+ return nil
+ })
+ return plaintext, err
+}
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
+}
diff --git a/internal/cryptokit/yubicrypt_test.go b/internal/cryptokit/yubicrypt_test.go
new file mode 100644
index 0000000..5adaf40
--- /dev/null
+++ b/internal/cryptokit/yubicrypt_test.go
@@ -0,0 +1,36 @@
+package cryptokit
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestFindYubiCryptCLIUsesExplicitExecutable(t *testing.T) {
+ directory := t.TempDir()
+ path := filepath.Join(directory, "yubicrypt")
+ if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("AEGIS_YUBICRYPT_CLI", path)
+ found, err := FindYubiCryptCLI()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if found != path {
+ t.Fatalf("found %q, want %q", found, path)
+ }
+}
+
+func TestRequireYubiPIN(t *testing.T) {
+ input, err := requireYubiPIN("123456\n")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(input) != "123456\n" {
+ t.Fatalf("unexpected PIN input %q", input)
+ }
+ if _, err := requireYubiPIN(""); err == nil {
+ t.Fatal("empty PIN accepted")
+ }
+}