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 | |
| parent | c1decadb590c4d79d92bcc4df7772119a5c91244 (diff) | |
| download | aegis-bdb8a02bbb3dc0d7c39f5b3ec451abb68e9d093d.tar.gz aegis-bdb8a02bbb3dc0d7c39f5b3ec451abb68e9d093d.tar.xz aegis-bdb8a02bbb3dc0d7c39f5b3ec451abb68e9d093d.zip | |
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/compose/mime.go | 40 | ||||
| -rw-r--r-- | internal/compose/mime_test.go | 10 | ||||
| -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 | ||||
| -rw-r--r-- | internal/profile/vault.go | 4 | ||||
| -rw-r--r-- | internal/ui/app.go | 227 | ||||
| -rw-r--r-- | internal/ui/app_test.go | 46 |
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) } } |
