Files
gowebmail/internal/handlers/certs.go
T

463 lines
14 KiB
Go

package handlers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ghostersk/gowebmail/internal/db"
"github.com/ghostersk/gowebmail/internal/middleware"
"github.com/ghostersk/gowebmail/internal/models"
"github.com/ghostersk/gowebmail/internal/pgp"
"github.com/ghostersk/gowebmail/internal/smime"
)
// dbSigner builds a signed/encrypted outgoing message from whatever S/MIME identity and
// PGP contact keys the sending account/user actually has on file. Implements
// internal/email.Signer. Sign first (if an S/MIME identity exists for the account), then
// encrypt (if every recipient has a PGP contact key on file) — matches the reference
// design: "S/MIME certificates sign... PGP keys encrypt...".
type dbSigner struct {
db *db.DB
userID int64
}
func (s *dbSigner) SignAndEncrypt(account *models.EmailAccount, recipients []string, raw []byte) ([]byte, error) {
out := raw
identities, err := s.db.ListSMIMEIdentities(account.ID)
if err == nil && len(identities) > 0 {
id := identities[0]
signed, err := smime.SignMIME([]byte(id.CertPEM), []byte(id.KeyPEM), out)
if err != nil {
return nil, fmt.Errorf("smime sign: %w", err)
}
out = signed
}
if len(recipients) > 0 {
var pgpEntities []*openpgp.Entity
allHaveKeys := true
for _, addr := range recipients {
contact, err := s.db.GetPGPContactByEmail(s.userID, addr)
if err != nil || contact == nil {
allHaveKeys = false
break
}
entity, err := pgp.ParsePublicKey([]byte(contact.PublicKeyArmor))
if err != nil {
allHaveKeys = false
break
}
pgpEntities = append(pgpEntities, entity)
}
if allHaveKeys && len(pgpEntities) > 0 {
encrypted, err := pgp.EncryptMIME(out, pgpEntities)
if err != nil {
return nil, fmt.Errorf("pgp encrypt: %w", err)
}
out = encrypted
}
}
return out, nil
}
// newSigner builds a Signer for outgoing mail on this account/user, or nil if no S/MIME
// identity and no PGP recipient keys apply — SendMessageFull treats nil as a no-op.
func (h *APIHandler) newSigner(userID int64) *dbSigner {
return &dbSigner{db: h.db, userID: userID}
}
// ---- S/MIME handlers ----
func (h *APIHandler) SMIMEIdentity(w http.ResponseWriter, r *http.Request) {
accountID := queryInt64(r, "account_id", 0)
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
identities, err := h.db.ListSMIMEIdentities(accountID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list identities")
return
}
h.writeJSON(w, identities)
}
func (h *APIHandler) SMIMEGenerate(w http.ResponseWriter, r *http.Request) {
var req struct {
AccountID int64 `json:"account_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AccountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
return
}
if !h.ownAccount(w, r, req.AccountID) {
return
}
account, _ := h.db.GetAccount(req.AccountID)
certPEM, keyPEM, err := smime.GenerateSelfSigned(account.EmailAddress, smime.DefaultValidity)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to generate certificate")
return
}
cert, _ := smime.ParseCertPEM(certPEM)
id, err := h.db.CreateSMIMEIdentity(req.AccountID, string(certPEM), string(keyPEM), cert.NotAfter)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to store identity")
return
}
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
}
func (h *APIHandler) SMIMEImport(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(5 << 20); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid form")
return
}
accountID := queryInt64(r, "account_id", 0)
if a, _ := strconv.ParseInt(r.FormValue("account_id"), 10, 64); a > 0 {
accountID = a
}
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
file, _, err := r.FormFile("p12_file")
if err != nil {
h.writeError(w, http.StatusBadRequest, "p12_file required")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
h.writeError(w, http.StatusBadRequest, "failed to read file")
return
}
password := r.FormValue("p12_password")
certPEM, keyPEM, err := smime.ImportPKCS12(data, password)
if err != nil {
h.writeError(w, http.StatusBadRequest, "failed to import: "+err.Error())
return
}
cert, _ := smime.ParseCertPEM(certPEM)
notAfter := time.Now().Add(smime.DefaultValidity)
if cert != nil {
notAfter = cert.NotAfter
}
id, err := h.db.CreateSMIMEIdentity(accountID, string(certPEM), string(keyPEM), notAfter)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to store identity")
return
}
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
}
func (h *APIHandler) SMIMERemoveIdentity(w http.ResponseWriter, r *http.Request) {
id := pathInt64(r, "id")
accountID := queryInt64(r, "account_id", 0)
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
if err := h.db.DeleteSMIMEIdentity(accountID, id); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete identity")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
func (h *APIHandler) SMIMEContacts(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
contacts, err := h.db.ListSMIMEContacts(userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list contacts")
return
}
h.writeJSON(w, contacts)
}
func (h *APIHandler) SMIMEAddContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
if err := r.ParseMultipartForm(2 << 20); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid form")
return
}
email := strings.TrimSpace(r.FormValue("email"))
if email == "" {
h.writeError(w, http.StatusBadRequest, "email required")
return
}
file, _, err := r.FormFile("cert_file")
if err != nil {
h.writeError(w, http.StatusBadRequest, "cert_file required")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
h.writeError(w, http.StatusBadRequest, "failed to read file")
return
}
if _, err := smime.ParseCertPEM(data); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid certificate: "+err.Error())
return
}
if err := h.db.UpsertSMIMEContact(userID, email, string(data)); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to save contact")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
func (h *APIHandler) SMIMERemoveContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
if err := h.db.DeleteSMIMEContact(userID, id); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete contact")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
// ---- PGP handlers ----
func (h *APIHandler) PGPIdentity(w http.ResponseWriter, r *http.Request) {
accountID := queryInt64(r, "account_id", 0)
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
identities, err := h.db.ListPGPIdentities(accountID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list identities")
return
}
h.writeJSON(w, identities)
}
func (h *APIHandler) PGPGenerate(w http.ResponseWriter, r *http.Request) {
var req struct {
AccountID int64 `json:"account_id"`
Label string `json:"label"`
Passphrase string `json:"passphrase"`
Confirm string `json:"passphrase_confirm"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AccountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
return
}
if !h.ownAccount(w, r, req.AccountID) {
return
}
if len(req.Passphrase) < 8 {
h.writeError(w, http.StatusBadRequest, "passphrase must be at least 8 characters")
return
}
if req.Passphrase != req.Confirm {
h.writeError(w, http.StatusBadRequest, "passphrases do not match")
return
}
account, _ := h.db.GetAccount(req.AccountID)
pubArmor, privArmor, err := pgp.GenerateKeyPair(account.EmailAddress, req.Passphrase)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to generate key")
return
}
entity, _ := pgp.ParsePublicKey(pubArmor)
fingerprint := ""
if entity != nil {
fingerprint = pgp.Fingerprint(entity)
}
id, err := h.db.CreatePGPIdentity(req.AccountID, req.Label, account.EmailAddress, fingerprint, string(pubArmor), string(privArmor))
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to store identity")
return
}
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
}
func (h *APIHandler) PGPImport(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(5 << 20); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid form")
return
}
accountID := queryInt64(r, "account_id", 0)
if a, _ := strconv.ParseInt(r.FormValue("account_id"), 10, 64); a > 0 {
accountID = a
}
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
passphrase := r.FormValue("passphrase")
label := r.FormValue("label")
file, _, err := r.FormFile("key_file")
if err != nil {
h.writeError(w, http.StatusBadRequest, "key_file required")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
h.writeError(w, http.StatusBadRequest, "failed to read file")
return
}
pubArmor, privArmor, err := pgp.ImportPrivateKey(data, passphrase)
if err != nil {
h.writeError(w, http.StatusBadRequest, "failed to import: "+err.Error())
return
}
entity, _ := pgp.ParsePublicKey(pubArmor)
email, fingerprint := "", ""
if entity != nil {
fingerprint = pgp.Fingerprint(entity)
for name := range entity.Identities {
if id := entity.Identities[name]; id.UserId != nil && id.UserId.Email != "" {
email = id.UserId.Email
break
}
}
}
account, _ := h.db.GetAccount(accountID)
if email == "" && account != nil {
email = account.EmailAddress
}
id, err := h.db.CreatePGPIdentity(accountID, label, email, fingerprint, string(pubArmor), string(privArmor))
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to store identity")
return
}
h.writeJSON(w, map[string]interface{}{"id": id, "ok": true})
}
func (h *APIHandler) PGPRemoveIdentity(w http.ResponseWriter, r *http.Request) {
id := pathInt64(r, "id")
accountID := queryInt64(r, "account_id", 0)
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
if err := h.db.DeletePGPIdentity(accountID, id); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete identity")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
// PGPUnlock verifies a passphrase decrypts the identity's private key, then caches the
// unlocked entity for this session (see internal/pgp.Cache) so a future decrypt-on-read
// of incoming PGP mail — not yet implemented — won't need to re-prompt for it. Cleared on
// logout (AuthHandler.Logout).
func (h *APIHandler) PGPUnlock(w http.ResponseWriter, r *http.Request) {
var req struct {
IdentityID int64 `json:"identity_id"`
Passphrase string `json:"passphrase"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.IdentityID == 0 {
h.writeError(w, http.StatusBadRequest, "identity_id required")
return
}
accountID := queryInt64(r, "account_id", 0)
if accountID == 0 || !h.ownAccount(w, r, accountID) {
if accountID == 0 {
h.writeError(w, http.StatusBadRequest, "account_id required")
}
return
}
identity, err := h.db.GetPGPIdentity(accountID, req.IdentityID)
if err != nil || identity == nil {
h.writeError(w, http.StatusNotFound, "identity not found")
return
}
entity, err := pgp.ParsePrivateKey([]byte(identity.PrivateKeyArmor))
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to parse key")
return
}
if err := pgp.UnlockPrivateKey(entity, req.Passphrase); err != nil {
h.writeError(w, http.StatusBadRequest, "incorrect passphrase")
return
}
if h.pgpCache != nil {
if cookie, err := r.Cookie("gomail_session"); err == nil {
h.pgpCache.Put(cookie.Value, req.IdentityID, entity)
}
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
func (h *APIHandler) PGPContacts(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
contacts, err := h.db.ListPGPContacts(userID)
if err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to list contacts")
return
}
h.writeJSON(w, contacts)
}
func (h *APIHandler) PGPAddContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
if err := r.ParseMultipartForm(2 << 20); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid form")
return
}
email := strings.TrimSpace(r.FormValue("email"))
if email == "" {
h.writeError(w, http.StatusBadRequest, "email required")
return
}
label := r.FormValue("label")
file, _, err := r.FormFile("key_file")
if err != nil {
h.writeError(w, http.StatusBadRequest, "key_file required")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
h.writeError(w, http.StatusBadRequest, "failed to read file")
return
}
entity, err := pgp.ParsePublicKey(data)
if err != nil {
h.writeError(w, http.StatusBadRequest, "invalid public key: "+err.Error())
return
}
if err := h.db.UpsertPGPContact(userID, email, label, pgp.Fingerprint(entity), string(data)); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to save contact")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}
func (h *APIHandler) PGPRemoveContact(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r)
id := pathInt64(r, "id")
if err := h.db.DeletePGPContact(userID, id); err != nil {
h.writeError(w, http.StatusInternalServerError, "failed to delete contact")
return
}
h.writeJSON(w, map[string]interface{}{"ok": true})
}