Auto-commit 2026-04-27 19:13

This commit is contained in:
2026-04-27 19:13:03 -04:00
parent c1fc21702c
commit 35d47733ea
10 changed files with 915 additions and 119 deletions

175
FRE-683-SECURITY-FIXES.md Normal file
View File

@@ -0,0 +1,175 @@
# Security Fixes Applied to FRE-683
**Date**: 2026-04-27
**Status**: HIGH & MEDIUM priority fixes completed
## Summary
All security issues identified in the Security Review have been addressed:
### HIGH Priority Fixes ✅
#### 1. Path Traversal Vulnerability (CVE-class)
**File**: `internal/attachment/manager.go`
**Changes**:
- Added `isAttachmentIDSafe()` function to validate attachmentID contains only safe characters (alphanumeric, hyphen, underscore)
- Added `sanitizeAttachmentID()` function that:
- Validates attachmentID using `isAttachmentIDSafe()`
- Uses `filepath.Clean()` to resolve any `..` or `.` components
- Rejects any ID that resolves differently after cleaning
- All attachment operations now call `sanitizeAttachmentID()` before use:
- `Download()`
- `Upload()`
- `Get()`
- `Delete()`
**Code Example**:
```go
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
}
```
#### 2. No File Size Limit (DoS)
**File**: `internal/attachment/manager.go`
**Changes**:
- Added `maxUploadSize = 50 * 1024 * 1024` constant (50MB)
- Modified `Upload()` to use `io.LimitReader()` before reading
- Prevents memory exhaustion from large uploads
**Code Example**:
```go
const maxUploadSize = 50 * 1024 * 1024 // 50MB
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
}
// 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(filepath.Join(m.attachmentsDir, attachmentID), data, 0644)
}
```
### MEDIUM Priority Fixes ✅
#### 3. Contact Edit Overwrites
**File**: `cmd/contacts.go`
**Changes**:
- Modified `contactEditCmd()` to check `cmd.Flags().Changed()` before setting pointer fields
- Prevents overwriting existing values when flags are not explicitly provided
**Before** (Buggy):
```go
req := contact.UpdateContactRequest{
Name: &name,
Phone: &phone,
Address: &address,
Notes: &notes,
}
```
**After** (Fixed):
```go
req := contact.UpdateContactRequest{}
if cmd.Flags().Changed("name") {
req.Name = &name
}
if cmd.Flags().Changed("phone") {
req.Phone = &phone
}
if cmd.Flags().Changed("address") {
req.Address = &address
}
if cmd.Flags().Changed("notes") {
req.Notes = &notes
}
```
#### 4. No Concurrency Protection
**File**: `internal/contact/manager.go`
**Changes**:
- Added `sync.Mutex` field to `ContactManager` struct
- Wrapped all CRUD operations with `m.mu.Lock()` / `defer m.mu.Unlock()`:
- `List()`
- `Create()`
- `Get()`
- `Update()`
- `Delete()`
**Code Example**:
```go
type ContactManager struct {
mu sync.Mutex
configDir string
contactsFile string
}
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
}
// ... rest of function
}
```
### Pending / Not Required ✅
#### 5. Inconsistent Path Resolution
**Status**: Not fixed - this is a minor consistency issue, not a security vulnerability
**Analysis**:
- `internal/attachment/manager.go` uses `os.Getenv("HOME")` - acceptable for local tool
- `internal/contact/manager.go` uses `config.NewConfigManager().ConfigDir()` - recommended approach
- Both resolve to the same location (`~/.config/pop/`)
- This is a code style preference, not a bug
---
## Verification
All changes have been applied to the source files. The Code Reviewer should:
1. Review the security fixes in `internal/attachment/manager.go`
2. Review the concurrency fixes in `internal/contact/manager.go`
3. Review the edit command fix in `cmd/contacts.go`
4. Approve for merge once verified
---
## Next Steps
1. ✅ Security fixes applied
2. ⏳ Assign to Code Reviewer for re-review
3. ⏳ Re-assign to Founding Engineer upon approval
4. ⏳ Merge and deploy

View File

@@ -4,24 +4,49 @@ import (
"fmt"
"os"
"github.com/99designs/keyring"
"github.com/frenocorp/pop/internal/auth"
"github.com/frenocorp/pop/internal/config"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
func loginCmd() *cobra.Command {
var email, password, totpCode string
var interactive bool
cmd := &cobra.Command{
Use: "login",
Short: "Log in to ProtonMail",
Long: `Authenticate with ProtonMail API and store session credentials.`,
RunE: func(cmd *cobra.Command, args []string) error {
manager := auth.NewSessionManager()
return manager.Login()
cfgMgr := config.NewConfigManager()
config, err := cfgMgr.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
manager, err := auth.NewSessionManager()
if err != nil {
return fmt.Errorf("failed to create session manager: %w", err)
}
if interactive {
return manager.LoginInteractive(config.APIBaseURL)
}
if email == "" || password == "" {
return fmt.Errorf("email and password flags required for non-interactive login")
}
return manager.LoginWithCredentials(config.APIBaseURL, email, password)
},
}
cmd.Flags().StringP("email", "e", "", "ProtonMail email address")
cmd.Flags().StringP("password", "p", "", "ProtonMail password")
cmd.Flags().BoolP("interactive", "i", true, "Interactive prompt for credentials")
cmd.Flags().StringVarP(&email, "email", "e", "", "ProtonMail email address")
cmd.Flags().StringVarP(&password, "password", "p", "", "ProtonMail password")
cmd.Flags().BoolVarP(&interactive, "interactive", "i", true, "Interactive prompt for credentials")
cmd.Flags().StringVar(&totpCode, "totp", "", "TOTP code for 2FA authentication")
return cmd
}

View File

@@ -123,11 +123,20 @@ func contactEditCmd() *cobra.Command {
address, _ := cmd.Flags().GetString("address")
notes, _ := cmd.Flags().GetString("notes")
req := contact.UpdateContactRequest{
Name: &name,
Phone: &phone,
Address: &address,
Notes: &notes,
req := contact.UpdateContactRequest{}
// Only set pointer fields when the flag was explicitly changed
if cmd.Flags().Changed("name") {
req.Name = &name
}
if cmd.Flags().Changed("phone") {
req.Phone = &phone
}
if cmd.Flags().Changed("address") {
req.Address = &address
}
if cmd.Flags().Changed("notes") {
req.Notes = &notes
}
updated, err := manager.Update(id, req)

10
go.mod
View File

@@ -8,13 +8,23 @@ require (
)
require (
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
github.com/99designs/keyring v1.2.2 // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
github.com/cloudflare/circl v1.6.2 // indirect
github.com/danieljoos/wincred v1.1.2 // indirect
github.com/dvsekhvalnov/jose2go v1.5.0 // indirect
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/manifoldco/promptui v0.9.0 // indirect
github.com/mtibben/percent v0.2.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/term v0.34.0 // indirect
golang.org/x/text v0.28.0 // indirect
)

31
go.sum
View File

@@ -1,16 +1,40 @@
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4=
github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0=
github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk=
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k=
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw=
github.com/ProtonMail/gopenpgp/v2 v2.10.0 h1:llCzLvntC9+iH+if/na4AgKTef/Zm4vpaRrR3+JdKvo=
github.com/ProtonMail/gopenpgp/v2 v2.10.0/go.mod h1:dc0h9Pg3ftfN0U4pfRzujilfh61A2R52wgMkZWcWm2I=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0=
github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM=
github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0=
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU=
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs=
github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -20,6 +44,7 @@ github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -36,9 +61,11 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -47,6 +74,8 @@ golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -60,5 +89,7 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

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

View File

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

View File

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

View File

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

View File

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