diff options
| author | Gab Virebent <gabriel1@virebent.art> | 2026-08-22 20:36:56 +0200 |
|---|---|---|
| committer | Gab Virebent <gabriel1@virebent.art> | 2026-08-22 20:36:56 +0200 |
| commit | c1decadb590c4d79d92bcc4df7772119a5c91244 (patch) | |
| tree | 468f9fa37535dbb90cc5d4061eb20de83912131e /internal/cryptokit | |
| download | aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.tar.gz aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.tar.xz aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.zip | |
Initial Aegis Usenet client release
Diffstat (limited to 'internal/cryptokit')
| -rw-r--r-- | internal/cryptokit/age.go | 91 | ||||
| -rw-r--r-- | internal/cryptokit/cryptokit_test.go | 156 | ||||
| -rw-r--r-- | internal/cryptokit/ed25519.go | 102 | ||||
| -rw-r--r-- | internal/cryptokit/openpgp.go | 240 | ||||
| -rw-r--r-- | internal/cryptokit/yubicrypt.go | 237 | ||||
| -rw-r--r-- | internal/cryptokit/yubicrypt_test.go | 36 |
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") + } +} |
