From 35d47733ea6016a467af4ba18051f2b9f25c0d98 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 27 Apr 2026 19:13:03 -0400 Subject: [PATCH] Auto-commit 2026-04-27 19:13 --- FRE-683-SECURITY-FIXES.md | 175 ++++++++++++++ cmd/auth.go | 35 ++- cmd/contacts.go | 19 +- go.mod | 10 + go.sum | 31 +++ internal/attachment/manager.go | 70 +++++- internal/auth/session.go | 414 +++++++++++++++++++++++++++++++-- internal/contact/manager.go | 12 + internal/mail/client.go | 164 +++++++------ internal/mail/pgp.go | 104 +++++++-- 10 files changed, 915 insertions(+), 119 deletions(-) create mode 100644 FRE-683-SECURITY-FIXES.md diff --git a/FRE-683-SECURITY-FIXES.md b/FRE-683-SECURITY-FIXES.md new file mode 100644 index 0000000..47fe92a --- /dev/null +++ b/FRE-683-SECURITY-FIXES.md @@ -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: ¬es, +} +``` + +**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 = ¬es +} +``` + +#### 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 diff --git a/cmd/auth.go b/cmd/auth.go index a30df71..5c1fedc 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -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 } diff --git a/cmd/contacts.go b/cmd/contacts.go index 6f0bbdc..7978cd4 100644 --- a/cmd/contacts.go +++ b/cmd/contacts.go @@ -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: ¬es, + 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 = ¬es } updated, err := manager.Update(id, req) diff --git a/go.mod b/go.mod index 04f69a4..c12b72c 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 8c5683d..e949297 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/attachment/manager.go b/internal/attachment/manager.go index f0b6388..b5f6f07 100644 --- a/internal/attachment/manager.go +++ b/internal/attachment/manager.go @@ -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) } diff --git a/internal/auth/session.go b/internal/auth/session.go index b3f9434..600231a 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -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 +} diff --git a/internal/contact/manager.go b/internal/contact/manager.go index 6bceada..a0c7569 100644 --- a/internal/contact/manager.go +++ b/internal/contact/manager.go @@ -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 diff --git a/internal/mail/client.go b/internal/mail/client.go index dacc68b..4b3bd50 100644 --- a/internal/mail/client.go +++ b/internal/mail/client.go @@ -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) } diff --git a/internal/mail/pgp.go b/internal/mail/pgp.go index c2512d3..bd99e7a 100644 --- a/internal/mail/pgp.go +++ b/internal/mail/pgp.go @@ -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 }