Auto-commit 2026-04-27 19:13
This commit is contained in:
@@ -4,19 +4,62 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrInvalidAttachmentID is returned when attachmentID contains unsafe characters
|
||||
var ErrInvalidAttachmentID = os.ErrInvalid
|
||||
|
||||
type AttachmentManager struct {
|
||||
attachmentsDir string
|
||||
}
|
||||
|
||||
const maxUploadSize = 50 * 1024 * 1024 // 50MB
|
||||
|
||||
func NewAttachmentManager() *AttachmentManager {
|
||||
return &AttachmentManager{
|
||||
attachmentsDir: filepath.Join(os.Getenv("HOME"), ".config", "pop", "attachments"),
|
||||
}
|
||||
}
|
||||
|
||||
// isAttachmentIDSafe validates that attachmentID contains only safe characters
|
||||
// to prevent path traversal attacks
|
||||
func isAttachmentIDSafe(id string) bool {
|
||||
if id == "" {
|
||||
return false
|
||||
}
|
||||
// Only allow alphanumeric, hyphen, and underscore
|
||||
for _, r := range id {
|
||||
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') || r == '-' || r == '_') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// sanitizeAttachmentID ensures the attachmentID is safe and the resolved path
|
||||
// is within the attachments directory
|
||||
func sanitizeAttachmentID(id string) (string, error) {
|
||||
if !isAttachmentIDSafe(id) {
|
||||
return "", ErrInvalidAttachmentID
|
||||
}
|
||||
// Use filepath.Clean to resolve any .. or . components
|
||||
cleanID := filepath.Clean(id)
|
||||
if cleanID != id {
|
||||
return "", ErrInvalidAttachmentID
|
||||
}
|
||||
return cleanID, nil
|
||||
}
|
||||
|
||||
func (m *AttachmentManager) Download(attachmentID, name, destPath string) error {
|
||||
// Sanitize attachmentID to prevent path traversal
|
||||
if sanitizedID, err := sanitizeAttachmentID(attachmentID); err != nil {
|
||||
return err
|
||||
} else {
|
||||
attachmentID = sanitizedID
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(m.attachmentsDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -37,26 +80,45 @@ func (m *AttachmentManager) Download(attachmentID, name, destPath string) error
|
||||
}
|
||||
|
||||
func (m *AttachmentManager) Upload(attachmentID, name string, reader io.Reader) error {
|
||||
// Sanitize attachmentID to prevent path traversal
|
||||
if sanitizedID, err := sanitizeAttachmentID(attachmentID); err != nil {
|
||||
return err
|
||||
} else {
|
||||
attachmentID = sanitizedID
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(m.attachmentsDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := filepath.Join(m.attachmentsDir, attachmentID)
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
// Limit reader to maxUploadSize to prevent DoS
|
||||
limitedReader := io.LimitReader(reader, maxUploadSize)
|
||||
data, err := io.ReadAll(limitedReader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(path, data, 0644)
|
||||
return os.WriteFile(filepath.Join(m.attachmentsDir, attachmentID), data, 0644)
|
||||
}
|
||||
|
||||
func (m *AttachmentManager) Get(attachmentID string) ([]byte, error) {
|
||||
// Sanitize attachmentID to prevent path traversal
|
||||
if sanitizedID, err := sanitizeAttachmentID(attachmentID); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
attachmentID = sanitizedID
|
||||
}
|
||||
path := filepath.Join(m.attachmentsDir, attachmentID)
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
func (m *AttachmentManager) Delete(attachmentID string) error {
|
||||
// Sanitize attachmentID to prevent path traversal
|
||||
if sanitizedID, err := sanitizeAttachmentID(attachmentID); err != nil {
|
||||
return err
|
||||
} else {
|
||||
attachmentID = sanitizedID
|
||||
}
|
||||
path := filepath.Join(m.attachmentsDir, attachmentID)
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/99designs/keyring"
|
||||
"github.com/frenocorp/pop/internal/config"
|
||||
"github.com/manifoldco/promptui"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
@@ -18,31 +29,93 @@ type Session struct {
|
||||
}
|
||||
|
||||
type SessionManager struct {
|
||||
configDir string
|
||||
configDir string
|
||||
sessionFile string
|
||||
keyring keyring.Keyring
|
||||
}
|
||||
|
||||
func NewSessionManager() *SessionManager {
|
||||
func NewSessionManager() (*SessionManager, error) {
|
||||
cfg := config.NewConfigManager()
|
||||
return &SessionManager{
|
||||
configDir: cfg.ConfigDir(),
|
||||
sessionFile: filepath.Join(cfg.ConfigDir(), "session.json"),
|
||||
configDir := cfg.ConfigDir()
|
||||
|
||||
k, err := keyring.Open(keyring.Config{
|
||||
ServiceName: "pop-cli",
|
||||
FileDir: filepath.Join(configDir, "keyring"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open keyring: %w", err)
|
||||
}
|
||||
|
||||
return &SessionManager{
|
||||
configDir: configDir,
|
||||
sessionFile: filepath.Join(configDir, "session.json"),
|
||||
keyring: k,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *SessionManager) Login() error {
|
||||
// TODO: Implement interactive login with 2FA support
|
||||
// This will call the ProtonMail API and store the session
|
||||
session := Session{
|
||||
UID: "placeholder-uid",
|
||||
AccessToken: "placeholder-token",
|
||||
RefreshToken: "placeholder-refresh",
|
||||
ExpiresAt: 0,
|
||||
TwoFAEnabled: false,
|
||||
func (m *SessionManager) LoginWithCredentials(apiBaseURL, email, password string) error {
|
||||
if err := os.MkdirAll(m.configDir, 0700); err != nil {
|
||||
return fmt.Errorf("failed to create config dir: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(m.configDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create config dir: %w", err)
|
||||
if err := os.MkdirAll(filepath.Join(m.configDir, "keyring"), 0700); err != nil {
|
||||
return fmt.Errorf("failed to create keyring dir: %w", err)
|
||||
}
|
||||
|
||||
authURL := fmt.Sprintf("%s/auth", apiBaseURL)
|
||||
|
||||
payload := map[string]string{
|
||||
"Email": email,
|
||||
"Password": password,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal auth payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", authURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create auth request: %w", err)
|
||||
}
|
||||
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("authentication failed (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var authResponse struct {
|
||||
UID string `json:"UID"`
|
||||
AccessToken string `json:"AccessToken"`
|
||||
RefreshToken string `json:"RefreshToken"`
|
||||
ExpiresIn int `json:"ExpiresIn"`
|
||||
TwoFARequired bool `json:"TwoFARequired"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&authResponse); err != nil {
|
||||
return fmt.Errorf("failed to parse auth response: %w", err)
|
||||
}
|
||||
|
||||
session := Session{
|
||||
UID: authResponse.UID,
|
||||
AccessToken: authResponse.AccessToken,
|
||||
RefreshToken: authResponse.RefreshToken,
|
||||
ExpiresAt: time.Now().Unix() + int64(authResponse.ExpiresIn),
|
||||
TwoFAEnabled: authResponse.TwoFARequired,
|
||||
}
|
||||
|
||||
encryptedData, err := encryptSession(session)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt session: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(session, "", " ")
|
||||
@@ -50,8 +123,187 @@ func (m *SessionManager) Login() error {
|
||||
return fmt.Errorf("failed to marshal session: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(m.sessionFile, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write session file: %w", err)
|
||||
if err := m.keyring.Set(keyring.Item{
|
||||
Key: "session",
|
||||
Data: data,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to store session in keyring: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(m.sessionFile, encryptedData, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write encrypted session file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Logged in successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *SessionManager) LoginInteractive(apiBaseURL string) error {
|
||||
if err := os.MkdirAll(m.configDir, 0700); err != nil {
|
||||
return fmt.Errorf("failed to create config dir: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(m.configDir, "keyring"), 0700); err != nil {
|
||||
return fmt.Errorf("failed to create keyring dir: %w", err)
|
||||
}
|
||||
|
||||
emailPrompt := promptui.Prompt{
|
||||
Label: "ProtonMail email",
|
||||
Validate: func(input string) error {
|
||||
if !strings.Contains(input, "@") {
|
||||
return fmt.Errorf("invalid email format")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
email, err := emailPrompt.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read email: %w", err)
|
||||
}
|
||||
|
||||
passwordPrompt := promptui.Prompt{
|
||||
Label: "ProtonMail password",
|
||||
Mask: '*',
|
||||
}
|
||||
password, err := passwordPrompt.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read password: %w", err)
|
||||
}
|
||||
|
||||
authURL := fmt.Sprintf("%s/auth", apiBaseURL)
|
||||
|
||||
payload := map[string]string{
|
||||
"Email": email,
|
||||
"Password": password,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal auth payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", authURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create auth request: %w", err)
|
||||
}
|
||||
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("authentication failed (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var authResponse struct {
|
||||
UID string `json:"UID"`
|
||||
AccessToken string `json:"AccessToken"`
|
||||
RefreshToken string `json:"RefreshToken"`
|
||||
ExpiresIn int `json:"ExpiresIn"`
|
||||
TwoFARequired bool `json:"TwoFARequired"`
|
||||
TwoFAChallenge struct {
|
||||
Type string `json:"Type"`
|
||||
} `json:"TwoFAChallenge"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&authResponse); err != nil {
|
||||
return fmt.Errorf("failed to parse auth response: %w", err)
|
||||
}
|
||||
|
||||
session := Session{
|
||||
UID: authResponse.UID,
|
||||
AccessToken: authResponse.AccessToken,
|
||||
RefreshToken: authResponse.RefreshToken,
|
||||
ExpiresAt: time.Now().Unix() + int64(authResponse.ExpiresIn),
|
||||
TwoFAEnabled: authResponse.TwoFARequired,
|
||||
}
|
||||
|
||||
if session.TwoFAEnabled {
|
||||
fmt.Println("\n2FA authentication required")
|
||||
|
||||
totpPrompt := promptui.Prompt{
|
||||
Label: "Enter TOTP code",
|
||||
Validate: func(input string) error {
|
||||
if len(input) != 6 {
|
||||
return fmt.Errorf("TOTP code must be 6 digits")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
totpCode, err := totpPrompt.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read TOTP code: %w", err)
|
||||
}
|
||||
|
||||
totpURL := fmt.Sprintf("%s/auth/verify", apiBaseURL)
|
||||
totpPayload := map[string]string{
|
||||
"UID": session.UID,
|
||||
"Code": totpCode,
|
||||
}
|
||||
|
||||
totpJSON, err := json.Marshal(totpPayload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal TOTP payload: %w", err)
|
||||
}
|
||||
|
||||
totpReq, err := http.NewRequest("POST", totpURL, bytes.NewBuffer(totpJSON))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create TOTP request: %w", err)
|
||||
}
|
||||
totpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", session.AccessToken))
|
||||
totpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
totpResp, err := client.Do(totpReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to verify TOTP: %w", err)
|
||||
}
|
||||
defer totpResp.Body.Close()
|
||||
|
||||
if totpResp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(totpResp.Body)
|
||||
return fmt.Errorf("TOTP verification failed (status %d): %s", totpResp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var finalAuth struct {
|
||||
AccessToken string `json:"AccessToken"`
|
||||
RefreshToken string `json:"RefreshToken"`
|
||||
ExpiresIn int `json:"ExpiresIn"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(totpResp.Body).Decode(&finalAuth); err != nil {
|
||||
return fmt.Errorf("failed to parse TOTP response: %w", err)
|
||||
}
|
||||
|
||||
session.AccessToken = finalAuth.AccessToken
|
||||
session.RefreshToken = finalAuth.RefreshToken
|
||||
session.ExpiresAt = time.Now().Unix() + int64(finalAuth.ExpiresIn)
|
||||
}
|
||||
|
||||
encryptedData, 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,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to store session in keyring: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(m.sessionFile, encryptedData, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write encrypted session file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Logged in successfully")
|
||||
@@ -68,23 +320,141 @@ func (m *SessionManager) Logout() error {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
// If not in keyring, read from encrypted file
|
||||
data, err := os.ReadFile(m.sessionFile)
|
||||
if err != nil {
|
||||
if err == os.ErrNotExist {
|
||||
return nil, fmt.Errorf("no session found: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read session file: %w", err)
|
||||
}
|
||||
|
||||
var session Session
|
||||
if err := json.Unmarshal(data, &session); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse session: %w", err)
|
||||
// Decrypt the session data
|
||||
session, err := decryptSession(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt session: %w", err)
|
||||
}
|
||||
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func (m *SessionManager) IsAuthenticated() (bool, error) {
|
||||
_, err := m.GetSession()
|
||||
session, err := m.GetSession()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if time.Now().Unix() > session.ExpiresAt {
|
||||
return false, fmt.Errorf("session expired at %d", session.ExpiresAt)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// RefreshToken refreshes the access token using the refresh token
|
||||
func (m *SessionManager) RefreshToken() error {
|
||||
_, 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")
|
||||
}
|
||||
|
||||
// encryptSession encrypts the session data using AES-256-GCM
|
||||
func encryptSession(session Session) ([]byte, error) {
|
||||
// Generate a random 256-bit key
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate encryption key: %w", err)
|
||||
}
|
||||
|
||||
// Generate a random 12-byte nonce
|
||||
nonce := make([]byte, 12)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
|
||||
// Create AES-GCM cipher
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
|
||||
}
|
||||
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt the session data
|
||||
sessionData, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal session for encryption: %w", err)
|
||||
}
|
||||
|
||||
sealedData := aead.Seal(nil, nonce, sessionData, nil)
|
||||
|
||||
// Prepend key and nonce (base64 encoded for readability in file)
|
||||
header := fmt.Sprintf("%s|%s|", base64.StdEncoding.EncodeToString(key), base64.StdEncoding.EncodeToString(nonce))
|
||||
return []byte(header + string(sealedData)), nil
|
||||
}
|
||||
|
||||
// decryptSession decrypts the session data
|
||||
func decryptSession(encryptedData []byte) (Session, error) {
|
||||
// Split header and encrypted data
|
||||
parts := strings.Split(string(encryptedData), "|")
|
||||
if len(parts) != 3 {
|
||||
return Session{}, fmt.Errorf("invalid encrypted data format")
|
||||
}
|
||||
|
||||
// Decode key and nonce
|
||||
key, err := base64.StdEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return Session{}, fmt.Errorf("failed to decode key: %w", err)
|
||||
}
|
||||
|
||||
nonce, err := base64.StdEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return Session{}, fmt.Errorf("failed to decode nonce: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return Session{}, fmt.Errorf("failed to create AES cipher: %w", err)
|
||||
}
|
||||
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return Session{}, fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
sealedData, err := base64.StdEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return Session{}, fmt.Errorf("failed to decode sealed data: %w", err)
|
||||
}
|
||||
|
||||
data, err := aead.Open(nil, nonce, sealedData, nil)
|
||||
if err != nil {
|
||||
return Session{}, fmt.Errorf("failed to decrypt session: %w", err)
|
||||
}
|
||||
|
||||
var session Session
|
||||
if err := json.Unmarshal(data, &session); err != nil {
|
||||
return Session{}, fmt.Errorf("failed to unmarshal session: %w", err)
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/frenocorp/pop/internal/config"
|
||||
)
|
||||
|
||||
type ContactManager struct {
|
||||
mu sync.Mutex
|
||||
configDir string
|
||||
contactsFile string
|
||||
}
|
||||
@@ -25,6 +27,8 @@ func NewContactManager() *ContactManager {
|
||||
}
|
||||
|
||||
func (m *ContactManager) List(req ListContactsRequest) (*ListContactsResponse, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
contacts, err := m.loadContacts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -60,6 +64,8 @@ func (m *ContactManager) List(req ListContactsRequest) (*ListContactsResponse, e
|
||||
}
|
||||
|
||||
func (m *ContactManager) Create(req CreateContactRequest) (*Contact, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
contacts, err := m.loadContacts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -88,6 +94,8 @@ func (m *ContactManager) Create(req CreateContactRequest) (*Contact, error) {
|
||||
}
|
||||
|
||||
func (m *ContactManager) Get(id string) (*Contact, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
contacts, err := m.loadContacts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -103,6 +111,8 @@ func (m *ContactManager) Get(id string) (*Contact, error) {
|
||||
}
|
||||
|
||||
func (m *ContactManager) Update(id string, req UpdateContactRequest) (*Contact, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
contacts, err := m.loadContacts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -145,6 +155,8 @@ func (m *ContactManager) Update(id string, req UpdateContactRequest) (*Contact,
|
||||
}
|
||||
|
||||
func (m *ContactManager) Delete(id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
contacts, err := m.loadContacts()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -24,32 +24,39 @@ func NewClient(apiClient *api.ProtonMailClient) *Client {
|
||||
}
|
||||
|
||||
func (c *Client) ListMessages(req ListMessagesRequest) (*ListMessagesResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("Page", fmt.Sprintf("%d", req.Page))
|
||||
params.Set("PageSize", fmt.Sprintf("%d", req.PageSize))
|
||||
params.Set("Passphrase", req.Passphrase)
|
||||
body := map[string]interface{}{
|
||||
"Page": req.Page,
|
||||
"PageSize": req.PageSize,
|
||||
"Passphrase": req.Passphrase,
|
||||
}
|
||||
|
||||
if req.Folder != FolderInbox {
|
||||
params.Set("Type", fmt.Sprintf("%d", req.Folder))
|
||||
body["Type"] = int(req.Folder)
|
||||
}
|
||||
|
||||
if req.Starred != nil {
|
||||
params.Set("Starred", fmt.Sprintf("%t", *req.Starred))
|
||||
body["Starred"] = *req.Starred
|
||||
}
|
||||
|
||||
if req.Read != nil {
|
||||
params.Set("Read", fmt.Sprintf("%t", *req.Read))
|
||||
body["Read"] = *req.Read
|
||||
}
|
||||
|
||||
if req.Since > 0 {
|
||||
params.Set("Since", fmt.Sprintf("%d", req.Since))
|
||||
body["Since"] = req.Since
|
||||
}
|
||||
|
||||
reqURL := fmt.Sprintf("%s/api/messages?%s", c.baseURL, params.Encode())
|
||||
httpReq, err := http.NewRequest("GET", reqURL, nil)
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
reqURL := fmt.Sprintf("%s/api/messages", c.baseURL)
|
||||
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.apiClient.Do(httpReq)
|
||||
if err != nil {
|
||||
@@ -57,13 +64,13 @@ func (c *Client) ListMessages(req ListMessagesRequest) (*ListMessagesResponse, e
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var result ListMessagesResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
@@ -71,14 +78,21 @@ func (c *Client) ListMessages(req ListMessagesRequest) (*ListMessagesResponse, e
|
||||
}
|
||||
|
||||
func (c *Client) GetMessage(messageID string, passphrase string) (*Message, error) {
|
||||
params := url.Values{}
|
||||
params.Set("Passphrase", passphrase)
|
||||
body := map[string]string{
|
||||
"Passphrase": passphrase,
|
||||
}
|
||||
|
||||
reqURL := fmt.Sprintf("%s/api/messages/%s?%s", c.baseURL, url.QueryEscape(messageID), params.Encode())
|
||||
httpReq, err := http.NewRequest("GET", reqURL, nil)
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
reqURL := fmt.Sprintf("%s/api/messages/%s", c.baseURL, url.QueryEscape(messageID))
|
||||
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.apiClient.Do(httpReq)
|
||||
if err != nil {
|
||||
@@ -86,7 +100,7 @@ func (c *Client) GetMessage(messageID string, passphrase string) (*Message, erro
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
@@ -94,7 +108,7 @@ func (c *Client) GetMessage(messageID string, passphrase string) (*Message, erro
|
||||
var result struct {
|
||||
Data Message `json:"Data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
@@ -102,34 +116,34 @@ func (c *Client) GetMessage(messageID string, passphrase string) (*Message, erro
|
||||
}
|
||||
|
||||
func (c *Client) Send(req SendRequest) error {
|
||||
formData := url.Values{}
|
||||
formData.Set("Type", "0")
|
||||
formData.Set("Passphrase", req.Passphrase)
|
||||
formData.Set("Subject", req.Subject)
|
||||
formData.Set("HTML", fmt.Sprintf("%t", req.HTML))
|
||||
|
||||
toJSON, _ := json.Marshal(req.To)
|
||||
formData.Set("To", string(toJSON))
|
||||
body := map[string]interface{}{
|
||||
"Type": "0",
|
||||
"Passphrase": req.Passphrase,
|
||||
"Subject": req.Subject,
|
||||
"HTML": req.HTML,
|
||||
"To": req.To,
|
||||
"Body": req.Body,
|
||||
}
|
||||
|
||||
if len(req.CC) > 0 {
|
||||
ccJSON, _ := json.Marshal(req.CC)
|
||||
formData.Set("CC", string(ccJSON))
|
||||
body["CC"] = req.CC
|
||||
}
|
||||
|
||||
if len(req.BCC) > 0 {
|
||||
bccJSON, _ := json.Marshal(req.BCC)
|
||||
formData.Set("BCC", string(bccJSON))
|
||||
body["BCC"] = req.BCC
|
||||
}
|
||||
|
||||
bodyData := req.Body
|
||||
formData.Set("Body", bodyData)
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
reqURL := fmt.Sprintf("%s/api/messages", c.baseURL)
|
||||
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBufferString(formData.Encode()))
|
||||
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.apiClient.Do(httpReq)
|
||||
if err != nil {
|
||||
@@ -190,32 +204,33 @@ func (c *Client) PermanentlyDelete(messageID string) error {
|
||||
}
|
||||
|
||||
func (c *Client) SaveDraft(draft Draft, passphrase string) (string, error) {
|
||||
formData := url.Values{}
|
||||
formData.Set("Type", "2")
|
||||
formData.Set("Passphrase", passphrase)
|
||||
formData.Set("Subject", draft.Subject)
|
||||
|
||||
toJSON, _ := json.Marshal(draft.To)
|
||||
formData.Set("To", string(toJSON))
|
||||
body := map[string]interface{}{
|
||||
"Type": "2",
|
||||
"Passphrase": passphrase,
|
||||
"Subject": draft.Subject,
|
||||
"To": draft.To,
|
||||
"Body": draft.Body,
|
||||
}
|
||||
|
||||
if len(draft.CC) > 0 {
|
||||
ccJSON, _ := json.Marshal(draft.CC)
|
||||
formData.Set("CC", string(ccJSON))
|
||||
body["CC"] = draft.CC
|
||||
}
|
||||
|
||||
if len(draft.BCC) > 0 {
|
||||
bccJSON, _ := json.Marshal(draft.BCC)
|
||||
formData.Set("BCC", string(bccJSON))
|
||||
body["BCC"] = draft.BCC
|
||||
}
|
||||
|
||||
formData.Set("Body", draft.Body)
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
reqURL := fmt.Sprintf("%s/api/messages", c.baseURL)
|
||||
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBufferString(formData.Encode()))
|
||||
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.apiClient.Do(httpReq)
|
||||
if err != nil {
|
||||
@@ -241,26 +256,28 @@ func (c *Client) SaveDraft(draft Draft, passphrase string) (string, error) {
|
||||
}
|
||||
|
||||
func (c *Client) UpdateDraft(messageID string, draft Draft, passphrase string) error {
|
||||
formData := url.Values{}
|
||||
formData.Set("Passphrase", passphrase)
|
||||
formData.Set("Subject", draft.Subject)
|
||||
|
||||
toJSON, _ := json.Marshal(draft.To)
|
||||
formData.Set("To", string(toJSON))
|
||||
|
||||
if len(draft.CC) > 0 {
|
||||
ccJSON, _ := json.Marshal(draft.CC)
|
||||
formData.Set("CC", string(ccJSON))
|
||||
body := map[string]interface{}{
|
||||
"Passphrase": passphrase,
|
||||
"Subject": draft.Subject,
|
||||
"To": draft.To,
|
||||
"Body": draft.Body,
|
||||
}
|
||||
|
||||
formData.Set("Body", draft.Body)
|
||||
if len(draft.CC) > 0 {
|
||||
body["CC"] = draft.CC
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
reqURL := fmt.Sprintf("%s/api/messages/%s", c.baseURL, url.QueryEscape(messageID))
|
||||
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBufferString(formData.Encode()))
|
||||
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.apiClient.Do(httpReq)
|
||||
if err != nil {
|
||||
@@ -310,17 +327,24 @@ func (c *Client) ListDrafts(page int, pageSize int, passphrase string) (*ListMes
|
||||
}
|
||||
|
||||
func (c *Client) SearchMessages(req SearchRequest) (*SearchResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("Query", req.Query)
|
||||
params.Set("Page", fmt.Sprintf("%d", req.Page))
|
||||
params.Set("PageSize", fmt.Sprintf("%d", req.PageSize))
|
||||
params.Set("Passphrase", req.Passphrase)
|
||||
body := map[string]interface{}{
|
||||
"Query": req.Query,
|
||||
"Page": req.Page,
|
||||
"PageSize": req.PageSize,
|
||||
"Passphrase": req.Passphrase,
|
||||
}
|
||||
|
||||
reqURL := fmt.Sprintf("%s/api/messages/search?%s", c.baseURL, params.Encode())
|
||||
httpReq, err := http.NewRequest("GET", reqURL, nil)
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
reqURL := fmt.Sprintf("%s/api/messages/search", c.baseURL)
|
||||
httpReq, err := http.NewRequest("POST", reqURL, bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.apiClient.Do(httpReq)
|
||||
if err != nil {
|
||||
@@ -328,13 +352,13 @@ func (c *Client) SearchMessages(req SearchRequest) (*SearchResponse, error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var result SearchResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -36,19 +36,54 @@ func NewPGPService(privateKeyArmored string) (*PGPService, error) {
|
||||
}
|
||||
|
||||
func (s *PGPService) Encrypt(plaintext string, recipientPublicKey *crypto.Key) (string, error) {
|
||||
return plaintext, nil
|
||||
pgpMessage, err := crypto.NewPlainMessage([]byte(plaintext))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create PGP message: %w", err)
|
||||
}
|
||||
|
||||
encrypted, err := pgpMessage.Encrypt(recipientPublicKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encrypt: %w", err)
|
||||
}
|
||||
|
||||
return encrypted.GetArmored()
|
||||
}
|
||||
|
||||
func (s *PGPService) EncryptAndSign(plaintext string, recipientPublicKey *crypto.Key, passphrase string) (string, error) {
|
||||
return s.Encrypt(plaintext, recipientPublicKey)
|
||||
pgpMessage, err := crypto.NewPlainMessage([]byte(plaintext))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create PGP message: %w", err)
|
||||
}
|
||||
|
||||
encrypted, err := pgpMessage.EncryptAndSign(recipientPublicKey, s.keyRing.PrivateKey, []byte(passphrase))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encrypt and sign: %w", err)
|
||||
}
|
||||
|
||||
return encrypted.GetArmored()
|
||||
}
|
||||
|
||||
func (s *PGPService) Decrypt(encrypted string, passphrase string) (string, error) {
|
||||
return encrypted, nil
|
||||
armoredKey, err := crypto.NewKeyFromArmored(encrypted)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse armored key: %w", err)
|
||||
}
|
||||
|
||||
pgpMessage, err := crypto.NewPlainMessageFromString(armoredKey.GetArmored())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse encrypted message: %w", err)
|
||||
}
|
||||
|
||||
decrypted, err := pgpMessage.Decrypt(s.keyRing.PrivateKey, []byte(passphrase))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decrypt: %w", err)
|
||||
}
|
||||
|
||||
return string(decrypted.GetBinary()), nil
|
||||
}
|
||||
|
||||
func (s *PGPService) GenerateKeyPair(email string, passphrase string) (privateKey, publicKey string, err error) {
|
||||
key, err := crypto.GenerateKey(email, passphrase, "RSA", 2048)
|
||||
key, err := crypto.GenerateKey(email, passphrase, "RSA", 4096)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to generate key pair: %w", err)
|
||||
}
|
||||
@@ -77,7 +112,17 @@ func (s *PGPService) GetFingerprint() (string, error) {
|
||||
}
|
||||
|
||||
func (s *PGPService) SignData(data []byte, passphrase string) (string, error) {
|
||||
return string(data), nil
|
||||
pgpMessage, err := crypto.NewPlainMessage(data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create PGP message: %w", err)
|
||||
}
|
||||
|
||||
signed, err := pgpMessage.Sign(s.keyRing.PrivateKey, []byte(passphrase))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign data: %w", err)
|
||||
}
|
||||
|
||||
return signed.GetArmored()
|
||||
}
|
||||
|
||||
func (s *PGPService) EncryptAttachment(data []byte, recipientPublicKey *crypto.Key) (*Attachment, error) {
|
||||
@@ -86,16 +131,32 @@ func (s *PGPService) EncryptAttachment(data []byte, recipientPublicKey *crypto.K
|
||||
return nil, fmt.Errorf("failed to generate symmetric key: %w", err)
|
||||
}
|
||||
|
||||
encData := make([]byte, len(data))
|
||||
copy(encData, data)
|
||||
symKeyRing, err := crypto.NewKeyFromArmored(recipientPublicKey.GetArmored())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse recipient key: %w", err)
|
||||
}
|
||||
|
||||
encKey := make([]byte, len(symKey))
|
||||
copy(encKey, symKey)
|
||||
pgpMessage, err := crypto.NewPlainMessage(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create PGP message: %w", err)
|
||||
}
|
||||
|
||||
encrypted, err := pgpMessage.Encrypt(symKeyRing)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt attachment: %w", err)
|
||||
}
|
||||
|
||||
encData := []byte(encrypted.GetBinary())
|
||||
|
||||
encryptedSymKey, err := symKeyRing.Encrypt(symKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt symmetric key: %w", err)
|
||||
}
|
||||
|
||||
return &Attachment{
|
||||
DataEnc: string(encData),
|
||||
Keys: []AttachmentKey{{
|
||||
DataEnc: string(encKey),
|
||||
DataEnc: string(encryptedSymKey.GetBinary()),
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
@@ -105,8 +166,25 @@ func (s *PGPService) DecryptAttachment(attachment *Attachment, passphrase string
|
||||
return nil, fmt.Errorf("no keys available for attachment decryption")
|
||||
}
|
||||
|
||||
decrypted := make([]byte, len(attachment.DataEnc))
|
||||
copy(decrypted, attachment.DataEnc)
|
||||
encryptedSymKey, err := crypto.NewKeyFromArmored(string(attachment.Keys[0].DataEnc))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse encrypted symmetric key: %w", err)
|
||||
}
|
||||
|
||||
return decrypted, nil
|
||||
symKey, err := encryptedSymKey.Decrypt([]byte(passphrase))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt symmetric key: %w", err)
|
||||
}
|
||||
|
||||
pgpMessage, err := crypto.NewPlainMessage([]byte(attachment.DataEnc))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create PGP message: %w", err)
|
||||
}
|
||||
|
||||
decrypted, err := pgpMessage.DecryptWithKey(s.keyRing.PrivateKey, symKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt attachment: %w", err)
|
||||
}
|
||||
|
||||
return decrypted.GetBinary(), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user