Files
mailgoserver/internal/webui/webmail_pgp_compose_test.go
T

337 lines
13 KiB
Go
Raw Normal View History

package webui
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"net/mail"
"net/url"
"strconv"
"strings"
"testing"
"mailgoserver/internal/pgp"
)
// parseRawAsPGPEntity is a test-only helper that reads a stored raw RFC822
// message's Content-Type header and body into a pgp.Entity — mirrors
// parseRawAsEntity (webmail_smime_compose_test.go) for the PGP side.
func parseRawAsPGPEntity(t *testing.T, raw []byte) pgp.Entity {
t.Helper()
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
t.Fatalf("parse raw message: %v", err)
}
body, err := io.ReadAll(msg.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
entity := pgp.Entity{Headers: []string{"Content-Type: " + msg.Header.Get("Content-Type")}, Body: body}
if cte := msg.Header.Get("Content-Transfer-Encoding"); cte != "" {
entity.Headers = append(entity.Headers, "Content-Transfer-Encoding: "+cte)
}
return entity
}
// genPGPIdentity generates and stores a passphrase-protected PGP identity for a
// mailbox directly (bypassing HTTP — the identity-management HTTP flow itself is
// covered in webmail_pgp_test.go), returning its ID.
func genPGPIdentity(t *testing.T, app *App, mailboxID int64, email, passphrase string) int64 {
t.Helper()
pubArmor, privArmor, err := pgp.GenerateKeyPair(email, passphrase)
if err != nil {
t.Fatal(err)
}
if err := app.storePGPIdentity(mailboxID, "", email, pubArmor, privArmor); err != nil {
t.Fatal(err)
}
identities, err := app.DB.ListPGPIdentities(mailboxID)
if err != nil || len(identities) == 0 {
t.Fatalf("expected the identity to be stored, err=%v", err)
}
return identities[0].ID // most recently created
}
// TestWebmailComposePGPEncryptSend confirms checking "Encrypt" alone — no PGP key
// unlocked, no passphrase submitted — still works, since encrypting only ever needs
// public keys. The message only the recipient (or the sender's own Sent copy) can
// decrypt, and plaintext never appears on the wire.
func TestWebmailComposePGPEncryptSend(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "penc-sender@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "penc-recip@example.com", domainID, "recip-password-1!")
senderIdentityID := genPGPIdentity(t, app, senderID, "penc-sender@example.com", "sender passphrase")
recipIdentityID := genPGPIdentity(t, app, recipientID, "penc-recip@example.com", "recipient passphrase")
senderIdentity, err := app.DB.GetPGPIdentity(senderID, senderIdentityID)
if err != nil || senderIdentity == nil {
t.Fatal(err)
}
recipIdentity, err := app.DB.GetPGPIdentity(recipientID, recipIdentityID)
if err != nil || recipIdentity == nil {
t.Fatal(err)
}
if err := app.DB.UpsertPGPContact(senderID, "penc-recip@example.com", "", recipIdentity.Fingerprint, recipIdentity.PublicKeyArmor); err != nil {
t.Fatal(err)
}
recipContact, err := app.DB.GetPGPContact(senderID, "penc-recip@example.com")
if err != nil || recipContact == nil {
t.Fatal(err)
}
cookie := webmailLoginSession(t, app, senderID)
form := url.Values{
"to": {"penc-recip@example.com"}, "subject": {"Secret"}, "body_html": {"the launch code is 1234"},
"pgp_encrypt": {"1"}, "pgp_recipient_id": {strconv.FormatInt(recipContact.ID, 10)},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("compose send: status=%d body=%s", rec.Code, rec.Body.String())
}
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d (err=%v)", len(msgs), err)
}
raw, err := app.Mailstore.FetchMessage(recipientID, msgs[0].ID)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(raw, []byte("launch code")) {
t.Fatal("plaintext leaked into the stored encrypted message")
}
entity := parseRawAsPGPEntity(t, raw)
recipParsedIdentity, err := pgp.ParsePrivateKey([]byte(recipIdentity.PrivateKeyArmor))
if err != nil {
t.Fatal(err)
}
if err := pgp.UnlockPrivateKey(recipParsedIdentity, "recipient passphrase"); err != nil {
t.Fatal(err)
}
decrypted, err := pgp.DecryptEntity(entity, recipParsedIdentity)
if err != nil {
t.Fatalf("recipient DecryptEntity: %v", err)
}
if !bytes.Contains(decrypted.Body, []byte("launch code")) {
t.Fatalf("expected plaintext after decrypt, got %q", decrypted.Body)
}
// Sender's own Sent copy must also decrypt, with the sender's own key — proving
// encrypt-only (no passphrase involved at compose time) still included the
// sender's own public key as a recipient.
sentMsgs, err := app.DB.ListMessagesInFolder(senderID, "Sent")
if err != nil || len(sentMsgs) != 1 {
t.Fatalf("expected 1 sent message, got %d (err=%v)", len(sentMsgs), err)
}
sentRaw, err := app.Mailstore.FetchMessage(senderID, sentMsgs[0].ID)
if err != nil {
t.Fatal(err)
}
senderParsedIdentity, err := pgp.ParsePrivateKey([]byte(senderIdentity.PrivateKeyArmor))
if err != nil {
t.Fatal(err)
}
if err := pgp.UnlockPrivateKey(senderParsedIdentity, "sender passphrase"); err != nil {
t.Fatal(err)
}
sentEntity := parseRawAsPGPEntity(t, sentRaw)
sentDecrypted, err := pgp.DecryptEntity(sentEntity, senderParsedIdentity)
if err != nil {
t.Fatalf("sender DecryptEntity of own Sent copy: %v", err)
}
if !bytes.Contains(sentDecrypted.Body, []byte("launch code")) {
t.Fatal("sender's own Sent copy did not decrypt to the original body")
}
}
// TestWebmailComposePGPEncryptDoesNotLogPlaintext confirms the admin-visible email
// log doesn't capture the plaintext body of a PGP-encrypted send.
func TestWebmailComposePGPEncryptDoesNotLogPlaintext(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "penc-sender3@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "penc-recip3@example.com", domainID, "recip-password-1!")
genPGPIdentity(t, app, senderID, "penc-sender3@example.com", "sender passphrase")
recipIdentityID := genPGPIdentity(t, app, recipientID, "penc-recip3@example.com", "recipient passphrase")
recipIdentity, err := app.DB.GetPGPIdentity(recipientID, recipIdentityID)
if err != nil || recipIdentity == nil {
t.Fatal(err)
}
if err := app.DB.UpsertPGPContact(senderID, "penc-recip3@example.com", "", recipIdentity.Fingerprint, recipIdentity.PublicKeyArmor); err != nil {
t.Fatal(err)
}
recipContact, err := app.DB.GetPGPContact(senderID, "penc-recip3@example.com")
if err != nil || recipContact == nil {
t.Fatal(err)
}
cookie := webmailLoginSession(t, app, senderID)
const secretPhrase = "nuclear launch codes are 00000000"
form := url.Values{
"to": {"penc-recip3@example.com"}, "subject": {"Top secret"}, "body_html": {secretPhrase},
"pgp_encrypt": {"1"}, "pgp_recipient_id": {strconv.FormatInt(recipContact.ID, 10)},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("compose send: status=%d body=%s", rec.Code, rec.Body.String())
}
logs, err := app.DB.ListEmailLogsPage(0, 10)
if err != nil {
t.Fatal(err)
}
found := false
for _, l := range logs {
if l.Subject != "Top secret" {
continue
}
found = true
if strings.Contains(l.MessageBody, secretPhrase) {
t.Fatalf("plaintext leaked into the admin email log: %q", l.MessageBody)
}
}
if !found {
t.Fatal("expected the send to appear in the email log (just without the plaintext body)")
}
}
// TestWebmailComposePGPEncryptNoRecipientPickedFails confirms encryption is refused
// (not silently skipped) when "Encrypt" is checked but no recipient key was picked
// from the dropdown — the picker replaced address-based auto-matching, so there's no
// implicit recipient to fall back to.
func TestWebmailComposePGPEncryptNoRecipientPickedFails(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "penc-sender2@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "penc-recip2@example.com", domainID, "recip-password-1!")
genPGPIdentity(t, app, senderID, "penc-sender2@example.com", "sender passphrase")
// No contact key added, and no pgp_recipient_id submitted either.
cookie := webmailLoginSession(t, app, senderID)
form := url.Values{
"to": {"penc-recip2@example.com"}, "subject": {"x"}, "body_html": {"x"},
"pgp_encrypt": {"1"},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(msgs) != 0 {
t.Fatalf("expected no message delivered, got %d (err=%v)", len(msgs), err)
}
}
// TestWebmailComposePGPEncryptPickedContactFromAnotherMailboxFails confirms a
// mailbox can't encrypt to a contact ID it doesn't own — GetPGPContactByID is scoped
// per mailbox the same way every other identity/contact lookup is.
func TestWebmailComposePGPEncryptPickedContactFromAnotherMailboxFails(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "penc-sender5@example.com", domainID, "sender-password-1!")
otherMailboxID := createTestMailboxWithPassword(t, app, "penc-other5@example.com", domainID, "other-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "penc-recip5@example.com", domainID, "recip-password-1!")
genPGPIdentity(t, app, senderID, "penc-sender5@example.com", "sender passphrase")
recipIdentityID := genPGPIdentity(t, app, recipientID, "penc-recip5@example.com", "recipient passphrase")
recipIdentity, err := app.DB.GetPGPIdentity(recipientID, recipIdentityID)
if err != nil || recipIdentity == nil {
t.Fatal(err)
}
// The contact is filed under a different mailbox than the sender.
if err := app.DB.UpsertPGPContact(otherMailboxID, "penc-recip5@example.com", "", recipIdentity.Fingerprint, recipIdentity.PublicKeyArmor); err != nil {
t.Fatal(err)
}
otherContact, err := app.DB.GetPGPContact(otherMailboxID, "penc-recip5@example.com")
if err != nil || otherContact == nil {
t.Fatal(err)
}
cookie := webmailLoginSession(t, app, senderID)
form := url.Values{
"to": {"penc-recip5@example.com"}, "subject": {"x"}, "body_html": {"x"},
"pgp_encrypt": {"1"}, "pgp_recipient_id": {strconv.FormatInt(otherContact.ID, 10)},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(msgs) != 0 {
t.Fatalf("expected no message delivered, got %d (err=%v)", len(msgs), err)
}
}
// TestWebmailComposePGPEncryptWithoutOwnKeyFails confirms encrypting requires the
// sender's own PGP key too (so the Sent copy stays readable) — not just the
// recipient's.
func TestWebmailComposePGPEncryptWithoutOwnKeyFails(t *testing.T) {
app := newTestApp(t)
mux := app.Mux()
domains, _ := app.DB.ListDomains()
domainID := domains[0].ID
senderID := createTestMailboxWithPassword(t, app, "penc-sender4@example.com", domainID, "sender-password-1!")
recipientID := createTestMailboxWithPassword(t, app, "penc-recip4@example.com", domainID, "recip-password-1!")
recipIdentityID := genPGPIdentity(t, app, recipientID, "penc-recip4@example.com", "recipient passphrase")
recipIdentity, err := app.DB.GetPGPIdentity(recipientID, recipIdentityID)
if err != nil || recipIdentity == nil {
t.Fatal(err)
}
if err := app.DB.UpsertPGPContact(senderID, "penc-recip4@example.com", "", recipIdentity.Fingerprint, recipIdentity.PublicKeyArmor); err != nil {
t.Fatal(err)
}
// Sender has no PGP identity of their own.
cookie := webmailLoginSession(t, app, senderID)
form := url.Values{
"to": {"penc-recip4@example.com"}, "subject": {"x"}, "body_html": {"x"},
"pgp_encrypt": {"1"},
}
req := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/mail/compose", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
msgs, err := app.DB.ListMessagesInFolder(recipientID, "INBOX")
if err != nil || len(msgs) != 0 {
t.Fatalf("expected no message delivered, got %d (err=%v)", len(msgs), err)
}
}