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:
Paperclip
2026-04-28 12:36:27 -04:00
committed by Michael Freno
parent e499d16b7c
commit 0684e726bb
6 changed files with 232 additions and 153 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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)

View File

@@ -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