229 lines
10 KiB
Go
229 lines
10 KiB
Go
package webui
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"net/url"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
)
|
||
|
|
|
||
|
|
// TestWebmailReadPGPEncryptedMessageRequiresUnlockThenDecrypts confirms the full
|
||
|
|
// decrypt-then-view flow: opening a PGP-encrypted message the recipient hasn't
|
||
|
|
// unlocked yet shows a passphrase prompt (not the content), and submitting the
|
||
|
|
// actual /pgp/unlock form makes the same message decrypt and render on the next
|
||
|
|
// view.
|
||
|
|
func TestWebmailReadPGPEncryptedMessageRequiresUnlockThenDecrypts(t *testing.T) {
|
||
|
|
app := newTestApp(t)
|
||
|
|
mux := app.Mux()
|
||
|
|
domains, _ := app.DB.ListDomains()
|
||
|
|
domainID := domains[0].ID
|
||
|
|
|
||
|
|
senderID := createTestMailboxWithPassword(t, app, "pread-enc-sender@example.com", domainID, "sender-password-1!")
|
||
|
|
recipientID := createTestMailboxWithPassword(t, app, "pread-enc-recip@example.com", domainID, "recip-password-1!")
|
||
|
|
genPGPIdentity(t, app, senderID, "pread-enc-sender@example.com", "sender passphrase")
|
||
|
|
recipIdentityID := genPGPIdentity(t, app, recipientID, "pread-enc-recip@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, "pread-enc-recip@example.com", "", recipIdentity.Fingerprint, recipIdentity.PublicKeyArmor); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
recipContact, err := app.DB.GetPGPContact(senderID, "pread-enc-recip@example.com")
|
||
|
|
if err != nil || recipContact == nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
senderCookie := webmailLoginSession(t, app, senderID)
|
||
|
|
form := url.Values{
|
||
|
|
"to": {"pread-enc-recip@example.com"}, "subject": {"Encrypted read test"}, "body_html": {"the vault code is 9999"},
|
||
|
|
"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(senderCookie)
|
||
|
|
mux.ServeHTTP(httptest.NewRecorder(), req)
|
||
|
|
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)
|
||
|
|
}
|
||
|
|
messageURL := MailboxPrefix + "/mail/INBOX/" + strconv.FormatInt(msgs[0].ID, 10)
|
||
|
|
|
||
|
|
recipientCookie := webmailLoginSession(t, app, recipientID)
|
||
|
|
|
||
|
|
// First view: nothing unlocked yet — should prompt, not decrypt.
|
||
|
|
viewReq := httptest.NewRequest(http.MethodGet, messageURL, nil)
|
||
|
|
viewReq.AddCookie(recipientCookie)
|
||
|
|
viewRec := httptest.NewRecorder()
|
||
|
|
mux.ServeHTTP(viewRec, viewReq)
|
||
|
|
if viewRec.Code != http.StatusOK {
|
||
|
|
t.Fatalf("view: status=%d body=%s", viewRec.Code, viewRec.Body.String())
|
||
|
|
}
|
||
|
|
body := viewRec.Body.String()
|
||
|
|
if !strings.Contains(body, "enter your passphrase to decrypt") {
|
||
|
|
t.Fatalf("expected a needs-unlock prompt on first view, got body: %s", body)
|
||
|
|
}
|
||
|
|
if strings.Contains(body, "vault code") {
|
||
|
|
t.Fatal("plaintext should not render before unlocking")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Unlock via the real endpoint, exactly as the rendered form would submit.
|
||
|
|
unlockForm := url.Values{"identity_id": {strconv.FormatInt(recipIdentityID, 10)}, "passphrase": {"recipient passphrase"}, "next": {messageURL}}
|
||
|
|
unlockReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/pgp/unlock", strings.NewReader(unlockForm.Encode()))
|
||
|
|
unlockReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
|
unlockReq.AddCookie(recipientCookie)
|
||
|
|
unlockRec := httptest.NewRecorder()
|
||
|
|
mux.ServeHTTP(unlockRec, unlockReq)
|
||
|
|
if unlockRec.Code != http.StatusFound || unlockRec.Header().Get("Location") != messageURL {
|
||
|
|
t.Fatalf("unlock: status=%d location=%q", unlockRec.Code, unlockRec.Header().Get("Location"))
|
||
|
|
}
|
||
|
|
|
||
|
|
// Second view: now decrypts.
|
||
|
|
viewReq2 := httptest.NewRequest(http.MethodGet, messageURL, nil)
|
||
|
|
viewReq2.AddCookie(recipientCookie)
|
||
|
|
viewRec2 := httptest.NewRecorder()
|
||
|
|
mux.ServeHTTP(viewRec2, viewReq2)
|
||
|
|
if viewRec2.Code != http.StatusOK {
|
||
|
|
t.Fatalf("view after unlock: status=%d", viewRec2.Code)
|
||
|
|
}
|
||
|
|
body2 := viewRec2.Body.String()
|
||
|
|
if !strings.Contains(body2, "PGP encrypted & decrypted") {
|
||
|
|
t.Fatalf("expected a decrypted badge after unlock, got body: %s", body2)
|
||
|
|
}
|
||
|
|
if !strings.Contains(body2, "the vault code is 9999") {
|
||
|
|
t.Fatal("expected the decrypted body rendered after unlock")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestWebmailReadSMIMESignAndPGPEncryptTogether confirms a message both S/MIME-signed
|
||
|
|
// and PGP-encrypted unwraps correctly on read once unlocked — the trickiest path
|
||
|
|
// through unwrapCrypto's loop, since it must peel two DIFFERENT protocols' layers in
|
||
|
|
// the right order.
|
||
|
|
func TestWebmailReadSMIMESignAndPGPEncryptTogether(t *testing.T) {
|
||
|
|
app := newTestApp(t)
|
||
|
|
mux := app.Mux()
|
||
|
|
domains, _ := app.DB.ListDomains()
|
||
|
|
domainID := domains[0].ID
|
||
|
|
|
||
|
|
senderID := createTestMailboxWithPassword(t, app, "pboth-sender@example.com", domainID, "sender-password-1!")
|
||
|
|
recipientID := createTestMailboxWithPassword(t, app, "pboth-recip@example.com", domainID, "recip-password-1!")
|
||
|
|
genIdentity(t, app, senderID, "pboth-sender@example.com")
|
||
|
|
genPGPIdentity(t, app, senderID, "pboth-sender@example.com", "pgp sender passphrase")
|
||
|
|
recipIdentityID := genPGPIdentity(t, app, recipientID, "pboth-recip@example.com", "pgp recipient passphrase")
|
||
|
|
recipIdentity, err := app.DB.GetPGPIdentity(recipientID, recipIdentityID)
|
||
|
|
if err != nil || recipIdentity == nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if err := app.DB.UpsertPGPContact(senderID, "pboth-recip@example.com", "", recipIdentity.Fingerprint, recipIdentity.PublicKeyArmor); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
recipContact, err := app.DB.GetPGPContact(senderID, "pboth-recip@example.com")
|
||
|
|
if err != nil || recipContact == nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
senderCookie := webmailLoginSession(t, app, senderID)
|
||
|
|
form := url.Values{
|
||
|
|
"to": {"pboth-recip@example.com"}, "subject": {"Sign and encrypt"}, "body_html": {"both protections applied"},
|
||
|
|
"smime_sign": {"1"}, "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(senderCookie)
|
||
|
|
sendRec := httptest.NewRecorder()
|
||
|
|
mux.ServeHTTP(sendRec, req)
|
||
|
|
if sendRec.Code != http.StatusFound {
|
||
|
|
t.Fatalf("compose send: status=%d body=%s", sendRec.Code, sendRec.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)
|
||
|
|
}
|
||
|
|
messageURL := MailboxPrefix + "/mail/INBOX/" + strconv.FormatInt(msgs[0].ID, 10)
|
||
|
|
|
||
|
|
recipientCookie := webmailLoginSession(t, app, recipientID)
|
||
|
|
unlockForm := url.Values{"identity_id": {strconv.FormatInt(recipIdentityID, 10)}, "passphrase": {"pgp recipient passphrase"}}
|
||
|
|
unlockReq := httptest.NewRequest(http.MethodPost, MailboxPrefix+"/pgp/unlock", strings.NewReader(unlockForm.Encode()))
|
||
|
|
unlockReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
|
unlockReq.AddCookie(recipientCookie)
|
||
|
|
mux.ServeHTTP(httptest.NewRecorder(), unlockReq)
|
||
|
|
|
||
|
|
viewReq := httptest.NewRequest(http.MethodGet, messageURL, nil)
|
||
|
|
viewReq.AddCookie(recipientCookie)
|
||
|
|
viewRec := httptest.NewRecorder()
|
||
|
|
mux.ServeHTTP(viewRec, viewReq)
|
||
|
|
if viewRec.Code != http.StatusOK {
|
||
|
|
t.Fatalf("view: status=%d body=%s", viewRec.Code, viewRec.Body.String())
|
||
|
|
}
|
||
|
|
body := viewRec.Body.String()
|
||
|
|
if !strings.Contains(body, "PGP encrypted & decrypted") {
|
||
|
|
t.Fatalf("expected a decrypted PGP badge, got body: %s", body)
|
||
|
|
}
|
||
|
|
if !strings.Contains(body, "Signature verified") {
|
||
|
|
t.Fatalf("expected a verified S/MIME signature badge, got body: %s", body)
|
||
|
|
}
|
||
|
|
if !strings.Contains(body, "both protections applied") {
|
||
|
|
t.Fatal("expected the plaintext rendered after unwrapping both layers")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestWebmailReadPGPEncryptedMessageNoIdentityShowsUndecryptable confirms a
|
||
|
|
// recipient with no PGP key at all sees an honest "could not decrypt" badge rather
|
||
|
|
// than a needs-unlock prompt (there's nothing to unlock) or a crash.
|
||
|
|
func TestWebmailReadPGPEncryptedMessageNoIdentityShowsUndecryptable(t *testing.T) {
|
||
|
|
app := newTestApp(t)
|
||
|
|
mux := app.Mux()
|
||
|
|
domains, _ := app.DB.ListDomains()
|
||
|
|
domainID := domains[0].ID
|
||
|
|
|
||
|
|
senderID := createTestMailboxWithPassword(t, app, "pread-enc-sender2@example.com", domainID, "sender-password-1!")
|
||
|
|
recipientID := createTestMailboxWithPassword(t, app, "pread-enc-recip2@example.com", domainID, "recip-password-1!")
|
||
|
|
genPGPIdentity(t, app, senderID, "pread-enc-sender2@example.com", "sender passphrase")
|
||
|
|
recipIdentityID := genPGPIdentity(t, app, recipientID, "pread-enc-recip2@example.com", "recipient passphrase")
|
||
|
|
recipIdentity, _ := app.DB.GetPGPIdentity(recipientID, recipIdentityID)
|
||
|
|
app.DB.UpsertPGPContact(senderID, "pread-enc-recip2@example.com", "", recipIdentity.Fingerprint, recipIdentity.PublicKeyArmor)
|
||
|
|
recipContact, err := app.DB.GetPGPContact(senderID, "pread-enc-recip2@example.com")
|
||
|
|
if err != nil || recipContact == nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
senderCookie := webmailLoginSession(t, app, senderID)
|
||
|
|
form := url.Values{
|
||
|
|
"to": {"pread-enc-recip2@example.com"}, "subject": {"x"}, "body_html": {"secret"},
|
||
|
|
"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(senderCookie)
|
||
|
|
mux.ServeHTTP(httptest.NewRecorder(), req)
|
||
|
|
msgs, _ := app.DB.ListMessagesInFolder(recipientID, "INBOX")
|
||
|
|
if len(msgs) != 1 {
|
||
|
|
t.Fatalf("expected 1 message, got %d", len(msgs))
|
||
|
|
}
|
||
|
|
|
||
|
|
// Recipient loses their PGP key (e.g. removed it) before reading.
|
||
|
|
if err := app.DB.DeletePGPIdentity(recipientID, recipIdentityID); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
recipientCookie := webmailLoginSession(t, app, recipientID)
|
||
|
|
viewReq := httptest.NewRequest(http.MethodGet, MailboxPrefix+"/mail/INBOX/"+strconv.FormatInt(msgs[0].ID, 10), nil)
|
||
|
|
viewReq.AddCookie(recipientCookie)
|
||
|
|
viewRec := httptest.NewRecorder()
|
||
|
|
mux.ServeHTTP(viewRec, viewReq)
|
||
|
|
if viewRec.Code != http.StatusOK {
|
||
|
|
t.Fatalf("view: status=%d body=%s", viewRec.Code, viewRec.Body.String())
|
||
|
|
}
|
||
|
|
body := viewRec.Body.String()
|
||
|
|
if !strings.Contains(body, "could not decrypt") {
|
||
|
|
t.Fatalf("expected an undecryptable badge, got body: %s", body)
|
||
|
|
}
|
||
|
|
if strings.Contains(body, "secret") {
|
||
|
|
t.Fatal("plaintext should not have leaked without successful decryption")
|
||
|
|
}
|
||
|
|
}
|