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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user