From 0684e726bbd8ec7b7cbeea419d7237367662cbb4 Mon Sep 17 00:00:00 2001 From: Paperclip Date: Tue, 28 Apr 2026 12:36:27 -0400 Subject: [PATCH] FRE-681: Fix security review findings (3 HIGH, 3 MEDIUM, 2 LOW) HIGH fixes: - Access Token now used as PGP Passphrase: replaced session.AccessToken with session.MailPassphrase for all PGP operations - Session stored encrypted in keyring and file (was plain JSON) - Added checkAuthenticated() helper with IsAuthenticated() guard MEDIUM fixes: - Added MailPassphrase field to Session, collected during login - Added email validation in LoginInteractive - Added keyring cleanup on Logout - Implemented RefreshToken with actual API call LOW fixes: - Added mutex to PGPKeyRing for thread safety - Added ZeroPrivateKeyData() for memory cleanup - Use net/mail.ParseAddress for proper recipient parsing - Renamed internal/mail import to internalmail to avoid conflict --- cmd/draft.go | 64 +++++++--------- cmd/mail.go | 133 +++++++++++++++++---------------- internal/auth/session.go | 154 ++++++++++++++++++++++++++++----------- internal/mail/client.go | 4 +- internal/mail/pgp.go | 21 +++++- internal/mail/types.go | 9 ++- 6 files changed, 232 insertions(+), 153 deletions(-) diff --git a/cmd/draft.go b/cmd/draft.go index 75dd324..29d7b60 100644 --- a/cmd/draft.go +++ b/cmd/draft.go @@ -6,9 +6,8 @@ import ( "strconv" "github.com/frenocorp/pop/internal/api" - "github.com/frenocorp/pop/internal/auth" "github.com/frenocorp/pop/internal/config" - "github.com/frenocorp/pop/internal/mail" + internalmail "github.com/frenocorp/pop/internal/mail" "github.com/spf13/cobra" ) @@ -49,12 +48,12 @@ func draftSaveCmd() *cobra.Command { } recipients := parseRecipients(to) - var ccRecipients []mail.Recipient + var ccRecipients []internalmail.Recipient if cc != "" { ccRecipients = parseRecipients(cc) } - var bccRecipients []mail.Recipient + var bccRecipients []internalmail.Recipient if bcc != "" { bccRecipients = parseRecipients(bcc) } @@ -65,20 +64,16 @@ func draftSaveCmd() *cobra.Command { return fmt.Errorf("failed to load config: %w", err) } - sessionMgr, err := auth.NewSessionManager() - if err != nil { - return fmt.Errorf("failed to create session manager: %w", err) - } - session, err := sessionMgr.GetSession() + session, err := checkAuthenticated() if err != nil { return fmt.Errorf("not authenticated: %w", err) } client := api.NewProtonMailClient(cfg) client.SetAuthHeader(session.AccessToken) - mailClient := mail.NewClient(client) + mailClient := internalmail.NewClient(client) - draft := mail.Draft{ + draft := internalmail.Draft{ To: recipients, CC: ccRecipients, BCC: bccRecipients, @@ -86,7 +81,7 @@ func draftSaveCmd() *cobra.Command { Body: msgBody, } - messageID, err := mailClient.SaveDraft(draft, session.AccessToken) + messageID, err := mailClient.SaveDraft(draft, session.MailPassphrase) if err != nil { return fmt.Errorf("failed to save draft: %w", err) } @@ -130,20 +125,16 @@ func draftListCmd() *cobra.Command { return fmt.Errorf("failed to load config: %w", err) } - sessionMgr, err := auth.NewSessionManager() - if err != nil { - return fmt.Errorf("failed to create session manager: %w", err) - } - session, err := sessionMgr.GetSession() + session, err := checkAuthenticated() if err != nil { return fmt.Errorf("not authenticated: %w", err) } client := api.NewProtonMailClient(cfg) client.SetAuthHeader(session.AccessToken) - mailClient := mail.NewClient(client) + mailClient := internalmail.NewClient(client) - result, err := mailClient.ListDrafts(pageVal, pageSizeVal, session.AccessToken) + result, err := mailClient.ListDrafts(pageVal, pageSizeVal, session.MailPassphrase) if err != nil { return fmt.Errorf("failed to list drafts: %w", err) } @@ -159,7 +150,7 @@ func draftListCmd() *cobra.Command { } func draftEditCmd() *cobra.Command { - var to, cc, subject, bodyFile, body string + var to, cc, bcc, subject, bodyFile, body string cmd := &cobra.Command{ Use: "edit ", @@ -169,16 +160,21 @@ func draftEditCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { messageID := args[0] - var recipients []mail.Recipient + var recipients []internalmail.Recipient if to != "" { recipients = parseRecipients(to) } - var ccRecipients []mail.Recipient + var ccRecipients []internalmail.Recipient if cc != "" { ccRecipients = parseRecipients(cc) } + var bccRecipients []internalmail.Recipient + if bcc != "" { + bccRecipients = parseRecipients(bcc) + } + msgBody := body if bodyFile != "" { data, err := os.ReadFile(bodyFile) @@ -194,27 +190,24 @@ func draftEditCmd() *cobra.Command { return fmt.Errorf("failed to load config: %w", err) } - sessionMgr, err := auth.NewSessionManager() - if err != nil { - return fmt.Errorf("failed to create session manager: %w", err) - } - session, err := sessionMgr.GetSession() + session, err := checkAuthenticated() if err != nil { return fmt.Errorf("not authenticated: %w", err) } client := api.NewProtonMailClient(cfg) client.SetAuthHeader(session.AccessToken) - mailClient := mail.NewClient(client) + mailClient := internalmail.NewClient(client) - draft := mail.Draft{ + draft := internalmail.Draft{ To: recipients, CC: ccRecipients, + BCC: bccRecipients, Subject: subject, Body: msgBody, } - if err := mailClient.UpdateDraft(messageID, draft, session.AccessToken); err != nil { + if err := mailClient.UpdateDraft(messageID, draft, session.MailPassphrase); err != nil { return fmt.Errorf("failed to update draft: %w", err) } @@ -225,6 +218,7 @@ func draftEditCmd() *cobra.Command { cmd.Flags().StringVarP(&to, "to", "t", "", "New recipient addresses (comma-separated)") cmd.Flags().StringVarP(&cc, "cc", "c", "", "New CC addresses (comma-separated)") + cmd.Flags().StringVarP(&bcc, "bcc", "b", "", "New BCC addresses (comma-separated)") cmd.Flags().StringVarP(&subject, "subject", "s", "", "New draft subject") cmd.Flags().StringVarP(&bodyFile, "body-file", "f", "", "File containing new draft body") cmd.Flags().StringVar(&body, "body", "", "New inline draft body") @@ -247,20 +241,16 @@ func draftSendCmd() *cobra.Command { return fmt.Errorf("failed to load config: %w", err) } - sessionMgr, err := auth.NewSessionManager() - if err != nil { - return fmt.Errorf("failed to create session manager: %w", err) - } - session, err := sessionMgr.GetSession() + session, err := checkAuthenticated() if err != nil { return fmt.Errorf("not authenticated: %w", err) } client := api.NewProtonMailClient(cfg) client.SetAuthHeader(session.AccessToken) - mailClient := mail.NewClient(client) + mailClient := internalmail.NewClient(client) - if err := mailClient.SendDraft(messageID, session.AccessToken); err != nil { + if err := mailClient.SendDraft(messageID, session.MailPassphrase); err != nil { return fmt.Errorf("failed to send draft: %w", err) } diff --git a/cmd/mail.go b/cmd/mail.go index 6113d8a..8c65888 100644 --- a/cmd/mail.go +++ b/cmd/mail.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "net/mail" "os" "strconv" "strings" @@ -10,10 +11,26 @@ import ( "github.com/frenocorp/pop/internal/api" "github.com/frenocorp/pop/internal/auth" "github.com/frenocorp/pop/internal/config" - "github.com/frenocorp/pop/internal/mail" + internalmail "github.com/frenocorp/pop/internal/mail" "github.com/spf13/cobra" ) +func checkAuthenticated() (*auth.Session, error) { + sessionMgr, err := auth.NewSessionManager() + if err != nil { + return nil, fmt.Errorf("failed to create session manager: %w", err) + } + authenticated, err := sessionMgr.IsAuthenticated() + if err != nil || !authenticated { + return nil, fmt.Errorf("not authenticated (run 'pop login' first): %w", err) + } + session, err := sessionMgr.GetSession() + if err != nil { + return nil, fmt.Errorf("not authenticated: %w", err) + } + return session, nil +} + func mailCmd() *cobra.Command { cmd := &cobra.Command{ Use: "mail", @@ -47,31 +64,27 @@ func mailListCmd() *cobra.Command { return fmt.Errorf("failed to load config: %w", err) } - sessionMgr, err := auth.NewSessionManager() + session, err := checkAuthenticated() if err != nil { - return fmt.Errorf("failed to create session manager: %w", err) - } - session, err := sessionMgr.GetSession() - if err != nil { - return fmt.Errorf("not authenticated: %w", err) + return err } client := api.NewProtonMailClient(cfg) client.SetAuthHeader(session.AccessToken) - mailClient := mail.NewClient(client) + mailClient := internalmail.NewClient(client) - folderVal := mail.FolderInbox + folderVal := internalmail.FolderInbox switch folder { case "inbox": - folderVal = mail.FolderInbox + folderVal = internalmail.FolderInbox case "sent": - folderVal = mail.FolderSent + folderVal = internalmail.FolderSent case "drafts": - folderVal = mail.FolderDraft + folderVal = internalmail.FolderDraft case "trash": - folderVal = mail.FolderTrash + folderVal = internalmail.FolderTrash case "spam": - folderVal = mail.FolderSpam + folderVal = internalmail.FolderSpam default: return fmt.Errorf("unknown folder: %s (valid: inbox, sent, drafts, trash, spam)", folder) } @@ -101,11 +114,11 @@ func mailListCmd() *cobra.Command { readPtr = &v } - req := mail.ListMessagesRequest{ + req := internalmail.ListMessagesRequest{ Folder: folderVal, Page: pageVal, PageSize: pageSizeVal, - Passphrase: session.AccessToken, + Passphrase: session.MailPassphrase, Starred: starredPtr, Read: readPtr, Since: since, @@ -145,20 +158,16 @@ func mailReadCmd() *cobra.Command { return fmt.Errorf("failed to load config: %w", err) } - sessionMgr, err := auth.NewSessionManager() + session, err := checkAuthenticated() if err != nil { - return fmt.Errorf("failed to create session manager: %w", err) - } - session, err := sessionMgr.GetSession() - if err != nil { - return fmt.Errorf("not authenticated: %w", err) + return err } client := api.NewProtonMailClient(cfg) client.SetAuthHeader(session.AccessToken) - mailClient := mail.NewClient(client) + mailClient := internalmail.NewClient(client) - msg, err := mailClient.GetMessage(messageID, session.AccessToken) + msg, err := mailClient.GetMessage(messageID, session.MailPassphrase) if err != nil { return fmt.Errorf("failed to get message: %w", err) } @@ -198,7 +207,7 @@ func mailSendCmd() *cobra.Command { } recipients := parseRecipients(to) - var ccRecipients, bccRecipients []mail.Recipient + var ccRecipients, bccRecipients []internalmail.Recipient if cc != "" { ccRecipients = parseRecipients(cc) } @@ -212,27 +221,23 @@ func mailSendCmd() *cobra.Command { return fmt.Errorf("failed to load config: %w", err) } - sessionMgr, err := auth.NewSessionManager() + session, err := checkAuthenticated() if err != nil { - return fmt.Errorf("failed to create session manager: %w", err) - } - session, err := sessionMgr.GetSession() - if err != nil { - return fmt.Errorf("not authenticated: %w", err) + return err } client := api.NewProtonMailClient(cfg) client.SetAuthHeader(session.AccessToken) - mailClient := mail.NewClient(client) + mailClient := internalmail.NewClient(client) - req := mail.SendRequest{ + req := internalmail.SendRequest{ To: recipients, CC: ccRecipients, BCC: bccRecipients, Subject: subject, Body: bodyContent, HTML: html, - Passphrase: session.AccessToken, + Passphrase: session.MailPassphrase, } if err := mailClient.Send(req); err != nil { @@ -271,18 +276,14 @@ func mailDeleteCmd() *cobra.Command { return fmt.Errorf("failed to load config: %w", err) } - sessionMgr, err := auth.NewSessionManager() + session, err := checkAuthenticated() if err != nil { - return fmt.Errorf("failed to create session manager: %w", err) - } - session, err := sessionMgr.GetSession() - if err != nil { - return fmt.Errorf("not authenticated: %w", err) + return err } client := api.NewProtonMailClient(cfg) client.SetAuthHeader(session.AccessToken) - mailClient := mail.NewClient(client) + mailClient := internalmail.NewClient(client) if err := mailClient.PermanentlyDelete(messageID); err != nil { return fmt.Errorf("failed to delete message: %w", err) @@ -311,20 +312,16 @@ func mailTrashCmd() *cobra.Command { return fmt.Errorf("failed to load config: %w", err) } - sessionMgr, err := auth.NewSessionManager() + session, err := checkAuthenticated() if err != nil { - return fmt.Errorf("failed to create session manager: %w", err) - } - session, err := sessionMgr.GetSession() - if err != nil { - return fmt.Errorf("not authenticated: %w", err) + return err } client := api.NewProtonMailClient(cfg) client.SetAuthHeader(session.AccessToken) - mailClient := mail.NewClient(client) + mailClient := internalmail.NewClient(client) - if err := mailClient.MoveToTrash(messageID, session.AccessToken); err != nil { + if err := mailClient.MoveToTrash(messageID, session.MailPassphrase); err != nil { return fmt.Errorf("failed to move to trash: %w", err) } @@ -336,7 +333,7 @@ func mailTrashCmd() *cobra.Command { return cmd } -func printMessages(messages []mail.Message) error { +func printMessages(messages []internalmail.Message) error { w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "ID\tFrom\tSubject\tDate\tStarred\tRead") fmt.Fprintln(w, "--\t----\t-------\t----\t-------\t----") @@ -369,7 +366,7 @@ func printMessages(messages []mail.Message) error { return w.Flush() } -func printMessageDetail(msg *mail.Message) error { +func printMessageDetail(msg *internalmail.Message) error { fmt.Printf("From: %s\n", msg.Sender.DisplayName()) fmt.Printf("To: %s\n", formatRecipients(msg.Recipients)) fmt.Printf("Subject: %s\n", msg.Subject) @@ -394,26 +391,30 @@ func printMessageDetail(msg *mail.Message) error { return nil } -func parseRecipients(input string) []mail.Recipient { - var recipients []mail.Recipient +func parseRecipients(input string) []internalmail.Recipient { + var recipients []internalmail.Recipient for _, addr := range strings.Split(input, ",") { addr = strings.TrimSpace(addr) if addr == "" { continue } - r := mail.Recipient{Address: addr} - if strings.Contains(addr, "<") { - parts := strings.SplitN(addr, "<", 2) - r.Name = strings.TrimSpace(parts[0]) - r.Address = strings.Trim(parts[1], "<>") + parsed, err := mail.ParseAddress(addr) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: invalid address %q: %v\n", addr, err) + continue + } + + r := internalmail.Recipient{ + Name: parsed.Name, + Address: parsed.Address, } recipients = append(recipients, r) } return recipients } -func formatRecipients(recipients []mail.Recipient) string { +func formatRecipients(recipients []internalmail.Recipient) string { parts := make([]string, len(recipients)) for i, r := range recipients { parts[i] = r.DisplayName() @@ -455,18 +456,14 @@ func mailSearchCmd() *cobra.Command { return fmt.Errorf("failed to load config: %w", err) } - sessionMgr, err := auth.NewSessionManager() + session, err := checkAuthenticated() if err != nil { - return fmt.Errorf("failed to create session manager: %w", err) - } - session, err := sessionMgr.GetSession() - if err != nil { - return fmt.Errorf("not authenticated: %w", err) + return err } client := api.NewProtonMailClient(cfg) client.SetAuthHeader(session.AccessToken) - mailClient := mail.NewClient(client) + mailClient := internalmail.NewClient(client) pageVal, err := strconv.Atoi(page) if err != nil || pageVal < 1 { @@ -481,11 +478,11 @@ func mailSearchCmd() *cobra.Command { pageSizeVal = 100 } - req := mail.SearchRequest{ + req := internalmail.SearchRequest{ Query: searchQuery, Page: pageVal, PageSize: pageSizeVal, - Passphrase: session.AccessToken, + Passphrase: session.MailPassphrase, } result, err := mailClient.SearchMessages(req) diff --git a/internal/auth/session.go b/internal/auth/session.go index 600231a..79b5a9e 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -21,11 +21,12 @@ import ( ) type Session struct { - UID string `json:"uid"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresAt int64 `json:"expires_at"` - TwoFAEnabled bool `json:"two_factor_enabled"` + UID string `json:"uid"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt int64 `json:"expires_at"` + TwoFAEnabled bool `json:"two_factor_enabled"` + MailPassphrase string `json:"mail_passphrase,omitempty"` } type SessionManager struct { @@ -53,7 +54,7 @@ func NewSessionManager() (*SessionManager, error) { }, nil } -func (m *SessionManager) LoginWithCredentials(apiBaseURL, email, password string) error { +func (m *SessionManager) LoginWithCredentials(apiBaseURL, email, password, mailPassphrase string) error { if err := os.MkdirAll(m.configDir, 0700); err != nil { return fmt.Errorf("failed to create config dir: %w", err) } @@ -106,31 +107,27 @@ func (m *SessionManager) LoginWithCredentials(apiBaseURL, email, password string } session := Session{ - UID: authResponse.UID, - AccessToken: authResponse.AccessToken, - RefreshToken: authResponse.RefreshToken, - ExpiresAt: time.Now().Unix() + int64(authResponse.ExpiresIn), - TwoFAEnabled: authResponse.TwoFARequired, + UID: authResponse.UID, + AccessToken: authResponse.AccessToken, + RefreshToken: authResponse.RefreshToken, + ExpiresAt: time.Now().Unix() + int64(authResponse.ExpiresIn), + TwoFAEnabled: authResponse.TwoFARequired, + MailPassphrase: mailPassphrase, } - encryptedData, err := encryptSession(session) + encryptedForFile, err := encryptSession(session) if err != nil { return fmt.Errorf("failed to encrypt session: %w", err) } - data, err := json.MarshalIndent(session, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal session: %w", err) - } - if err := m.keyring.Set(keyring.Item{ Key: "session", - Data: data, + Data: encryptedForFile, }); err != nil { return fmt.Errorf("failed to store session in keyring: %w", err) } - if err := os.WriteFile(m.sessionFile, encryptedData, 0600); err != nil { + if err := os.WriteFile(m.sessionFile, encryptedForFile, 0600); err != nil { return fmt.Errorf("failed to write encrypted session file: %w", err) } @@ -150,7 +147,11 @@ func (m *SessionManager) LoginInteractive(apiBaseURL string) error { emailPrompt := promptui.Prompt{ Label: "ProtonMail email", Validate: func(input string) error { - if !strings.Contains(input, "@") { + if !strings.Contains(input, "@") || !strings.Contains(input, ".") { + return fmt.Errorf("invalid email format") + } + parts := strings.Split(input, "@") + if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) < 3 { return fmt.Errorf("invalid email format") } return nil @@ -170,6 +171,15 @@ func (m *SessionManager) LoginInteractive(apiBaseURL string) error { return fmt.Errorf("failed to read password: %w", err) } + passphrasePrompt := promptui.Prompt{ + Label: "Mail passphrase", + Mask: '*', + } + mailPassphrase, err := passphrasePrompt.Run() + if err != nil { + return fmt.Errorf("failed to read mail passphrase: %w", err) + } + authURL := fmt.Sprintf("%s/auth", apiBaseURL) payload := map[string]string{ @@ -217,11 +227,12 @@ func (m *SessionManager) LoginInteractive(apiBaseURL string) error { } session := Session{ - UID: authResponse.UID, - AccessToken: authResponse.AccessToken, - RefreshToken: authResponse.RefreshToken, - ExpiresAt: time.Now().Unix() + int64(authResponse.ExpiresIn), - TwoFAEnabled: authResponse.TwoFARequired, + UID: authResponse.UID, + AccessToken: authResponse.AccessToken, + RefreshToken: authResponse.RefreshToken, + ExpiresAt: time.Now().Unix() + int64(authResponse.ExpiresIn), + TwoFAEnabled: authResponse.TwoFARequired, + MailPassphrase: mailPassphrase, } if session.TwoFAEnabled { @@ -285,24 +296,19 @@ func (m *SessionManager) LoginInteractive(apiBaseURL string) error { session.ExpiresAt = time.Now().Unix() + int64(finalAuth.ExpiresIn) } - encryptedData, err := encryptSession(session) + encryptedForFile, err := encryptSession(session) if err != nil { return fmt.Errorf("failed to encrypt session: %w", err) } - data, err := json.MarshalIndent(session, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal session: %w", err) - } - if err := m.keyring.Set(keyring.Item{ Key: "session", - Data: data, + Data: encryptedForFile, }); err != nil { return fmt.Errorf("failed to store session in keyring: %w", err) } - if err := os.WriteFile(m.sessionFile, encryptedData, 0600); err != nil { + if err := os.WriteFile(m.sessionFile, encryptedForFile, 0600); err != nil { return fmt.Errorf("failed to write encrypted session file: %w", err) } @@ -315,6 +321,10 @@ func (m *SessionManager) Logout() error { return fmt.Errorf("failed to remove session file: %w", err) } + if err := m.keyring.Remove("session"); err != nil { + return fmt.Errorf("failed to remove keyring entry: %w", err) + } + fmt.Println("Logged out successfully") return nil } @@ -323,9 +333,9 @@ func (m *SessionManager) GetSession() (*Session, error) { // First, try to get from keyring (encrypted storage) item, err := m.keyring.Get("session") if err == nil { - var session Session - if err := json.Unmarshal(item.Data, &session); err != nil { - return nil, fmt.Errorf("failed to parse session from keyring: %w", err) + session, err := decryptSession(item.Data) + if err != nil { + return nil, fmt.Errorf("failed to decrypt session from keyring: %w", err) } return &session, nil } @@ -361,16 +371,78 @@ func (m *SessionManager) IsAuthenticated() (bool, error) { return true, nil } -// RefreshToken refreshes the access token using the refresh token func (m *SessionManager) RefreshToken() error { - _, err := m.GetSession() + session, err := m.GetSession() if err != nil { return fmt.Errorf("failed to get session: %w", err) } - // TODO: Implement actual token refresh with API - // This would make a request to the ProtonMail API to get a new access token - return fmt.Errorf("token refresh not yet implemented - requires API integration") + if session.RefreshToken == "" { + return fmt.Errorf("no refresh token available") + } + + apiBaseURL := "https://api.protonmail.ch" + refreshURL := fmt.Sprintf("%s/auth/refresh", apiBaseURL) + + payload := map[string]string{ + "UID": session.UID, + "RefreshToken": session.RefreshToken, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal refresh payload: %w", err) + } + + req, err := http.NewRequest("POST", refreshURL, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create refresh request: %w", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", session.AccessToken)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to connect to ProtonMail API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("token refresh failed (status %d): %s", resp.StatusCode, string(body)) + } + + var refreshResponse struct { + AccessToken string `json:"AccessToken"` + RefreshToken string `json:"RefreshToken"` + ExpiresIn int `json:"ExpiresIn"` + } + + if err := json.NewDecoder(resp.Body).Decode(&refreshResponse); err != nil { + return fmt.Errorf("failed to parse refresh response: %w", err) + } + + session.AccessToken = refreshResponse.AccessToken + session.RefreshToken = refreshResponse.RefreshToken + session.ExpiresAt = time.Now().Unix() + int64(refreshResponse.ExpiresIn) + + encryptedForFile, err := encryptSession(*session) + if err != nil { + return fmt.Errorf("failed to encrypt updated session: %w", err) + } + + if err := m.keyring.Set(keyring.Item{ + Key: "session", + Data: encryptedForFile, + }); err != nil { + return fmt.Errorf("failed to update session in keyring: %w", err) + } + + _ = os.WriteFile(m.sessionFile, encryptedForFile, 0600) + + return nil } // encryptSession encrypts the session data using AES-256-GCM diff --git a/internal/mail/client.go b/internal/mail/client.go index eae16e3..233c595 100644 --- a/internal/mail/client.go +++ b/internal/mail/client.go @@ -122,7 +122,7 @@ func (c *Client) GetMessage(messageID string, passphrase string) (*Message, erro func (c *Client) Send(req SendRequest) error { payload := map[string]interface{}{ - "Type": "0", + "Type": MessageTypeRegular, "Passphrase": req.Passphrase, "Subject": req.Subject, "HTML": req.HTML, @@ -222,7 +222,7 @@ func (c *Client) PermanentlyDelete(messageID string) error { func (c *Client) SaveDraft(draft Draft, passphrase string) (string, error) { body := map[string]interface{}{ - "Type": "2", + "Type": MessageTypeDraft, "Passphrase": passphrase, "Subject": draft.Subject, "To": draft.To, diff --git a/internal/mail/pgp.go b/internal/mail/pgp.go index 08047f8..9d289fe 100644 --- a/internal/mail/pgp.go +++ b/internal/mail/pgp.go @@ -3,14 +3,16 @@ package mail import ( "crypto/rand" "fmt" + "sync" "github.com/ProtonMail/gopenpgp/v2/crypto" ) type PGPKeyRing struct { + mu sync.Mutex PrivateKey *crypto.Key PublicKey []byte - PrivateKeyData string + PrivateKeyData []byte } type PGPService struct { @@ -32,7 +34,7 @@ func NewPGPService(privateKeyArmored string) (*PGPService, error) { keyRing: &PGPKeyRing{ PrivateKey: privateKey, PublicKey: publicKey, - PrivateKeyData: privateKeyArmored, + PrivateKeyData: []byte(privateKeyArmored), }, }, nil } @@ -121,7 +123,9 @@ func (s *PGPService) EncryptAndSign(plaintext string, recipientPublicKey *crypto } func (s *PGPService) getUnlockedKeyRing(passphrase string) (*crypto.KeyRing, error) { - key, err := crypto.NewKeyFromArmored(s.keyRing.PrivateKeyData) + s.keyRing.mu.Lock() + key, err := crypto.NewKeyFromArmored(string(s.keyRing.PrivateKeyData)) + s.keyRing.mu.Unlock() if err != nil { return nil, fmt.Errorf("failed to parse private key: %w", err) } @@ -185,6 +189,17 @@ func (s *PGPService) GetFingerprint() (string, error) { return fingerprint, nil } +func (s *PGPService) ZeroPrivateKeyData() { + if s.keyRing == nil { + return + } + s.keyRing.mu.Lock() + defer s.keyRing.mu.Unlock() + for i := range s.keyRing.PrivateKeyData { + s.keyRing.PrivateKeyData[i] = 0 + } +} + func (s *PGPService) SignData(data []byte, passphrase string) (string, error) { pgpMessage := crypto.NewPlainMessage(data) diff --git a/internal/mail/types.go b/internal/mail/types.go index b3d3f1a..21afe70 100644 --- a/internal/mail/types.go +++ b/internal/mail/types.go @@ -12,6 +12,11 @@ const ( FolderSpam Folder = 5 ) +const ( + MessageTypeRegular = "0" + MessageTypeDraft = "2" +) + func (f Folder) Name() string { names := map[Folder]string{ FolderInbox: "Inbox", @@ -48,10 +53,10 @@ type Message struct { } func (m *Message) Folder() Folder { - if m.Type == 2 { + if m.Type == int(FolderDraft) { return FolderDraft } - if m.Type == 3 { + if m.Type == int(FolderSent) { return FolderSent } return FolderInbox