diff options
| author | Gab Virebent <gabriel1@virebent.art> | 2026-08-23 00:26:27 +0200 |
|---|---|---|
| committer | Gab Virebent <gabriel1@virebent.art> | 2026-08-23 00:26:27 +0200 |
| commit | bdb8a02bbb3dc0d7c39f5b3ec451abb68e9d093d (patch) | |
| tree | 796d246f9874641717b5e81a72989961e65a9b9f /internal/cryptokit | |
| parent | c1decadb590c4d79d92bcc4df7772119a5c91244 (diff) | |
| download | aegis-main.tar.gz aegis-main.tar.xz aegis-main.zip | |
Diffstat (limited to 'internal/cryptokit')
| -rw-r--r-- | internal/cryptokit/age.go | 91 | ||||
| -rw-r--r-- | internal/cryptokit/cryptokit_test.go | 109 | ||||
| -rw-r--r-- | internal/cryptokit/openpgp.go | 240 | ||||
| -rw-r--r-- | internal/cryptokit/yubicrypt.go | 75 |
4 files changed, 17 insertions, 498 deletions
diff --git a/internal/cryptokit/age.go b/internal/cryptokit/age.go deleted file mode 100644 index 525b1fa..0000000 --- a/internal/cryptokit/age.go +++ /dev/null @@ -1,91 +0,0 @@ -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 index 928120e..cc771c2 100644 --- a/internal/cryptokit/cryptokit_test.go +++ b/internal/cryptokit/cryptokit_test.go @@ -1,18 +1,10 @@ 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) { @@ -53,104 +45,3 @@ func TestEd25519PublicKeyAndFingerprint(t *testing.T) { 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/openpgp.go b/internal/cryptokit/openpgp.go deleted file mode 100644 index bf766b5..0000000 --- a/internal/cryptokit/openpgp.go +++ /dev/null @@ -1,240 +0,0 @@ -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 index 851d564..d9b641d 100644 --- a/internal/cryptokit/yubicrypt.go +++ b/internal/cryptokit/yubicrypt.go @@ -12,6 +12,23 @@ import ( const maxYubiCryptMessageBytes = 64 << 20 +type limitedBuffer struct { + buffer bytes.Buffer + limit int +} + +func (b *limitedBuffer) Write(value []byte) (int, error) { + remaining := b.limit - b.buffer.Len() + if remaining <= 0 { + return len(value), nil + } + if len(value) > remaining { + _, _ = b.buffer.Write(value[:remaining]) + return len(value), nil + } + return b.buffer.Write(value) +} + var yubiCryptCandidates = []string{ "/home/gabriel1/bin/yubicrypt", "/home/gabriel1/bin/yubicrpt-cli", @@ -120,37 +137,6 @@ func checkYubiMessageSize(message []byte) error { 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 == "" { @@ -186,33 +172,6 @@ func SignYubiCrypt(message []byte, pin string) ([]byte, error) { 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) { |
