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
This commit is contained in:
64
cmd/draft.go
64
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 <draft-id>",
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
133
cmd/mail.go
133
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user