337 lines
13 KiB
Go
337 lines
13 KiB
Go
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)
|
|
}
|
|
}
|