summaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/compose/mime.go40
-rw-r--r--internal/compose/mime_test.go10
-rw-r--r--internal/cryptokit/age.go91
-rw-r--r--internal/cryptokit/cryptokit_test.go109
-rw-r--r--internal/cryptokit/openpgp.go240
-rw-r--r--internal/cryptokit/yubicrypt.go75
-rw-r--r--internal/profile/vault.go4
-rw-r--r--internal/ui/app.go227
-rw-r--r--internal/ui/app_test.go46
9 files changed, 103 insertions, 739 deletions
diff --git a/internal/compose/mime.go b/internal/compose/mime.go
index da68bd8..6bda7b5 100644
--- a/internal/compose/mime.go
+++ b/internal/compose/mime.go
@@ -88,46 +88,6 @@ func BuildMixed(text string, attachments []Attachment) (contentType, transferEnc
return `multipart/mixed; boundary="` + boundary + `"`, "7bit", builder.String(), nil
}
-func BuildOpenPGPMIME(armoredCiphertext string) (contentType, transferEncoding, body string, err error) {
- if strings.TrimSpace(armoredCiphertext) == "" || strings.Contains(armoredCiphertext, "\x00") {
- return "", "", "", errors.New("OpenPGP ciphertext is required")
- }
- boundary, err := boundary()
- if err != nil {
- return "", "", "", err
- }
- var builder strings.Builder
- writer := multipart.NewWriter(&builder)
- if err := writer.SetBoundary(boundary); err != nil {
- return "", "", "", err
- }
- versionHeader := make(textproto.MIMEHeader)
- versionHeader.Set("Content-Type", "application/pgp-encrypted")
- versionHeader.Set("Content-Description", "PGP/MIME version identification")
- part, err := writer.CreatePart(versionHeader)
- if err != nil {
- return "", "", "", err
- }
- if _, err := part.Write([]byte("Version: 1\r\n")); err != nil {
- return "", "", "", err
- }
- cipherHeader := make(textproto.MIMEHeader)
- cipherHeader.Set("Content-Type", "application/octet-stream; name=encrypted.asc")
- cipherHeader.Set("Content-Disposition", `inline; filename="encrypted.asc"`)
- cipherHeader.Set("Content-Description", "OpenPGP encrypted message")
- part, err = writer.CreatePart(cipherHeader)
- if err != nil {
- return "", "", "", err
- }
- if _, err := part.Write([]byte(normalizeCRLF(armoredCiphertext))); err != nil {
- return "", "", "", err
- }
- if err := writer.Close(); err != nil {
- return "", "", "", err
- }
- return `multipart/encrypted; protocol="application/pgp-encrypted"; boundary="` + boundary + `"`, "7bit", builder.String(), nil
-}
-
func boundary() (string, error) {
random := make([]byte, 12)
if _, err := rand.Read(random); err != nil {
diff --git a/internal/compose/mime_test.go b/internal/compose/mime_test.go
index 32fb6b2..6e8f0a0 100644
--- a/internal/compose/mime_test.go
+++ b/internal/compose/mime_test.go
@@ -19,13 +19,3 @@ func TestBuildMixed(t *testing.T) {
t.Fatalf("attachment missing: %q", body)
}
}
-
-func TestBuildOpenPGPMIME(t *testing.T) {
- contentType, _, body, err := BuildOpenPGPMIME("-----BEGIN PGP MESSAGE-----\nabc\n-----END PGP MESSAGE-----")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.HasPrefix(contentType, "multipart/encrypted") || !strings.Contains(body, "Version: 1") || !strings.Contains(body, "BEGIN PGP MESSAGE") {
- t.Fatalf("invalid OpenPGP/MIME body: %q", body)
- }
-}
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) {
diff --git a/internal/profile/vault.go b/internal/profile/vault.go
index 554d01b..5b819b4 100644
--- a/internal/profile/vault.go
+++ b/internal/profile/vault.go
@@ -33,7 +33,7 @@ type Profile struct {
PublicKey string `json:"ed25519_public_key"`
PrivateKey string `json:"ed25519_private_key"`
AgeIdentity string `json:"age_identity,omitempty"`
- YubiKeyFingerprint string `json:"yubikey_openpgp_fingerprint,omitempty"`
+ YubiKeyFingerprint string `json:"yubikey_signing_fingerprint,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
@@ -154,7 +154,7 @@ func Validate(value Profile) error {
}
if strings.TrimSpace(value.PrivateKey) == "" {
if strings.TrimSpace(value.YubiKeyFingerprint) == "" {
- return errors.New("profile needs an encrypted private key or a YubiKey OpenPGP fingerprint")
+ return errors.New("profile needs an encrypted private key or a YubiKey signing fingerprint")
}
return nil
}
diff --git a/internal/ui/app.go b/internal/ui/app.go
index d193ce1..f7e7cac 100644
--- a/internal/ui/app.go
+++ b/internal/ui/app.go
@@ -10,7 +10,6 @@ import (
"io"
"mime"
"net/mail"
- "net/url"
"sort"
"strconv"
"strings"
@@ -86,16 +85,14 @@ type application struct {
composeGroups *widget.Entry
composeDelivery *widget.Select
- composeMail2News *widget.Entry
+ composeTo *widget.Entry
composeFrom *widget.Entry
composeSubject *widget.Entry
composeReferences *widget.Entry
composeFollowupTo *widget.Entry
composeBody *widget.Entry
composeCryptoMode *widget.Select
- composeCryptoKey *widget.Entry
composeCryptoSigningKey *widget.Entry
- composeCryptoKeyURL *widget.Entry
cryptoAlgorithm *widget.Select
cryptoOperation *widget.Select
@@ -144,6 +141,8 @@ type application struct {
vfaceStatus *widget.Label
vfaceImage *canvas.Image
vfaceHash *widget.Label
+ vfacePublicKey *widget.Label
+ vfaceKeyStatus *widget.Label
}
func Run() {
@@ -311,8 +310,8 @@ func (a *application) buildComposer() fyne.CanvasObject {
a.composeGroups.SetPlaceHolder("comp.lang.go,example.group")
a.composeDelivery = widget.NewSelect([]string{"NNTP direct posting", "SMTP mail2news"}, nil)
a.composeDelivery.SetSelected("NNTP direct posting")
- a.composeMail2News = widget.NewEntry()
- a.composeMail2News.SetText(a.settings.SMTPRecipient)
+ a.composeTo = widget.NewEntry()
+ a.composeTo.SetText(a.settings.SMTPRecipient)
a.composeFrom = widget.NewEntry()
a.composeSubject = widget.NewEntry()
a.composeReferences = widget.NewEntry()
@@ -322,35 +321,27 @@ func (a *application) buildComposer() fyne.CanvasObject {
a.composeBody = widget.NewMultiLineEntry()
a.composeBody.SetPlaceHolder("Article body...")
a.composeFrom.SetText(formatFrom(a.settings.DisplayName, a.settings.Email))
- a.composeFrom.Disable()
+ // Keep the identity visible in the normal foreground color. VFace, when
+ // unlocked, still replaces this value before posting.
a.composeCryptoMode = widget.NewSelect([]string{
"Plain",
"Sign with Ed25519",
- "Encrypt with age",
- "Encrypt with age and sign",
}, nil)
a.composeCryptoMode.SetSelected("Plain")
- a.composeCryptoKey = widget.NewMultiLineEntry()
- a.composeCryptoKey.SetPlaceHolder("age recipient (native X25519 or SSH Ed25519/RSA)")
- a.composeCryptoKey.Wrapping = fyne.TextWrapOff
a.composeCryptoSigningKey = widget.NewMultiLineEntry()
- a.composeCryptoSigningKey.SetPlaceHolder("Ed25519 private key, base64 or hexadecimal")
+ a.composeCryptoSigningKey.SetPlaceHolder("Optional Ed25519 private key; VFace supplies it automatically")
a.composeCryptoSigningKey.Wrapping = fyne.TextWrapOff
- a.composeCryptoKeyURL = widget.NewEntry()
- a.composeCryptoKeyURL.SetPlaceHolder("Optional HTTPS URL for the public key")
post := widget.NewButtonWithIcon("Post article", theme.MailSendIcon(), a.postArticle)
form := widget.NewForm(
widget.NewFormItem("Newsgroups", a.composeGroups),
widget.NewFormItem("Delivery", a.composeDelivery),
- widget.NewFormItem("mail2news recipient", a.composeMail2News),
+ widget.NewFormItem("To", a.composeTo),
widget.NewFormItem("From", a.composeFrom),
widget.NewFormItem("Subject", a.composeSubject),
widget.NewFormItem("References", a.composeReferences),
widget.NewFormItem("Followup-To", a.composeFollowupTo),
- widget.NewFormItem("Crypto mode", a.composeCryptoMode),
- widget.NewFormItem("Age recipient", a.composeCryptoKey),
+ widget.NewFormItem("Mode", a.composeCryptoMode),
widget.NewFormItem("Ed25519 signing key", a.composeCryptoSigningKey),
- widget.NewFormItem("Public-key URL", a.composeCryptoKeyURL),
)
return container.NewBorder(form, post, nil, nil, a.composeBody)
}
@@ -368,6 +359,11 @@ func (a *application) buildProfile() fyne.CanvasObject {
a.vfaceStatus.Wrapping = fyne.TextWrapWord
a.vfaceHash = widget.NewLabel("")
a.vfaceHash.Wrapping = fyne.TextWrapWord
+ a.vfacePublicKey = widget.NewLabel("")
+ a.vfacePublicKey.Wrapping = fyne.TextWrapBreak
+ a.vfacePublicKey.Selectable = true
+ a.vfaceKeyStatus = widget.NewLabel("")
+ a.vfaceKeyStatus.Wrapping = fyne.TextWrapWord
a.vfaceImage = canvas.NewImageFromImage(image.NewRGBA(image.Rect(0, 0, 48, 48)))
a.vfaceImage.FillMode = canvas.ImageFillContain
a.vfaceImage.SetMinSize(fyne.NewSize(96, 96))
@@ -387,7 +383,7 @@ func (a *application) buildProfile() fyne.CanvasObject {
widget.NewFormItem("Vault password", a.vfacePasswordEntry),
widget.NewFormItem("Confirm password", a.vfaceConfirmEntry),
)
- provider := widget.NewLabel("Crypto providers: age for software encryption. OpenPGP is available only through a YubiKey integration and is not exposed as a standalone format.")
+ provider := widget.NewLabel("VFace creates an Ed25519 key pair. The public key is part of the identity; the private key remains encrypted in the local vault and is used for signing. Message encryption is intentionally not part of the Usenet client.")
provider.Wrapping = fyne.TextWrapWord
return container.NewVScroll(container.NewVBox(
widget.NewLabelWithStyle("Optional pseudonymous identity", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
@@ -397,6 +393,8 @@ func (a *application) buildProfile() fyne.CanvasObject {
a.vfaceStatus,
a.vfaceImage,
a.vfaceHash,
+ a.vfacePublicKey,
+ a.vfaceKeyStatus,
provider,
))
}
@@ -429,6 +427,7 @@ func (a *application) createVFaceProfile() {
a.vfaceConfirmEntry.SetText("")
a.renderVFaceProfile(value)
a.updateComposeIdentity()
+ a.composeCryptoMode.SetSelected("Sign with Ed25519")
a.vfaceStatus.SetText("VFace identity created and encrypted on disk.")
})
}()
@@ -454,6 +453,7 @@ func (a *application) loadVFaceProfile() {
a.vfaceEmailEntry.SetText(value.Email)
a.renderVFaceProfile(value)
a.updateComposeIdentity()
+ a.composeCryptoMode.SetSelected("Sign with Ed25519")
a.vfaceStatus.SetText("VFace identity loaded from encrypted disk vault.")
})
}()
@@ -464,6 +464,15 @@ func (a *application) lockVFaceProfile() {
if a.vfaceImage != nil {
a.vfaceImage.Hide()
}
+ if a.vfacePublicKey != nil {
+ a.vfacePublicKey.SetText("")
+ }
+ if a.vfaceKeyStatus != nil {
+ a.vfaceKeyStatus.SetText("")
+ }
+ if a.composeCryptoMode != nil && a.composeCryptoMode.Selected == "Sign with Ed25519" {
+ a.composeCryptoMode.SetSelected("Plain")
+ }
if a.vfaceStatus != nil {
a.vfaceStatus.SetText("VFace identity locked. VFace is optional.")
}
@@ -483,6 +492,8 @@ func (a *application) renderVFaceProfile(value vfaceprofile.Profile) {
a.vfaceImage.Refresh()
}
a.vfaceHash.SetText("Identity SHA-256: " + profile.IdentityHash + "\nPNG SHA-256: " + profile.PNGHash)
+ a.vfacePublicKey.SetText("Ed25519 public key (selectable):\n" + value.PublicKey)
+ a.vfaceKeyStatus.SetText("Ed25519 key pair ready. Private key is encrypted in the local vault and available for signing.")
}
func (a *application) updateComposeIdentity() {
@@ -497,12 +508,12 @@ func (a *application) updateComposeIdentity() {
}
func (a *application) buildCrypto() fyne.CanvasObject {
- a.cryptoAlgorithm = widget.NewSelect([]string{"OpenPGP", "age", "Ed25519", "YubiCrypt"}, nil)
- a.cryptoAlgorithm.SetSelected("OpenPGP")
- a.cryptoOperation = widget.NewSelect([]string{"Sign", "Verify", "Encrypt", "Decrypt"}, nil)
+ a.cryptoAlgorithm = widget.NewSelect([]string{"Ed25519", "YubiCrypt"}, nil)
+ a.cryptoAlgorithm.SetSelected("Ed25519")
+ a.cryptoOperation = widget.NewSelect([]string{"Sign", "Verify"}, nil)
a.cryptoOperation.SetSelected("Sign")
a.cryptoMessage = widget.NewMultiLineEntry()
- a.cryptoMessage.SetPlaceHolder("Message or ciphertext")
+ a.cryptoMessage.SetPlaceHolder("Message")
a.cryptoMessage.Wrapping = fyne.TextWrapOff
a.cryptoPrimary = widget.NewMultiLineEntry()
a.cryptoPrimary.SetPlaceHolder("Key material supplied by you")
@@ -529,13 +540,13 @@ func (a *application) buildCrypto() fyne.CanvasObject {
a.cryptoSecondary.SetText("")
a.cryptoOutput.SetText("No result yet.")
})
- note := widget.NewLabel("Aegis does not generate or save keys. Key fields are used only for this session. YubiCrypt requires the optional yubicrypt executable, a YubiKey, pcscd and the PIV PIN.")
+ note := widget.NewLabel("This panel is limited to signing and verification. YubiCrypt requires the optional yubicrypt executable, a YubiKey, pcscd and the PIV PIN.")
note.Wrapping = fyne.TextWrapWord
form := widget.NewForm(
widget.NewFormItem("Format", a.cryptoAlgorithm),
widget.NewFormItem("Operation", a.cryptoOperation),
)
- messageBox := container.NewVBox(widget.NewLabel("Message / ciphertext"), a.cryptoMessage)
+ messageBox := container.NewVBox(widget.NewLabel("Message"), a.cryptoMessage)
a.cryptoPrimaryBox = container.NewVBox(a.cryptoPrimaryLabel, a.cryptoPrimary)
a.cryptoSecretBox = container.NewVBox(a.cryptoSecretLabel, a.cryptoSecret)
a.cryptoSecondaryBox = container.NewVBox(a.cryptoSecondaryLabel, a.cryptoSecondary)
@@ -567,27 +578,6 @@ func (a *application) refreshCryptoFields() {
a.cryptoPrimaryLabel.SetText("Signature")
a.cryptoSecondaryLabel.SetText("Public key")
}
- case "Encrypt":
- if algorithm == "OpenPGP" {
- a.cryptoPrimaryLabel.SetText("Recipient public key")
- a.cryptoSecondaryLabel.SetText("Optional signer private key")
- } else if algorithm == "YubiCrypt" {
- a.cryptoPrimaryLabel.SetText("RSA recipient certificate/key (PEM)")
- a.cryptoSecondaryBox.Hide()
- } else {
- a.cryptoPrimaryLabel.SetText("Recipient key")
- a.cryptoSecondaryLabel.SetText("Not used")
- }
- case "Decrypt":
- if algorithm == "YubiCrypt" {
- a.cryptoPrimaryBox.Hide()
- a.cryptoSecretBox.Show()
- a.cryptoSecretLabel.SetText("YubiKey PIV PIN")
- a.cryptoSecondaryBox.Hide()
- } else {
- a.cryptoPrimaryLabel.SetText("Private key / identity")
- a.cryptoSecondaryLabel.SetText("Not used")
- }
default:
if algorithm == "YubiCrypt" {
a.cryptoPrimaryBox.Hide()
@@ -600,7 +590,7 @@ func (a *application) refreshCryptoFields() {
}
}
a.cryptoSecondary.Disable()
- if operation == "Verify" || (operation == "Encrypt" && algorithm == "OpenPGP") {
+ if operation == "Verify" {
a.cryptoSecondary.Enable()
}
a.cryptoPrimaryBox.Refresh()
@@ -616,17 +606,13 @@ func (a *application) runCryptoOperation() {
secret := a.cryptoSecret.Text
secondary := a.cryptoSecondary.Text
if len(strings.TrimSpace(string(message))) == 0 {
- dialog.ShowError(errors.New("message or ciphertext is required"), a.window)
+ dialog.ShowError(errors.New("message is required"), a.window)
return
}
- if algorithm == "YubiCrypt" && operation != "Verify" && operation != "Encrypt" && strings.TrimSpace(secret) == "" {
+ if algorithm == "YubiCrypt" && operation == "Sign" && strings.TrimSpace(secret) == "" {
dialog.ShowError(errors.New("YubiKey PIV PIN is required"), a.window)
return
}
- if algorithm == "YubiCrypt" && operation == "Encrypt" && strings.TrimSpace(primary) == "" {
- dialog.ShowError(errors.New("RSA recipient certificate/key is required"), a.window)
- return
- }
if algorithm != "YubiCrypt" && strings.TrimSpace(primary) == "" {
dialog.ShowError(errors.New("primary key material is required"), a.window)
return
@@ -636,33 +622,6 @@ func (a *application) runCryptoOperation() {
var result string
var err error
switch algorithm {
- case "OpenPGP":
- switch operation {
- case "Sign":
- result, err = cryptokit.SignOpenPGPDetached(message, primary)
- case "Verify":
- err = cryptokit.VerifyOpenPGPDetached(message, primary, secondary)
- result = "OpenPGP signature verified."
- case "Encrypt":
- result, err = cryptokit.EncryptOpenPGP(message, primary, secondary)
- case "Decrypt":
- var plaintext []byte
- plaintext, err = cryptokit.DecryptOpenPGP(message, primary)
- result = string(plaintext)
- }
- case "age":
- switch operation {
- case "Encrypt":
- var ciphertext []byte
- ciphertext, err = cryptokit.EncryptAge(message, primary)
- result = string(ciphertext)
- case "Decrypt":
- var plaintext []byte
- plaintext, err = cryptokit.DecryptAge(message, primary)
- result = string(plaintext)
- default:
- err = errors.New("age supports Encrypt and Decrypt")
- }
case "Ed25519":
switch operation {
case "Sign":
@@ -670,8 +629,6 @@ func (a *application) runCryptoOperation() {
case "Verify":
err = cryptokit.VerifyEd25519(message, primary, secondary)
result = "Ed25519 signature verified."
- default:
- err = errors.New("raw Ed25519 supports Sign and Verify; use age SSH keys for encryption")
}
case "YubiCrypt":
switch operation {
@@ -683,18 +640,10 @@ func (a *application) runCryptoOperation() {
var verified []byte
verified, err = cryptokit.VerifyYubiCrypt(message)
result = string(verified)
- case "Encrypt":
- var ciphertext []byte
- ciphertext, err = cryptokit.EncryptYubiCrypt(message, primary)
- result = string(ciphertext)
- case "Decrypt":
- var plaintext []byte
- plaintext, err = cryptokit.DecryptYubiCrypt(message, secret)
- result = string(plaintext)
}
}
fyne.Do(func() {
- a.setBusy(false, "Cryptography operation completed.")
+ a.setBusy(false, "Signing operation completed.")
if err != nil {
dialog.ShowError(err, a.window)
return
@@ -780,7 +729,7 @@ func (a *application) buildSettings() fyne.CanvasObject {
widget.NewFormItem("SMTP transport", a.smtpModeSelect),
widget.NewFormItem("SMTP username", a.smtpUserEntry),
widget.NewFormItem("SMTP email", a.smtpEmailEntry),
- widget.NewFormItem("mail2news recipient", a.smtpRecipientEntry),
+ widget.NewFormItem("Default To", a.smtpRecipientEntry),
widget.NewFormItem("SMTP password", a.smtpPasswordEntry),
widget.NewFormItem("SMTP TLS", a.smtpSkipVerify),
widget.NewFormItem("NNTP display name", a.displayEntry),
@@ -1192,11 +1141,10 @@ func formatArticleHeaders(article string, showAll bool) string {
return raw
}
important := []string{
- "From", "Date", "Newsgroups", "Subject", "Message-ID", "References", "Followup-To",
+ "From", "To", "Date", "Newsgroups", "Subject", "Message-ID", "References", "Followup-To",
"Reply-To", "Organization", "User-Agent", "MIME-Version", "Content-Type",
- "Content-Transfer-Encoding", "Face", "OpenPGP", "X-OpenPGP", "X-Signature",
- "X-Aegis-Crypto-Version", "X-Aegis-Encryption", "X-Aegis-Signature",
- "X-Aegis-Public-Key", "X-Aegis-Key-Fingerprint", "X-Aegis-Public-Key-URL",
+ "Content-Transfer-Encoding", "Face", "X-Signature",
+ "X-Aegis-Signature", "X-Aegis-Public-Key", "X-Aegis-Key-Fingerprint",
"X-VFace-Version", "X-Ed25519-Pub", "X-Ed25519-Sig", "Identity-Hash",
"X-VFace-Hash", "X-VFace-PNG-SHA256", "X-VFace-Verify",
}
@@ -1268,26 +1216,30 @@ func (a *application) postArticle() {
return
}
var recipients []string
+ to := strings.TrimSpace(a.composeTo.Text)
if delivery == "SMTP mail2news" {
- recipient := strings.TrimSpace(a.composeMail2News.Text)
- if recipient == "" {
- recipient = strings.TrimSpace(settings.SMTPRecipient)
+ if to == "" {
+ to = strings.TrimSpace(settings.SMTPRecipient)
}
- if recipient == "" {
- dialog.ShowError(errors.New("mail2news recipient is required for SMTP delivery"), a.window)
+ if to == "" {
+ dialog.ShowError(errors.New("To address is required for SMTP delivery"), a.window)
return
}
- if _, err := mail.ParseAddress(recipient); err != nil {
- dialog.ShowError(fmt.Errorf("invalid mail2news recipient: %w", err), a.window)
+ if _, err := mail.ParseAddress(to); err != nil {
+ dialog.ShowError(fmt.Errorf("invalid To address: %w", err), a.window)
+ return
+ }
+ recipients = []string{to}
+ } else if to != "" {
+ if _, err := mail.ParseAddress(to); err != nil {
+ dialog.ShowError(fmt.Errorf("invalid To address: %w", err), a.window)
return
}
- recipients = []string{recipient}
}
article, err := buildArticleWithIdentityHeaders(groups, from, subject, a.composeBody.Text, identityHeaders, articleCryptoOptions{
Mode: a.composeCryptoMode.Selected,
- EncryptionKey: a.composeCryptoKey.Text,
+ To: to,
SigningKey: signingKey,
- PublicKeyURL: a.composeCryptoKeyURL.Text,
ExpectedPublicKey: expectedPublicKey,
References: strings.TrimSpace(a.composeReferences.Text),
FollowupTo: strings.TrimSpace(a.composeFollowupTo.Text),
@@ -1322,10 +1274,8 @@ func (a *application) postArticle() {
a.composeBody.SetText("")
a.composeReferences.SetText("")
a.composeFollowupTo.SetText("")
- a.composeMail2News.SetText(settings.SMTPRecipient)
- a.composeCryptoKey.SetText("")
+ a.composeTo.SetText(settings.SMTPRecipient)
a.composeCryptoSigningKey.SetText("")
- a.composeCryptoKeyURL.SetText("")
a.composeCryptoMode.SetSelected("Plain")
a.setBusy(false, "Article accepted by the NNTP server.")
dialog.ShowInformation("Article posted", "The NNTP server accepted the article.", a.window)
@@ -1339,9 +1289,8 @@ func buildTextArticle(groups []string, from, subject, body string) (string, erro
type articleCryptoOptions struct {
Mode string
- EncryptionKey string
+ To string
SigningKey string
- PublicKeyURL string
ExpectedPublicKey string
References string
FollowupTo string
@@ -1399,6 +1348,15 @@ func buildArticleWithIdentityHeaders(groups []string, from, subject, body string
"Content-Type: " + contentType,
"Content-Transfer-Encoding: " + contentTransferEncoding,
}
+ if to := strings.TrimSpace(options.To); to != "" {
+ if strings.ContainsAny(to, "\r\n") {
+ return "", errors.New("To must not contain line breaks")
+ }
+ if _, err := mail.ParseAddress(to); err != nil {
+ return "", fmt.Errorf("invalid To address: %w", err)
+ }
+ headers = append(headers, foldHeader("To", to))
+ }
if options.References != "" {
if strings.ContainsAny(options.References, "\r\n") {
return "", errors.New("References must not contain line breaks")
@@ -1445,51 +1403,18 @@ func prepareArticleCrypto(body, mode string, options articleCryptoOptions) (head
contentType = "text/plain; charset=UTF-8"
transferEncoding = "8bit"
signingKey := strings.TrimSpace(options.SigningKey)
- encryptionKey := strings.TrimSpace(options.EncryptionKey)
- publicKeyURL := strings.TrimSpace(options.PublicKeyURL)
- if publicKeyURL != "" {
- parsed, parseErr := url.Parse(publicKeyURL)
- if parseErr != nil || !strings.EqualFold(parsed.Scheme, "https") || parsed.Host == "" || strings.ContainsAny(publicKeyURL, "\r\n") {
- return nil, "", "", "", errors.New("public-key URL must be a valid HTTPS URL without line breaks")
- }
- }
switch mode {
case "Plain":
- if signingKey != "" || encryptionKey != "" || publicKeyURL != "" {
- return nil, "", "", "", errors.New("plain mode does not accept cryptographic key fields")
- }
case "Sign with Ed25519":
if signingKey == "" {
return nil, "", "", "", errors.New("an Ed25519 private key is required for signing")
}
- case "Encrypt with age":
- if encryptionKey == "" {
- return nil, "", "", "", errors.New("an age recipient is required for encryption")
- }
- ciphertext, encryptErr := cryptokit.EncryptAge([]byte(body), encryptionKey)
- if encryptErr != nil {
- return nil, "", "", "", fmt.Errorf("encrypt article body: %w", encryptErr)
- }
- wireBody = normalizeCRLF(string(ciphertext))
- contentType = "application/vnd.aegis.age"
- transferEncoding = "7bit"
- case "Encrypt with age and sign":
- if encryptionKey == "" || signingKey == "" {
- return nil, "", "", "", errors.New("an age recipient and an Ed25519 private key are required")
- }
- ciphertext, encryptErr := cryptokit.EncryptAge([]byte(body), encryptionKey)
- if encryptErr != nil {
- return nil, "", "", "", fmt.Errorf("encrypt article body: %w", encryptErr)
- }
- wireBody = normalizeCRLF(string(ciphertext))
- contentType = "application/vnd.aegis.age"
- transferEncoding = "7bit"
default:
- return nil, "", "", "", fmt.Errorf("unsupported article crypto mode %q", mode)
+ return nil, "", "", "", fmt.Errorf("unsupported article signing mode %q", mode)
}
- if mode == "Sign with Ed25519" || mode == "Encrypt with age and sign" {
+ if mode == "Sign with Ed25519" {
if signingKey == "" {
return nil, "", "", "", errors.New("an Ed25519 private key is required for signing")
}
@@ -1521,14 +1446,6 @@ func prepareArticleCrypto(body, mode string, options articleCryptoOptions) (head
"X-Ed25519-Sig: "+signature,
"X-Aegis-Key-Fingerprint: "+fingerprint,
)
- if publicKeyURL != "" {
- headers = append(headers, foldHeader("X-Aegis-Public-Key-URL", publicKeyURL))
- }
- } else if publicKeyURL != "" {
- return nil, "", "", "", errors.New("public-key URL requires an Ed25519 signature mode")
- }
- if mode == "Encrypt with age" || mode == "Encrypt with age and sign" {
- headers = append([]string{"X-Aegis-Crypto-Version: 1", "X-Aegis-Encryption: age"}, headers...)
}
return headers, contentType, transferEncoding, wireBody, nil
}
diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go
index 0483bdb..6e9dc2a 100644
--- a/internal/ui/app_test.go
+++ b/internal/ui/app_test.go
@@ -10,8 +10,6 @@ import (
"aegis/internal/cryptokit"
"aegis/internal/identity"
-
- "filippo.io/age"
)
func TestNormalizeGroups(t *testing.T) {
@@ -100,7 +98,7 @@ func TestPrepareArticleCryptoSignsCanonicalBody(t *testing.T) {
headers, contentType, transferEncoding, wireBody, err := prepareArticleCrypto(
"hello\r\nworld",
"Sign with Ed25519",
- articleCryptoOptions{SigningKey: privateSeed, PublicKeyURL: "https://keys.example.invalid/aegis.pub"},
+ articleCryptoOptions{SigningKey: privateSeed},
)
if err != nil {
t.Fatal(err)
@@ -108,10 +106,6 @@ func TestPrepareArticleCryptoSignsCanonicalBody(t *testing.T) {
if contentType != "text/plain; charset=UTF-8" || transferEncoding != "8bit" {
t.Fatalf("unexpected MIME metadata: %q, %q", contentType, transferEncoding)
}
- joined := strings.Join(headers, "\r\n")
- if !strings.Contains(joined, "X-Aegis-Public-Key-URL: https://keys.example.invalid/aegis.pub") {
- t.Fatalf("missing public-key URL: %s", joined)
- }
const signaturePrefix = "X-Aegis-Signature: ed25519; "
var signature string
for _, header := range headers {
@@ -127,38 +121,22 @@ func TestPrepareArticleCryptoSignsCanonicalBody(t *testing.T) {
}
}
-func TestPrepareArticleCryptoEncryptsAndSigns(t *testing.T) {
- identity, err := age.GenerateX25519Identity()
- if err != nil {
- t.Fatal(err)
+func TestPrepareArticleCryptoRejectsMessageEncryption(t *testing.T) {
+ if _, _, _, _, err := prepareArticleCrypto("secret body", "Encrypt with age", articleCryptoOptions{}); err == nil {
+ t.Fatal("message encryption mode was accepted")
}
- _, private, err := ed25519.GenerateKey(rand.Reader)
- if err != nil {
- t.Fatal(err)
- }
- headers, contentType, transferEncoding, wireBody, err := prepareArticleCrypto(
- "secret body",
- "Encrypt with age and sign",
- articleCryptoOptions{
- EncryptionKey: identity.Recipient().String(),
- SigningKey: base64.StdEncoding.EncodeToString(private.Seed()),
- },
+}
+
+func TestBuildArticleIncludesToHeader(t *testing.T) {
+ article, err := buildArticleWithIdentityHeaders(
+ []string{"alt.test"}, "reader@example.org", "To header", "body", nil,
+ articleCryptoOptions{Mode: "Plain", To: "mail2news@example.org"},
)
if err != nil {
t.Fatal(err)
}
- if contentType != "application/vnd.aegis.age" || transferEncoding != "7bit" {
- t.Fatalf("unexpected encrypted MIME metadata: %q, %q", contentType, transferEncoding)
- }
- if !strings.Contains(strings.Join(headers, "\r\n"), "X-Aegis-Encryption: age") {
- t.Fatal("missing age encryption header")
- }
- plaintext, err := cryptokit.DecryptAge([]byte(strings.ReplaceAll(wireBody, "\r\n", "\n")), identity.String())
- if err != nil {
- t.Fatal(err)
- }
- if string(plaintext) != "secret body" {
- t.Fatalf("decrypted body = %q", plaintext)
+ if !strings.Contains(article, "To: mail2news@example.org\r\n") {
+ t.Fatalf("article missing To header:\n%s", article)
}
}