From a4a1037fdc5cdba173c1d6ae4dfa888539ba9198 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 4 Jun 2026 10:27:20 -0400 Subject: [PATCH 1/4] CalDAV support, rebased onto master + ported to go-webdav v0.7 Squashes PR #282 (zCri's CalDAV support) plus our fixes onto current master, replacing the stale go-webdav v0.5.1 base. Ports the backend to the go-webdav v0.7.0 caldav.Backend interface: - PutCalendarObject now returns (*caldav.CalendarObject, error) - implement CreateCalendar (MKCALENDAR) as 501 Not Implemented (Proton calendar provisioning is not supported yet; create via the web app) Carries the three correctness fixes from the original port: 1. event create detection via errors.As (wrapped APIError, code 2061) 2. read-back of own events when Author is empty (fall back to user keyring) 3. time-range calendar-query filtering via go-webdav's caldav.Match Tests: protonmail/calendar_test.go (unit) + test/caldav_e2e.py (full create/read/update/delete/query lifecycle, 12/12 against live API). --- .gitignore | 2 + README.md | 14 +- caldav/caldav.go | 486 ++++++++++++++++++ cmd/hydroxide/main.go | 69 ++- go.mod | 2 + go.sum | 2 + protonmail/calendar.go | 949 ++++++++++++++++++++++++++++++++++-- protonmail/calendar_test.go | 54 ++ protonmail/protonmail.go | 4 + test/caldav_e2e.py | 140 ++++++ 10 files changed, 1688 insertions(+), 34 deletions(-) create mode 100644 caldav/caldav.go create mode 100644 protonmail/calendar_test.go create mode 100644 test/caldav_e2e.py diff --git a/.gitignore b/.gitignore index 6a1a0cf2..163a68cc 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ .glide/ auth.json +/hydroxide +/hydroxide-caldav diff --git a/README.md b/README.md index 50db2c1a..66c9b4e5 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A third-party, open-source ProtonMail bridge. For power users only, designed to run on a server. -hydroxide supports CardDAV, IMAP and SMTP. +hydroxide supports CardDAV, CalDAV, IMAP and SMTP. Rationale: @@ -17,7 +17,7 @@ Feel free to join the IRC channel: #emersion on Libera Chat. ## How does it work? -hydroxide is a server that translates standard protocols (SMTP, IMAP, CardDAV) +hydroxide is a server that translates standard protocols (SMTP, IMAP, CardDAV, CalDAV) into ProtonMail API requests. It allows you to use your preferred e-mail clients and `git-send-email` with ProtonMail. @@ -62,7 +62,7 @@ password (a 32-byte random password generated when logging in). hydroxide can be used in multiple modes. > Don't start hydroxide multiple times, instead you can use `hydroxide serve`. -> This requires ports 1025 (smtp), 1143 (imap), and 8080 (carddav). +> This requires ports 1025 (smtp), 1143 (imap), 8080 (carddav) and 8081 (caldav). ### SMTP @@ -91,6 +91,14 @@ hydroxide carddav Tested on GNOME (Evolution) and Android (DAVDroid). +### CalDAV + +```shell +hydroxide caldav +``` + +Tested on GNOME (Evolution), Thunderbird, KOrganizer. + ### IMAP ⚠️ **Warning**: IMAP support is work-in-progress. Here be dragons. diff --git a/caldav/caldav.go b/caldav/caldav.go new file mode 100644 index 00000000..5d33f3da --- /dev/null +++ b/caldav/caldav.go @@ -0,0 +1,486 @@ +package caldav + +import ( + "bytes" + "context" + "errors" + "fmt" + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/emersion/go-ical" + "github.com/emersion/go-webdav" + "github.com/emersion/go-webdav/caldav" + "github.com/emersion/hydroxide/protonmail" + "io" + "maps" + "net/http" + "strings" + "sync" + "time" +) + +type backend struct { + c *protonmail.Client + privateKeys openpgp.EntityList + keyCache map[string]openpgp.EntityList + locker sync.Mutex +} + +func (b *backend) receiveEvents(events <-chan *protonmail.Event) { + // TODO +} + +func readEventCard(event *ical.Event, eventCard protonmail.CalendarEventCard, userKr openpgp.KeyRing, calKr openpgp.KeyRing, keyPacket string) (ical.Props, error) { + md, err := eventCard.Read(userKr, calKr, keyPacket) + if err != nil { + return nil, fmt.Errorf("caldav/readEventCard: error reading event card: (%w)", err) + } + + data, err := io.ReadAll(md.UnverifiedBody) + if err != nil { + return nil, fmt.Errorf("caldav/readEventCard: error reading unverified body: (%w)", err) + } + + decoded, err := ical.NewDecoder(bytes.NewReader(data)).Decode() + if err != nil { + return nil, fmt.Errorf("caldav/readEventCard: error decoding ical data: (%w)", err) + } + + // The signature can be checked only if md.UnverifiedBody is consumed until + // EOF + // TODO: mdc hash mismatch (?) + /*_, err = io.Copy(io.Discard, md.UnverifiedBody) + if err != nil { + return nil, fmt.Errorf("caldav/readEventCard: error copying unverified body: (%w)", err) + }*/ + + if err := md.SignatureError; err != nil { + return nil, fmt.Errorf("caldav/readEventCard: signature error: (%w)", err) + } + + children := decoded.Events() + if len(children) != 1 { + return nil, fmt.Errorf("caldav/readEventCard: expected VCALENDAR to have exactly one VEVENT") + } + decodedEvent := &children[0] + + for _, props := range decodedEvent.Props { + for _, p := range props { + event.Props.Set(&p) + } + } + + return decoded.Props, nil +} + +func toIcalCalendar(event *protonmail.CalendarEvent, userKr openpgp.KeyRing, calKr openpgp.KeyRing) (*ical.Calendar, error) { + merged := ical.NewEvent() + calProps := ical.Props{} + // TODO: handle AttendeesEvents and PersonalEvents + for _, card := range event.SharedEvents { + if propsMap, err := readEventCard(merged, card, userKr, calKr, event.SharedKeyPacket); err != nil { + return nil, fmt.Errorf("caldav/toIcalCalendar: error reading shared event card: (%w)", err) + } else { + for name, _ := range propsMap { + calProps.Set(propsMap.Get(name)) + } + } + } + + for _, card := range event.CalendarEvents { + if propsMap, err := readEventCard(merged, card, userKr, calKr, event.CalendarKeyPacket); err != nil { + return nil, fmt.Errorf("caldav/toIcalCalendar: error reading calendar event card: (%w)", err) + } else { + for name, _ := range propsMap { + calProps.Set(propsMap.Get(name)) + } + } + } + + for _, notification := range event.Notifications { + alarm := ical.NewComponent(ical.CompAlarm) + + trigger := ical.NewProp("TRIGGER") + trigger.SetValueType(ical.ValueDuration) + trigger.Value = notification.Trigger + + alarm.Props.SetText("ACTION", notification.Type.ToIcalAction()) + alarm.Props.Add(trigger) + + merged.Children = append(merged.Children, alarm) + } + + cal := ical.NewCalendar() + + if calProps != nil { + maps.Copy(cal.Props, calProps) + } + cal.Children = append(cal.Children, merged.Component) + + return cal, nil +} + +func getCalendarObject(b *backend, calId string, calKr openpgp.KeyRing, event *protonmail.CalendarEvent, settings protonmail.CalendarSettings) (*caldav.CalendarObject, error) { + // Events created by hydroxide (and some Proton events) come back with an + // empty Author. There's no email to look up keys for, but such events are + // authored by the current user, so verify their signatures against our own + // keyring instead of calling GetPublicKeys with an empty email (which the + // API rejects with "The Email is required"). + userKr, exists := b.keyCache[event.Author] + if !exists && event.Author == "" { + userKr = b.privateKeys + exists = true + } + if !exists { + userKeys, err := b.c.GetPublicKeys(event.Author) + if err != nil { + return nil, fmt.Errorf("caldav/getCalendarObject: could not get public keys for author %s: (%w)", event.Author, err) + } + + for _, userKey := range userKeys.Keys { + userKeyEntity, err := userKey.Entity() + if err != nil { + return nil, fmt.Errorf("caldav/getCalendarObject: error converting user key entity: (%w)", err) + } + + userKr = append(userKr, userKeyEntity) + } + + b.locker.Lock() + b.keyCache[event.Author] = userKr + b.locker.Unlock() + } + + if event.Notifications == nil { + if event.FullDay == 0 { + event.Notifications = settings.DefaultPartDayNotifications + } else { + event.Notifications = settings.DefaultFullDayNotifications + } + } + + data, err := toIcalCalendar(event, userKr, calKr) + if err != nil { + return nil, fmt.Errorf("caldav/getCalendarObject: error converting to iCal calendar: (%w)", err) + } + + homeSetPath, err := b.CalendarHomeSetPath(nil) + if err != nil { + return nil, fmt.Errorf("caldav/getCalendarObject: error getting calendar home set path: (%w)", err) + } + + co := &caldav.CalendarObject{ + Path: homeSetPath + calId + formatCalendarObjectPath(event.ID), + ModTime: time.Unix(int64(event.LastEditTime), 0), + ETag: fmt.Sprintf("%X%s", event.LastEditTime, event.ID), + Data: data, + } + return co, nil +} + +func formatCalendarObjectPath(id string) string { + return "/" + id + ".ics" +} + +func (b *backend) CalendarHomeSetPath(ctx context.Context) (string, error) { + userPrincipal, err := b.CurrentUserPrincipal(ctx) + if err != nil { + return "", fmt.Errorf("caldav/CalendarHomeSetPath: could not get current user principal: (%w)", err) + } + return userPrincipal + "calendars/", nil +} + +func (b *backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) { + protonCals, err := b.c.ListCalendars() + if err != nil { + return nil, fmt.Errorf("caldav/ListCalendars: error listing ProtonMail calendars: (%w)", err) + } + + cals := make([]caldav.Calendar, len(protonCals)) + homeSetPath, err := b.CalendarHomeSetPath(ctx) + if err != nil { + return nil, fmt.Errorf("caldav/ListCalendars: error getting calendar home set path: (%w)", err) + } + + for i, cal := range protonCals { + calView, err := protonmail.FindMemberViewFromKeyring(cal.Members, b.privateKeys) + if err != nil { + return nil, fmt.Errorf("caldav/ListCalendars: error finding member view for calendar %s: (%w)", cal.ID, err) + } + + caldavCal := caldav.Calendar{ + Path: homeSetPath + cal.ID, + Name: calView.Name, + Description: calView.Description, + } + cals[i] = caldavCal + } + return cals, nil +} + +func (b *backend) GetCalendar(ctx context.Context, path string) (*caldav.Calendar, error) { + protonCals, err := b.c.ListCalendars() + if err != nil { + return nil, fmt.Errorf("caldav/GetCalendar: error listing ProtonMail calendars: (%w)", err) + } + + homeSetPath, err := b.CalendarHomeSetPath(ctx) + if err != nil { + return nil, fmt.Errorf("caldav/GetCalendar: error getting calendar home set path: (%w)", err) + } + + id, _ := strings.CutSuffix(path, "/") + id, _ = strings.CutPrefix(id, homeSetPath) + for _, cal := range protonCals { + if cal.ID != id { + continue + } + + calView, err := protonmail.FindMemberViewFromKeyring(cal.Members, b.privateKeys) + if err != nil { + return nil, fmt.Errorf("caldav/GetCalendar: error finding member view for calendar %s: (%w)", cal.ID, err) + } + + caldavCal := caldav.Calendar{ + Path: homeSetPath + cal.ID, + Name: calView.Name, + Description: calView.Description, + } + + return &caldavCal, nil + } + return nil, errors.New("could not find calendar with path") +} + +func (b *backend) GetCalendarObject(ctx context.Context, path string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) { + homeSetPath, err := b.CalendarHomeSetPath(ctx) + if err != nil { + return nil, fmt.Errorf("caldav/GetCalendarObject: error getting calendar home set path: (%w)", err) + } + + calEvtId, _ := strings.CutSuffix(path, "/") + calEvtId, _ = strings.CutSuffix(calEvtId, ".ics") + calEvtId, _ = strings.CutPrefix(calEvtId, homeSetPath) + splitIds := strings.Split(calEvtId, "/") + if len(splitIds) < 2 { + return nil, fmt.Errorf("caldav/GetCalendarObject: bad path %s", path) + } + + calId, evtId := splitIds[0], splitIds[1] + event, err := b.c.GetCalendarEvent(calId, evtId) + if err != nil { + return nil, fmt.Errorf("caldav/GetCalendarObject: error getting calendar event (calId: %s, evtId: %s): (%w)", calId, evtId, err) + } + + bootstrap, err := b.c.BootstrapCalendar(calId) + if err != nil { + return nil, fmt.Errorf("caldav/GetCalendarObject: error bootstrapping calendar (calId: %s): (%w)", calId, err) + } + + calKr, err := bootstrap.DecryptKeyring(b.privateKeys) + if err != nil { + return nil, fmt.Errorf("caldav/GetCalendarObject: error decrypting keyring: (%w)", err) + } + + co, err := getCalendarObject(b, calId, calKr, event, bootstrap.CalendarSettings) + if err != nil { + return nil, fmt.Errorf("caldav/GetCalendarObject: error creating calendar object: (%w)", err) + } + + return co, nil +} + +func (b *backend) ListCalendarObjects(ctx context.Context, path string, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) { + homeSetPath, err := b.CalendarHomeSetPath(ctx) + if err != nil { + return nil, fmt.Errorf("caldav/ListCalendarObjects: error getting calendar home set path: (%w)", err) + } + + calId, _ := strings.CutSuffix(path, "/") + calId, _ = strings.CutPrefix(calId, homeSetPath) + + events, err := b.c.ListCalendarEvents(calId, nil) + if err != nil { + return nil, fmt.Errorf("caldav/ListCalendarObjects: error listing calendar events for calId %s: (%w)", calId, err) + } + + bootstrap, err := b.c.BootstrapCalendar(calId) + if err != nil { + return nil, fmt.Errorf("caldav/ListCalendarObjects: error bootstrapping calendar (calId: %s): (%w)", calId, err) + } + + calKr, err := bootstrap.DecryptKeyring(b.privateKeys) + if err != nil { + return nil, fmt.Errorf("caldav/ListCalendarObjects: error decrypting keyring: (%w)", err) + } + + cos := make([]caldav.CalendarObject, len(events)) + for i, event := range events { + co, err := getCalendarObject(b, calId, calKr, event, bootstrap.CalendarSettings) + if err != nil { + return nil, fmt.Errorf("caldav/ListCalendarObjects: error creating calendar object for event %d: (%w)", i, err) + } + + cos[i] = *co + } + + return cos, nil +} + +func (b *backend) QueryCalendarObjects(ctx context.Context, path string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) { + //TODO caldav backend lib inefficient for not passing query comprequest, possibly bump go-caldav but need to resolve breaking changes on carddav (would also allow create calendar support) + homeSetPath, err := b.CalendarHomeSetPath(ctx) + if err != nil { + return nil, fmt.Errorf("caldav/QueryCalendarObjects: error getting calendar home set path: (%w)", err) + } + + calId, _ := strings.CutSuffix(path, "/") + calId, _ = strings.CutPrefix(calId, homeSetPath) + + if query.CompFilter.Name != ical.CompCalendar { + return nil, fmt.Errorf("caldav/QueryCalendarObjects: expected top-level comp to be VCALENDAR") + } + if len(query.CompFilter.Comps) != 1 || query.CompFilter.Comps[0].Name != ical.CompEvent { + return nil, fmt.Errorf("caldav/QueryCalendarObjects: expected exactly one nested VEVENT comp") + } + cf := &query.CompFilter.Comps[0] + + filter := protonmail.CalendarEventFilter{ + Start: protonmail.NewTimestamp(cf.Start), + End: protonmail.NewTimestamp(cf.End), + Timezone: cf.Start.Location().String(), + } + + events, err := b.c.ListCalendarEvents(calId, &filter) + if err != nil { + return nil, fmt.Errorf("caldav/QueryCalendarObjects: error listing calendar events for calId %s: (%w)", calId, err) + } + + bootstrap, err := b.c.BootstrapCalendar(calId) + if err != nil { + return nil, fmt.Errorf("caldav/QueryCalendarObjects: error bootstrapping calendar (calId: %s): (%w)", calId, err) + } + + calKr, err := bootstrap.DecryptKeyring(b.privateKeys) + if err != nil { + return nil, fmt.Errorf("caldav/QueryCalendarObjects: error decrypting keyring: (%w)", err) + } + + // ProtonMail's /events endpoint ignores the Start/End query params, so it + // returns events outside the requested window. go-webdav's server doesn't + // post-filter calendar-query results either, so honor the filter here using + // go-webdav's own matcher (a no-op when the query carries no time-range). + cos := make([]caldav.CalendarObject, 0, len(events)) + for i, event := range events { + co, err := getCalendarObject(b, calId, calKr, event, bootstrap.CalendarSettings) + if err != nil { + return nil, fmt.Errorf("caldav/QueryCalendarObjects: error creating calendar object for event %d: (%w)", i, err) + } + + matched, err := caldav.Match(query.CompFilter, co) + if err != nil { + return nil, fmt.Errorf("caldav/QueryCalendarObjects: error matching calendar object for event %d: (%w)", i, err) + } + if !matched { + continue + } + + cos = append(cos, *co) + } + + return cos, nil +} + +func (b *backend) PutCalendarObject(ctx context.Context, path string, calendar *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) { + //TODO: maybe impl opts? + //TODO: attendees maybe + homeSetPath, err := b.CalendarHomeSetPath(nil) + if err != nil { + return nil, fmt.Errorf("caldav/PutCalendarObject: error getting calendar home set path: (%w)", err) + } + + calEvtId, _ := strings.CutSuffix(path, "/") + calEvtId, _ = strings.CutSuffix(calEvtId, ".ics") + calEvtId, _ = strings.CutPrefix(calEvtId, homeSetPath) + splitIds := strings.Split(calEvtId, "/") + if len(splitIds) < 2 { + return nil, fmt.Errorf("caldav/PutCalendarObject: bad path %s", path) + } + + calId, evtId := splitIds[0], splitIds[1] + + events := calendar.Events() + if len(events) != 1 { + return nil, fmt.Errorf("caldav/PutCalendarObject: expected PUT VCALENDAR to have exactly one VEVENT") + } + event := events[0] + + newEvent, err := b.c.UpdateCalendarEvent(calId, evtId, event, b.privateKeys) + if err != nil { + return nil, fmt.Errorf("caldav/PutCalendarObject: error updating calendar event (calId: %s, evtId: %s): (%w)", calId, evtId, err) + } + + // go-webdav v0.7 expects the stored object back; the PUT handler only reads + // Path/ETag/ModTime from it, so a header-only object is sufficient (ETag + // format matches getCalendarObject). + co := &caldav.CalendarObject{ + Path: homeSetPath + calId + formatCalendarObjectPath(newEvent.ID), + ModTime: time.Unix(int64(newEvent.LastEditTime), 0), + ETag: fmt.Sprintf("%X%s", newEvent.LastEditTime, newEvent.ID), + } + return co, nil +} + +// CreateCalendar is required by the go-webdav v0.7 caldav.Backend interface +// (handles MKCALENDAR). Creating a ProtonMail calendar requires generating an +// encrypted calendar key hierarchy, which is not implemented yet, so report it +// as unsupported rather than silently succeeding. Create calendars via the +// Proton web app for now. +func (b *backend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) error { + return webdav.NewHTTPError(http.StatusNotImplemented, errors.New("caldav/CreateCalendar: creating calendars is not supported by hydroxide; create it in the ProtonMail web app")) +} + +func (b *backend) DeleteCalendarObject(ctx context.Context, path string) error { + homeSetPath, err := b.CalendarHomeSetPath(nil) + if err != nil { + return fmt.Errorf("caldav/DeleteCalendarObject: error getting calendar home set path: (%w)", err) + } + + calEvtId, _ := strings.CutSuffix(path, "/") + calEvtId, _ = strings.CutSuffix(calEvtId, ".ics") + calEvtId, _ = strings.CutPrefix(calEvtId, homeSetPath) + splitIds := strings.Split(calEvtId, "/") + if len(splitIds) < 2 { + return fmt.Errorf("caldav/DeleteCalendarObject: bad path %s", path) + } + + calId, evtId := splitIds[0], splitIds[1] + + if err := b.c.DeleteCalendarEvent(calId, evtId); err != nil { + return fmt.Errorf("caldav/DeleteCalendarObject: error deleting calendar event (calId: %s, evtId: %s): (%w)", calId, evtId, err) + } + + return nil +} + +func (b *backend) CurrentUserPrincipal(ctx context.Context) (string, error) { + return "/caldav/", nil +} + +func NewHandler(c *protonmail.Client, privateKeys openpgp.EntityList, username string, events <-chan *protonmail.Event) http.Handler { + if len(privateKeys) == 0 { + panic("hydroxide/caldav: no private key available") + } + + keyCache := map[string]openpgp.EntityList{username: privateKeys} + b := &backend{ + c: c, + privateKeys: privateKeys, + keyCache: keyCache, + } + + if events != nil { + go b.receiveEvents(events) + } + + return &caldav.Handler{Backend: b} +} diff --git a/cmd/hydroxide/main.go b/cmd/hydroxide/main.go index b32780fd..7ffba4f8 100644 --- a/cmd/hydroxide/main.go +++ b/cmd/hydroxide/main.go @@ -19,6 +19,7 @@ import ( "golang.org/x/term" "github.com/emersion/hydroxide/auth" + "github.com/emersion/hydroxide/caldav" "github.com/emersion/hydroxide/carddav" "github.com/emersion/hydroxide/config" "github.com/emersion/hydroxide/events" @@ -114,6 +115,50 @@ func listenAndServeIMAP(addr string, debug bool, authManager *auth.Manager, even return s.ListenAndServe() } +func listenAndServeCalDAV(addr string, authManager *auth.Manager, eventsManager *events.Manager, tlsConfig *tls.Config) error { + handlers := make(map[string]http.Handler) + + s := &http.Server{ + Addr: addr, + TLSConfig: tlsConfig, + Handler: http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + resp.Header().Set("WWW-Authenticate", "Basic") + + username, password, ok := req.BasicAuth() + if !ok { + resp.WriteHeader(http.StatusUnauthorized) + io.WriteString(resp, "Credentials are required") + return + } + + c, privateKeys, err := authManager.Auth(username, password) + if err != nil { + if err == auth.ErrUnauthorized { + resp.WriteHeader(http.StatusUnauthorized) + } else { + resp.WriteHeader(http.StatusInternalServerError) + } + io.WriteString(resp, err.Error()) + return + } + + h, ok := handlers[username] + if !ok { + ch := make(chan *protonmail.Event) + eventsManager.Register(c, username, ch, nil) + h = caldav.NewHandler(c, privateKeys, username, ch) + + handlers[username] = h + } + + h.ServeHTTP(resp, req) + }), + } + + log.Println("CalDAV server listening on", s.Addr) + return s.ListenAndServe() +} + func listenAndServeCardDAV(addr string, authManager *auth.Manager, eventsManager *events.Manager, tlsConfig *tls.Config) error { handlers := make(map[string]http.Handler) @@ -176,6 +221,7 @@ const usage = `usage: hydroxide [options...] Commands: auth Login to ProtonMail via hydroxide carddav Run hydroxide as a CardDAV server + caldav Run hydroxide as a CalDAV server export-secret-keys Export secret keys imap Run hydroxide as an IMAP server import-messages [file] Import messages @@ -197,19 +243,25 @@ Global options: -imap-host example.com Allowed IMAP email hostname on which hydroxide listens, defaults to 127.0.0.1 -carddav-host example.com - Allowed SMTP email hostname on which hydroxide listens, defaults to 127.0.0.1 + Allowed CardDAV email hostname on which hydroxide listens, defaults to 127.0.0.1 + -caldav-host example.com + Allowed CalDAV email hostname on which hydroxide listens, defaults to 127.0.0.1 -smtp-port example.com SMTP port on which hydroxide listens, defaults to 1025 -imap-port example.com IMAP port on which hydroxide listens, defaults to 1143 -carddav-port example.com CardDAV port on which hydroxide listens, defaults to 8080 + -caldav-port example.com + CardDAV port on which hydroxide listens, defaults to 8081 -disable-imap Disable IMAP for hydroxide serve -disable-smtp Disable SMTP for hydroxide serve -disable-carddav Disable CardDAV for hydroxide serve + -disable-caldav + Disable CalDAV for hydroxide serve -tls-cert /path/to/cert.pem Path to the certificate to use for incoming connections (Optional) -tls-key /path/to/key.pem @@ -238,6 +290,10 @@ func main() { carddavPort := flag.String("carddav-port", "8080", "CardDAV port on which hydroxide listens, defaults to 8080") disableCardDAV := flag.Bool("disable-carddav", false, "Disable CardDAV for hydroxide serve") + caldavHost := flag.String("caldav-host", "127.0.0.1", "Allowed CalDAV email hostname on which hydroxide listens, defaults to 127.0.0.1") + caldavPort := flag.String("caldav-port", "8081", "CalDAV port on which hydroxide listens, defaults to 8081") + disableCalDAV := flag.Bool("disable-caldav", false, "Disable CalDAV for hydroxide serve") + tlsCert := flag.String("tls-cert", "", "Path to the certificate to use for incoming connections") tlsCertKey := flag.String("tls-key", "", "Path to the certificate key to use for incoming connections") tlsClientCA := flag.String("tls-client-ca", "", "If set, clients must provide a certificate signed by the given CA") @@ -497,6 +553,11 @@ func main() { authManager := auth.NewManager(newClient) eventsManager := events.NewManager() log.Fatal(listenAndServeIMAP(addr, debug, authManager, eventsManager, tlsConfig)) + case "caldav": + addr := *caldavHost + ":" + *caldavPort + authManager := auth.NewManager(newClient) + eventsManager := events.NewManager() + log.Fatal(listenAndServeCalDAV(addr, authManager, eventsManager, tlsConfig)) case "carddav": addr := *carddavHost + ":" + *carddavPort authManager := auth.NewManager(newClient) @@ -506,6 +567,7 @@ func main() { smtpAddr := *smtpHost + ":" + *smtpPort imapAddr := *imapHost + ":" + *imapPort carddavAddr := *carddavHost + ":" + *carddavPort + caldavAddr := *caldavHost + ":" + *caldavPort authManager := auth.NewManager(newClient) eventsManager := events.NewManager() @@ -526,6 +588,11 @@ func main() { done <- listenAndServeCardDAV(carddavAddr, authManager, eventsManager, tlsConfig) }() } + if !*disableCalDAV { + go func() { + done <- listenAndServeCalDAV(caldavAddr, authManager, eventsManager, tlsConfig) + }() + } log.Fatal(<-done) case "sendmail": username := flag.Arg(1) diff --git a/go.mod b/go.mod index b3b9366e..62a1034a 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.0 require ( github.com/ProtonMail/go-crypto v1.4.1 github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63 + github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 github.com/emersion/go-imap v1.2.1 github.com/emersion/go-mbox v1.0.4 github.com/emersion/go-message v0.18.2 @@ -19,6 +20,7 @@ require ( require ( github.com/cloudflare/circl v1.6.3 // indirect + github.com/teambition/rrule-go v1.8.2 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect ) diff --git a/go.sum b/go.sum index 348d8c05..f52e4ce9 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,7 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63 h1:7aCSuwTBzg7BCPRRaBJD0weKZYdeAykOrY6ktpx8Vvc= github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63/go.mod h1:eRwwJnuLVFtYTC+AI2JDJTMcuQUTYhBIK4I6bC5tpqw= +github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM= github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= @@ -29,6 +30,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= diff --git a/protonmail/calendar.go b/protonmail/calendar.go index 0dabd873..d6f757ee 100644 --- a/protonmail/calendar.go +++ b/protonmail/calendar.go @@ -1,9 +1,20 @@ package protonmail import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" + "github.com/ProtonMail/go-crypto/openpgp/packet" + "github.com/emersion/go-ical" + "io" + "maps" "net/http" "net/url" "strconv" + "strings" ) const calendarPath = "/calendar/v1" @@ -11,49 +22,299 @@ const calendarPath = "/calendar/v1" type CalendarFlags int type Calendar struct { + ID string + Type int + CreateTime Timestamp + Members []CalendarMemberView +} + +type CalendarBootstrap struct { + Keys []CalendarKey + Passphrase CalendarPassphrase + Members []CalendarMemberView + CalendarSettings CalendarSettings +} + +type CalendarSettings struct { + ID string + CalendarID string + DefaultEventDuration int + DefaultPartDayNotifications []CalendarNotification + DefaultFullDayNotifications []CalendarNotification +} + +type CalendarKey struct { + ID string + PrivateKey string + PassphraseID string + Flags int + CalendarID string +} + +type CalendarPassphrase struct { + Flags int + ID string + MemberPassphrases []CalendarMemberPassphrase + CalendarID string +} + +type CalendarMemberView struct { ID string + Permissions int + Email string + AddressID string + CalendarID string Name string Description string Color string Display int - Flags CalendarFlags + Priority int + Flags int +} + +type CalendarMemberPassphrase struct { + MemberID string + Passphrase string + Signature string } type CalendarEventPermissions int +const ( + CalendarEventAvailability CalendarEventPermissions = 1 << iota + CalendarEventRead + CalendarEventReadMemberList + CalendarEventWrite + CalendarEventAdmin + CalendarEventOwner +) + type CalendarEvent struct { - ID string - CalendarID string - CalendarKeyPacket string - CreateTime Timestamp - LastEditTime Timestamp - Author string - Permissions CalendarEventPermissions - SharedKeyPacket string - SharedEvents []CalendarEventCard - CalendarEvents interface{} - PersonalEvent []CalendarEventCard + ID string + CalendarID string + SharedEventID string + CalendarKeyPacket string + CreateTime, ModifyTime Timestamp + LastEditTime Timestamp + StartTime, EndTime Timestamp + StartTimezone, EndTimezone string + FullDay int + UID string + IsOrganizer int + RecurrenceID string + Exdates []interface{} + RRule interface{} + Author string + Permissions CalendarEventPermissions + SharedKeyPacket string + SharedEvents []CalendarEventCard + CalendarEvents []CalendarEventCard + PersonalEvents []CalendarEventCard + AttendeesEvents []CalendarEventCard + Attendees []interface{} + Notifications []CalendarNotification } type CalendarEventCardType int +const ( + CalendarEventCardClear CalendarEventCardType = iota + CalendarEventCardEncrypted + CalendarEventCardSigned + CalendarEventCardEncryptedAndSigned +) + +func (t CalendarEventCardType) Signed() bool { + switch t { + case CalendarEventCardSigned, CalendarEventCardEncryptedAndSigned: + return true + default: + return false + } +} + +func (t CalendarEventCardType) Encrypted() bool { + switch t { + case CalendarEventCardEncryptedAndSigned: + return true + default: + return false + } +} + type CalendarEventCard struct { Type CalendarEventCardType Data string Signature string - MemberID string + MemberID string `json:",omitempty"` + Author string `json:",omitempty"` } -func (c *Client) ListCalendars(page, pageSize int) ([]*Calendar, error) { - v := url.Values{} - v.Set("Page", strconv.Itoa(page)) - if pageSize > 0 { - v.Set("PageSize", strconv.Itoa(pageSize)) +type CalendarNotificationType int + +const ( + CalendarNotificationEmail CalendarNotificationType = iota + CalendarNotificationDevice +) + +func (t CalendarNotificationType) ToIcalAction() string { + switch t { + case CalendarNotificationEmail: + return "EMAIL" + case CalendarNotificationDevice: + return "DISPLAY" + default: + return "" + } +} + +func ValarmActionToCalendarNotificationType(action string) CalendarNotificationType { + switch action { + case "EMAIL": + return CalendarNotificationEmail + case "DISPLAY": + fallthrough + default: + return CalendarNotificationDevice } +} - req, err := c.newRequest(http.MethodGet, calendarPath+"?"+v.Encode(), nil) +type CalendarNotification struct { + Type CalendarNotificationType + Trigger string +} + +func FindMemberViewFromKeyring(members []CalendarMemberView, kr openpgp.KeyRing) (*CalendarMemberView, error) { + for _, _member := range members { + for _, userKey := range kr.DecryptionKeys() { + for _, identity := range userKey.Entity.Identities { + if _member.Email == identity.UserId.Email { + return &_member, nil + } + } + } + } + return nil, fmt.Errorf("FindMemberViewFromKeyring: could not find a CalendarMemberView for the provided keyring") +} + +func (bootstrap *CalendarBootstrap) DecryptKeyring(userKr openpgp.KeyRing) (openpgp.KeyRing, error) { + var calKr openpgp.EntityList + for _, key := range bootstrap.Keys { + var passphrase *CalendarMemberPassphrase + + member, err := FindMemberViewFromKeyring(bootstrap.Members, userKr) + if err != nil { + return nil, fmt.Errorf("DecryptKeyring: failed to find member view: (%w)", err) + } + + for _, _passphrase := range bootstrap.Passphrase.MemberPassphrases { + if _passphrase.MemberID == member.ID { + passphrase = &_passphrase + break + } + } + if passphrase == nil { + return nil, fmt.Errorf("DecryptKeyring: could not find MemberPassphrase for MemberID: %s", member.ID) + } + + passphraseEnc, err := armor.Decode(strings.NewReader(passphrase.Passphrase)) + if err != nil { + return nil, fmt.Errorf("DecryptKeyring: failed to decode passphrase: (%w)", err) + } + + md, err := openpgp.ReadMessage(passphraseEnc.Body, userKr, nil, nil) + if err != nil { + return nil, fmt.Errorf("DecryptKeyring: failed to read message: (%w)", err) + } + + passphraseBytes, err := io.ReadAll(md.UnverifiedBody) + if err != nil { + return nil, fmt.Errorf("DecryptKeyring: failed to read passphrase body: (%w)", err) + } + + /* signatureData, err := armor.Decode(strings.NewReader(passphrase.Signature)) + if err != nil { + return nil, err + } + _, err = openpgp.CheckArmoredDetachedSignature(userKr, bytes.NewReader(passphraseBytes), signatureData.Body, nil) + if err != nil { + return nil, err + }*/ + + keyKr, err := openpgp.ReadArmoredKeyRing(strings.NewReader(key.PrivateKey)) + if err != nil { + return nil, fmt.Errorf("DecryptKeyring: failed to read armored key ring: (%w)", err) + } + + for _, calKey := range keyKr { + err = calKey.PrivateKey.Decrypt(passphraseBytes) + if err != nil { + return nil, fmt.Errorf("DecryptKeyring: failed to decrypt private key: (%w)", err) + } + + for _, subKey := range calKey.Subkeys { + err := subKey.PrivateKey.Decrypt(passphraseBytes) + if err != nil { + return nil, fmt.Errorf("DecryptKeyring: failed to decrypt subkey: (%w)", err) + } + } + } + + calKr = append(calKr, keyKr...) + } + return calKr, nil +} + +func (card *CalendarEventCard) Read(userKr openpgp.KeyRing, calKr openpgp.KeyRing, keyPacket string) (*openpgp.MessageDetails, error) { + if !card.Type.Encrypted() { + md := &openpgp.MessageDetails{ + IsEncrypted: false, + IsSigned: false, + UnverifiedBody: strings.NewReader(card.Data), + } + + if !card.Type.Signed() { + return md, nil + } + + signed := strings.NewReader(card.Data) + signature := strings.NewReader(card.Signature) + signer, err := openpgp.CheckArmoredDetachedSignature(userKr, signed, signature, nil) + md.IsSigned = true + md.SignatureError = err + if signer != nil { + md.SignedByKeyId = signer.PrimaryKey.KeyId + md.SignedBy = entityPrimaryKey(signer) + } + return md, nil + } + + keyPacketData := base64.NewDecoder(base64.StdEncoding, strings.NewReader(keyPacket)) + ciphertext := base64.NewDecoder(base64.StdEncoding, strings.NewReader(card.Data)) + msg := io.MultiReader(keyPacketData, ciphertext) + md, err := openpgp.ReadMessage(msg, calKr, nil, nil) if err != nil { - return nil, err + return nil, fmt.Errorf("Read: failed to read message: (%w)", err) + } + + if card.Type.Signed() { + r := &detachedSignatureReader{ + md: md, + signature: strings.NewReader(card.Signature), + keyring: userKr, + } + r.body = io.TeeReader(md.UnverifiedBody, &r.signed) + + md.UnverifiedBody = r + } + + return md, nil +} + +func (c *Client) ListCalendars() ([]*Calendar, error) { + req, err := c.newRequest(http.MethodGet, calendarPath, nil) + if err != nil { + return nil, fmt.Errorf("ListCalendars: failed to create new request: (%w)", err) } var respData struct { @@ -61,31 +322,51 @@ func (c *Client) ListCalendars(page, pageSize int) ([]*Calendar, error) { Calendars []*Calendar } if err := c.doJSON(req, &respData); err != nil { - return nil, err + return nil, fmt.Errorf("ListCalendars: failed to execute JSON request: (%w)", err) } return respData.Calendars, nil } +func (c *Client) BootstrapCalendar(id string) (*CalendarBootstrap, error) { + req, err := c.newRequest(http.MethodGet, calendarPath+"/"+id+"/bootstrap", nil) + if err != nil { + return nil, fmt.Errorf("BootstrapCalendar: failed to create new request: (%w)", err) + } + + var respData struct { + resp + *CalendarBootstrap + } + if err := c.doJSON(req, &respData); err != nil { + return nil, fmt.Errorf("BootstrapCalendar: failed to execute JSON request: (%w)", err) + } + + return respData.CalendarBootstrap, nil +} + type CalendarEventFilter struct { - Start, End int64 + Start, End Timestamp Timezone string Page, PageSize int } func (c *Client) ListCalendarEvents(calendarID string, filter *CalendarEventFilter) ([]*CalendarEvent, error) { v := url.Values{} - v.Set("Start", strconv.FormatInt(filter.Start, 10)) - v.Set("End", strconv.FormatInt(filter.End, 10)) - v.Set("Timezone", filter.Timezone) - v.Set("Page", strconv.Itoa(filter.Page)) - if filter.PageSize > 0 { - v.Set("PageSize", strconv.Itoa(filter.PageSize)) + + if filter != nil { + v.Set("Start", strconv.FormatInt(int64(filter.Start), 10)) + v.Set("End", strconv.FormatInt(int64(filter.End), 10)) + v.Set("Timezone", filter.Timezone) + v.Set("Page", strconv.Itoa(filter.Page)) + if filter.PageSize > 0 { + v.Set("PageSize", strconv.Itoa(filter.PageSize)) + } } req, err := c.newRequest(http.MethodGet, calendarPath+"/"+calendarID+"/events?"+v.Encode(), nil) if err != nil { - return nil, err + return nil, fmt.Errorf("ListCalendarEvents: failed to create new request for calendarID %s: (%w)", calendarID, err) } var respData struct { @@ -93,9 +374,617 @@ func (c *Client) ListCalendarEvents(calendarID string, filter *CalendarEventFilt Events []*CalendarEvent } if err := c.doJSON(req, &respData); err != nil { - return nil, err + return nil, fmt.Errorf("ListCalendarEvents: failed to execute JSON request for calendarID %s: (%w)", calendarID, err) } return respData.Events, nil +} + +func (c *Client) GetCalendarEvent(calendarID string, eventID string) (*CalendarEvent, error) { + req, err := c.newRequest(http.MethodGet, calendarPath+"/"+calendarID+"/events/"+eventID, nil) + if err != nil { + return nil, fmt.Errorf("GetCalendarEvent: failed to create new request for calendarID %s and eventID %s: (%w)", calendarID, eventID, err) + } + + var respData struct { + resp + Event *CalendarEvent + } + if err := c.doJSON(req, &respData); err != nil { + return nil, fmt.Errorf("GetCalendarEvent: failed to execute JSON request for calendarID %s and eventID %s: (%w)", calendarID, eventID, err) + } + + return respData.Event, nil +} + +func concat(slices [][]string) []string { + var totalLen int + for _, s := range slices { + totalLen += len(s) + } + tmp := make([]string, totalLen) + var i int + for _, s := range slices { + i += copy(tmp[i:], s) + } + return tmp +} + +type CalendarEventCreateOrUpdateData struct { + Color *string + Notifications []CalendarNotification + CalendarKeyPacket string `json:",omitempty"` + CalendarEventContent []CalendarEventCard `json:",omitempty"` + SharedKeyPacket string `json:",omitempty"` + SharedEventContent []CalendarEventCard `json:",omitempty"` + Permissions int `json:",omitempty"` + IsOrganizer int `json:",omitempty"` + // AttendeesEventContent, AddedProtonAttendees, Attendees, CancelledOccurrenceContent, IsPersonalSingleEdit, RemovedAttendeeAddresses ... +} + +type CalendarEventSyncReq struct { + MemberID string + Events []interface{} +} + +type CalendarEventCreateSyncEntry struct { + Overwrite int + Event *CalendarEventCreateOrUpdateData +} + +type CalendarEventUpdateSyncEntry struct { + ID string + Event *CalendarEventCreateOrUpdateData +} + +var sharedSignedFields = []string{ + "uid", + "dtstamp", + "dtstart", + "dtend", + "recurrence-id", + "rrule", + "exdate", + "organizer", + "sequence", +} +var sharedEncryptedFields = []string{ + "uid", + "dtstamp", + "created", + "description", + "summary", + "location", +} + +var calendarSignedFields = []string{ + "uid", + "dtstamp", + "exdate", + "status", + "transp", +} +var calendarEncryptedFields = []string{ + "uid", + "dtstamp", + "comment", +} + +var requiredSet = map[string]struct{}{ + "uid": {}, + "dtstamp": {}, +} + +/*var personalSignedFields = []string{ + "uid", + "dtstamp", +} +var personalEncryptedFields = []string{}*/ + +var usedFields = concat([][]string{ + sharedSignedFields, + sharedEncryptedFields, + + calendarSignedFields, + calendarEncryptedFields, + /* + personalSignedFields, + personalEncryptedFields,*/ +}) + +// ... attendeesSigned/EncryptedFields +func pickProps(event *ical.Event, propNames []string) *ical.Component { + evt := ical.NewEvent() + for _, propName := range propNames { + props := event.Props.Values(propName) + if props != nil && len(props) > 0 { + evt.Props[props[0].Name] = props + } + } + + return evt.Component +} + +func makeIcal(props ical.Props, components ...*ical.Component) *ical.Calendar { + cal := ical.NewCalendar() + + if props != nil { + maps.Copy(cal.Props, props) + } + + cal.Props.SetText(ical.PropVersion, "2.0") + cal.Props.SetText(ical.PropProductID, "-//Proton AG//web-calendar 5.0.33.2//EN") // TODO: change? + + if components != nil { + cal.Children = append(cal.Children, components...) + } + + return cal +} + +func getEventParts(event *ical.Event) (map[CalendarEventCardType]*ical.Calendar, map[CalendarEventCardType]*ical.Calendar) { + sharedPart := make(map[CalendarEventCardType]*ical.Calendar) + sharedPart[CalendarEventCardSigned] = makeIcal(nil, pickProps(event, sharedSignedFields)) + sharedPart[CalendarEventCardEncryptedAndSigned] = makeIcal(nil, pickProps(event, sharedEncryptedFields)) + + calendarPart := make(map[CalendarEventCardType]*ical.Calendar) + calendarPart[CalendarEventCardSigned] = makeIcal(nil, pickProps(event, calendarSignedFields)) + calendarPart[CalendarEventCardEncryptedAndSigned] = makeIcal(nil, pickProps(event, calendarEncryptedFields)) + + // No personal part support for now + /*personalPart := make(map[CalendarEventCardType]*ical.Calendar) + personalPart[CalendarEventCardSigned] = pickProps(event, personalSignedFields) + personalPart[CalendarEventCardEncryptedAndSigned] = pickProps(event, personalEncryptedFields) + */ + + for _, propName := range usedFields { + event.Props.Del(strings.ToUpper(propName)) + } + for name, props := range event.Props { + sharedPart[CalendarEventCardEncryptedAndSigned].Events()[0].Component.Props[strings.ToUpper(name)] = append(sharedPart[CalendarEventCardEncryptedAndSigned].Props[strings.ToUpper(name)], props...) + } + + return sharedPart, calendarPart +} + +func encodePart(part map[CalendarEventCardType]*ical.Calendar) (map[CalendarEventCardType]string, error) { + encodedPart := make(map[CalendarEventCardType]string) + for cardType, card := range part { + if card == nil { + encodedPart[cardType] = "" + continue + } + icalData := new(bytes.Buffer) + icalEncoder := ical.NewEncoder(icalData) + err := icalEncoder.Encode(card) + if err != nil { + return nil, fmt.Errorf("encodePart: failed to encode card to ical: (%w)", err) + } + + encodedPart[cardType] = icalData.String() + } + + return encodedPart, nil +} +func encodePartOptimized(part map[CalendarEventCardType]*ical.Calendar) (map[CalendarEventCardType]string, error) { + for cardType, card := range part { + evt := card.Children[0] + if len(evt.Children) > 0 { + continue + } + + if len(evt.Props) > len(requiredSet) { + continue + } + + isExactMatch := true + for propName := range evt.Props { + if _, exists := requiredSet[strings.ToLower(propName)]; !exists { + isExactMatch = false + break + } + } + + if isExactMatch && len(evt.Props) == len(requiredSet) { + part[cardType] = nil + } + } + + return encodePart(part) +} + +func decryptSessionKey(sessionKey string, calKr openpgp.KeyRing) (*packet.EncryptedKey, error) { + if sessionKey == "" { + return nil, nil + } + + sharedKeyPacket := base64.NewDecoder(base64.StdEncoding, strings.NewReader(sessionKey)) + packetReader := packet.NewReader(sharedKeyPacket) + + pkt, err := packetReader.Next() + if err != nil { + return nil, fmt.Errorf("decryptSessionKey: failed to read next packet: (%w)", err) + } + + switch pkt := pkt.(type) { + case *packet.EncryptedKey: + decryptionKey := calKr.KeysById(pkt.KeyId)[0] + if decryptionKey.PrivateKey.Encrypted { + return nil, fmt.Errorf("decryptSessionKey: decryption private key must be decrypted for KeyId %d", pkt.KeyId) + } + + err := pkt.Decrypt(decryptionKey.PrivateKey, nil) + if err != nil { + return nil, fmt.Errorf("decryptSessionKey: failed to decrypt encrypted key for KeyId %d: (%w)", pkt.KeyId, err) + } + return pkt, nil + default: + return nil, fmt.Errorf("decryptSessionKey: unexpected packet type, unable to decrypt session key") + } +} + +func getOrGenerateSessionKey(keyPacket string, calKr openpgp.KeyRing, config *packet.Config) (*packet.EncryptedKey, string, error) { + var sessionKey *packet.EncryptedKey + var encryptedSessionKey string + if keyPacket != "" { + encryptedSessionKey = keyPacket + + var err error + sessionKey, err = decryptSessionKey(encryptedSessionKey, calKr) + if err != nil { + return nil, "", fmt.Errorf("getOrGenerateSessionKey: failed to decrypt session key: (%w)", err) + } + } + + if sessionKey == nil { + var err error + sessionKey, err = generateUnencryptedKey(packet.CipherAES256, config) + if err != nil { + return nil, "", fmt.Errorf("getOrGenerateSessionKey: failed to generate unencrypted key: (%w)", err) + } + + calKeys := getCalKeys(calKr) + calEncryptionKey, ok := encryptionKey(calKeys, config.Now()) + if !ok { + return nil, "", fmt.Errorf("getOrGenerateSessionKey: could not find encryption key for calendar keys") + } + encryptedSessionKey, err = serializeEncryptedKey(sessionKey, calEncryptionKey.PublicKey, config) + if err != nil { + return nil, "", fmt.Errorf("getOrGenerateSessionKey: failed to serialize encrypted key: (%w)", err) + } + } + + return sessionKey, encryptedSessionKey, nil +} + +func getCalKeys(calKr openpgp.KeyRing) *openpgp.Entity { + return calKr.(openpgp.EntityList)[0] // Is the first key always the correct one? +} + +func getUserKeys(userKr openpgp.KeyRing) *openpgp.Entity { + return userKr.(openpgp.EntityList)[0] // Is the first key always the correct one? +} + +func encryptPart(part string, key *packet.EncryptedKey, signer *openpgp.Entity, config *packet.Config) (string, error) { + var signKey *packet.PrivateKey + if signer != nil { + signKeys, ok := signingKey(signer, config.Now()) + if !ok { + return "", fmt.Errorf("encryptPart: no valid signing keys available") + } + signKey = signKeys.PrivateKey + if signKey == nil { + return "", fmt.Errorf("encryptPart: no private key found in signing key") + } + if signKey.Encrypted { + return "", fmt.Errorf("encryptPart: signing key must be decrypted") + } + } + + encryptedBuf := new(bytes.Buffer) + encryptedTextWriter := base64.NewEncoder(base64.StdEncoding, encryptedBuf) + + hints := openpgp.FileHints{ + IsBinary: true, + ModTime: config.Now(), + } + + clearTextWriter, err := symetricallyEncrypt(encryptedTextWriter, key, signKey, &hints, config) + if err != nil { + return "", fmt.Errorf("encryptPart: failed to encrypt text: (%w)", err) + } + + _, err = clearTextWriter.Write([]byte(part)) + if err != nil { + return "", fmt.Errorf("encryptPart: failed to write part data: (%w)", err) + } + + err = clearTextWriter.Close() + if err != nil { + return "", fmt.Errorf("encryptPart: failed to close clear text writer: (%w)", err) + } + + err = encryptedTextWriter.Close() + if err != nil { + return "", fmt.Errorf("encryptPart: failed to close encrypted text writer: (%w)", err) + } + + return encryptedBuf.String(), nil +} + +func signPart(part string, signer *openpgp.Entity, config *packet.Config) (string, error) { + signatureBuf := new(bytes.Buffer) + if err := openpgp.ArmoredDetachSignText(signatureBuf, signer, strings.NewReader(part), config); err != nil { + return "", fmt.Errorf("signPart: failed to sign part: (%w)", err) + } + + return signatureBuf.String(), nil +} + +func makeUpdateData(c *Client, calID string, oldEvent *CalendarEvent, event ical.Event, userKr openpgp.KeyRing) (*CalendarEventCreateOrUpdateData, string, error) { + isCreate := oldEvent == nil + + bootstrap, err := c.BootstrapCalendar(calID) + if err != nil { + return nil, "", fmt.Errorf("makeUpdateData: failed to bootstrap calendar with ID %s: (%w)", calID, err) + } + + calKr, err := bootstrap.DecryptKeyring(userKr) + if err != nil { + return nil, "", fmt.Errorf("makeUpdateData: failed to decrypt keyring: (%w)", err) + } + + sharedPartCal, calendarPartCal := getEventParts(&event) + sharedPart, err := encodePart(sharedPartCal) + if err != nil { + return nil, "", fmt.Errorf("makeUpdateData: failed to encode shared part: (%w)", err) + } + calendarPart, err := encodePartOptimized(calendarPartCal) + if err != nil { + return nil, "", fmt.Errorf("makeUpdateData: failed to encode calendar part: (%w)", err) + } + + config := &packet.Config{} + data := CalendarEventCreateOrUpdateData{} + data.Permissions = 1 + + color := event.Props.Get("color") + if color != nil && color.Value != "" { + data.Color = &color.Value + } + + notifications := make([]CalendarNotification, 0) + for _, child := range event.Children { + if child.Name != ical.CompAlarm { + continue + } + + notification := CalendarNotification{} + + action := child.Props.Get("ACTION") + notification.Type = ValarmActionToCalendarNotificationType(action.Value) + + trigger := child.Props.Get("TRIGGER") + notification.Trigger = trigger.Value + + notifications = append(notifications, notification) + } + + if len(notifications) > 0 { + data.Notifications = notifications + } + + if oldEvent != nil { + data.IsOrganizer = oldEvent.IsOrganizer + } else { + data.IsOrganizer = 1 + } + + userKeys := getUserKeys(userKr) + if signedSharedPart, ok := sharedPart[CalendarEventCardSigned]; ok && signedSharedPart != "" { + signature, err := signPart(signedSharedPart, userKeys, config) + if err != nil { + return nil, "", fmt.Errorf("makeUpdateData: failed to sign shared part: (%w)", err) + } + + card := CalendarEventCard{ + Type: CalendarEventCardSigned, + Data: signedSharedPart, + Signature: signature, + } + + data.SharedEventContent = append(data.SharedEventContent, card) + } + if encryptedSharedPart, ok := sharedPart[CalendarEventCardEncryptedAndSigned]; ok && encryptedSharedPart != "" { + sharedKeyPacket := "" + if oldEvent != nil { + sharedKeyPacket = oldEvent.SharedKeyPacket + } + sharedSessionKey, encryptedSharedSessionKey, err := getOrGenerateSessionKey(sharedKeyPacket, calKr, config) + if err != nil { + return nil, "", fmt.Errorf("makeUpdateData: failed to get or generate session key for shared part: (%w)", err) + } + + if isCreate || sharedKeyPacket == "" { + data.SharedKeyPacket = encryptedSharedSessionKey + } + + signature, err := signPart(encryptedSharedPart, userKeys, config) + if err != nil { + return nil, "", fmt.Errorf("makeUpdateData: failed to sign encrypted shared part: (%w)", err) + } + + encryptedData, err := encryptPart(encryptedSharedPart, sharedSessionKey, nil, config) + if err != nil { + return nil, "", fmt.Errorf("makeUpdateData: failed to encrypt shared part: (%w)", err) + } + + card := CalendarEventCard{ + Type: CalendarEventCardEncryptedAndSigned, + Data: encryptedData, + Signature: signature, + } + + data.SharedEventContent = append(data.SharedEventContent, card) + } + + if signedCalendarPart, ok := calendarPart[CalendarEventCardSigned]; ok && signedCalendarPart != "" { + signature, err := signPart(signedCalendarPart, userKeys, config) + if err != nil { + return nil, "", fmt.Errorf("makeUpdateData: failed to sign calendar part: (%w)", err) + } + + card := CalendarEventCard{ + Type: CalendarEventCardSigned, + Data: signedCalendarPart, + Signature: signature, + } + + data.CalendarEventContent = append(data.CalendarEventContent, card) + } + if encryptedCalendarPart, ok := calendarPart[CalendarEventCardEncryptedAndSigned]; ok && encryptedCalendarPart != "" { + calendarKeyPacket := "" + if oldEvent != nil { + calendarKeyPacket = oldEvent.CalendarKeyPacket + } + calendarSessionKey, encryptedCalendarSessionKey, err := getOrGenerateSessionKey(calendarKeyPacket, calKr, config) + if err != nil { + return nil, "", fmt.Errorf("makeUpdateData: failed to get or generate session key for calendar part: (%w)", err) + } + + if isCreate || calendarKeyPacket == "" { + data.CalendarKeyPacket = encryptedCalendarSessionKey + } + + signature, err := signPart(encryptedCalendarPart, userKeys, config) + if err != nil { + return nil, "", fmt.Errorf("makeUpdateData: failed to sign encrypted calendar part: (%w)", err) + } + + encryptedData, err := encryptPart(encryptedCalendarPart, calendarSessionKey, nil, config) + if err != nil { + return nil, "", fmt.Errorf("makeUpdateData: failed to encrypt calendar part: (%w)", err) + } + + card := CalendarEventCard{ + Type: CalendarEventCardEncryptedAndSigned, + Data: encryptedData, + Signature: signature, + } + + data.CalendarEventContent = append(data.CalendarEventContent, card) + } + + // Attendees encrypted and clear parts ... + // Removed attendees emails ... + // Attendees encrypted session keys ... + // Cancelled occurrence parts ... + + member, err := FindMemberViewFromKeyring(bootstrap.Members, userKr) + if err != nil { + return nil, "", fmt.Errorf("makeUpdateData: failed to find member view from keyring: (%w)", err) + } + + return &data, member.ID, nil +} + +// calendarEventNotFoundCode is the ProtonMail API error code returned when an +// event ID does not exist (e.g. when a CalDAV client PUTs a brand new event +// under a UID it picked). It is used to distinguish a create from an update. +const calendarEventNotFoundCode = 2061 + +// isEventNotFoundErr reports whether err (possibly wrapped) is a ProtonMail API +// error indicating the calendar event does not exist. GetCalendarEvent wraps +// the APIError with fmt.Errorf("...%w..."), so a plain type assertion fails; +// errors.As is required to unwrap it. +func isEventNotFoundErr(err error) bool { + var apiErr *APIError + return errors.As(err, &apiErr) && apiErr.Code == calendarEventNotFoundCode +} + +func (c *Client) UpdateCalendarEvent(calID string, eventID string, event ical.Event, userKr openpgp.KeyRing) (*CalendarEvent, error) { + oldEvent, err := c.GetCalendarEvent(calID, eventID) + isCreate := false + if isEventNotFoundErr(err) { + isCreate = true + } else if err != nil { + return nil, fmt.Errorf("UpdateCalendarEvent: could not get old calendar event: (%w)", err) + } + + data, memberID, err := makeUpdateData(c, calID, oldEvent, event, userKr) + if err != nil { + return nil, fmt.Errorf("UpdateCalendarEvent: could not make update data: (%w)", err) + } + + var entry interface{} + if isCreate { + entry = CalendarEventCreateSyncEntry{ + Event: data, + } + } else { + entry = CalendarEventUpdateSyncEntry{ + ID: eventID, + Event: data, + } + } + + body := CalendarEventSyncReq{ + MemberID: memberID, + Events: []interface{}{ + entry, + }, + } + + req, err := c.newJSONRequest(http.MethodPut, calendarPath+"/"+calID+"/events/sync", body) + if err != nil { + return nil, fmt.Errorf("UpdateCalendarEvent: could not create JSON request: (%w)", err) + } + + var respData struct { + resp + Responses []struct { + Index int + Response struct { + resp + Event *CalendarEvent + } + } + } + + if err := c.doJSON(req, &respData); err != nil { + return nil, fmt.Errorf("UpdateCalendarEvent: could not send JSON request: (%w)", err) + } + + if len(respData.Responses) != 1 || respData.Responses[0].Response.Event == nil { + return nil, fmt.Errorf("UpdateCalendarEvent: no event on events sync response") + } + + return respData.Responses[0].Response.Event, nil +} + +type CalendarEventDeleteSyncEntry struct { + ID string + DeletionReason int +} + +func (c *Client) DeleteCalendarEvent(calID string, eventID string) error { + body := CalendarEventSyncReq{ + Events: []interface{}{ + CalendarEventDeleteSyncEntry{ + ID: eventID, + DeletionReason: 0, + }, + }, + } + + req, err := c.newJSONRequest(http.MethodPut, calendarPath+"/"+calID+"/events/sync", body) + if err != nil { + return fmt.Errorf("DeleteCalendarEvent: could not create JSON request: (%w)", err) + } + + if _, err := c.do(req); err != nil { + return fmt.Errorf("DeleteCalendarEvent: could not send JSON request: (%w)", err) + } + return nil } diff --git a/protonmail/calendar_test.go b/protonmail/calendar_test.go new file mode 100644 index 00000000..038ba389 --- /dev/null +++ b/protonmail/calendar_test.go @@ -0,0 +1,54 @@ +package protonmail + +import ( + "errors" + "fmt" + "testing" +) + +// TestIsEventNotFoundErr guards the create-vs-update detection in +// UpdateCalendarEvent. GetCalendarEvent wraps its APIError with +// fmt.Errorf("...%w..."), which a bare `err.(*APIError)` type assertion does +// NOT see through — that regression made every CalDAV event creation fail with +// a 500. isEventNotFoundErr must unwrap via errors.As. +func TestIsEventNotFoundErr(t *testing.T) { + notFound := &APIError{Code: calendarEventNotFoundCode, Message: "Attribute EventID is invalid"} + + cases := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"bare not-found APIError", notFound, true}, + { + "wrapped not-found APIError (real GetCalendarEvent path)", + fmt.Errorf("GetCalendarEvent: failed: (%w)", notFound), + true, + }, + { + "doubly wrapped not-found APIError", + fmt.Errorf("outer: (%w)", fmt.Errorf("inner: (%w)", notFound)), + true, + }, + { + "different API error code", + &APIError{Code: 33101, Message: "The Email is required"}, + false, + }, + { + "wrapped different API error code", + fmt.Errorf("wrap: (%w)", &APIError{Code: 33101}), + false, + }, + {"non-API error", errors.New("network down"), false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isEventNotFoundErr(tc.err); got != tc.want { + t.Fatalf("isEventNotFoundErr(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} diff --git a/protonmail/protonmail.go b/protonmail/protonmail.go index 8093fb36..a5985f90 100644 --- a/protonmail/protonmail.go +++ b/protonmail/protonmail.go @@ -54,6 +54,10 @@ func (err *APIError) Error() string { type Timestamp int64 +func NewTimestamp(t time.Time) Timestamp { + return Timestamp(t.Unix()) +} + func (t Timestamp) Time() time.Time { return time.Unix(int64(t), 0) } diff --git a/test/caldav_e2e.py b/test/caldav_e2e.py new file mode 100644 index 00000000..eeb5c0de --- /dev/null +++ b/test/caldav_e2e.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""End-to-end test for hydroxide CalDAV support (PR #282 + fixes). + +Exercises the full lifecycle against a running `hydroxide caldav`/`serve` +instance and a live ProtonMail account that has at least one calendar: + + discover -> create -> read (PROPFIND + GET) -> time-range query -> update -> delete + +Usage: + export HYDROXIDE_CALDAV_URL=http://127.0.0.1:8081 + export HYDROXIDE_USER=you@proton.me + export HYDROXIDE_BRIDGE_PASS= + python3 test/caldav_e2e.py + +Exits non-zero if any check fails. Requires `requests` (pip install requests). +""" +import os +import re +import sys +import uuid + +import requests +from requests.auth import HTTPBasicAuth + +BASE = os.environ.get("HYDROXIDE_CALDAV_URL", "http://127.0.0.1:8081").rstrip("/") +USER = os.environ["HYDROXIDE_USER"] +PASS = os.environ["HYDROXIDE_BRIDGE_PASS"] +AUTH = HTTPBasicAuth(USER, PASS) + +PASSED, FAILED = [], [] + + +def check(cond, label): + (PASSED if cond else FAILED).append(label) + print((" PASS " if cond else " FAIL ") + label) + + +def req(method, path, body=None, headers=None, ctype=None): + h = dict(headers or {}) + if ctype: + h["Content-Type"] = ctype + return requests.request(method, BASE + path, auth=AUTH, headers=h, data=body, timeout=40) + + +def discover_calendar(): + body = ('' + "") + resp = req("PROPFIND", "/caldav/calendars/", body, {"Depth": "1"}, "application/xml") + for chunk in re.findall(r"]*>.*?", resp.text, re.S): + href = re.search(r"([^<]+)", chunk).group(1) + if "' + '') + resp = req("PROPFIND", cal, body, {"Depth": "1"}, "application/xml") + hrefs = re.findall(r"<(?:[a-z]+:)?href>([^<]+\.ics)", resp.text) + return resp.status_code, hrefs, resp.text + + +def query_time_range(cal, start, end): + body = (f'' + f"" + f'' + f'' + f"") + resp = req("REPORT", cal, body, {"Depth": "1"}, "application/xml") + return resp.status_code, [u.strip() for u in re.findall(r"UID:([^\r\n&]+)", resp.text)] + + +def cleanup(cal): + for h in listobjs(cal)[1]: + et = req("GET", h).headers.get("ETag") + req("DELETE", h, None, {"If-Match": et} if et else {}) + + +def main(): + cal = discover_calendar() + print("calendar:", cal) + cleanup(cal) + + uid = "hydroxide-e2e-" + uuid.uuid4().hex[:10] + ics = ("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//hydroxide-e2e//EN\r\n" + f"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20260604T120000Z\r\n" + "DTSTART:20260610T150000Z\r\nDTEND:20260610T160000Z\r\n" + "SUMMARY:Hydroxide CalDAV test event\r\nDESCRIPTION:created by e2e\r\n" + "LOCATION:Test Lab\r\nSEQUENCE:0\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n") + + print("== CREATE ==") + check(req("PUT", cal + "/" + uid + ".ics", ics, {"If-None-Match": "*"}, + "text/calendar").status_code in (201, 204), "CREATE returns 201/204") + + print("== READ (PROPFIND Depth:1) ==") + sc, hrefs, body = listobjs(cal) + check(sc == 207, "PROPFIND returns 207") + check("Hydroxide CalDAV test event" in body, "SUMMARY round-trips") + check("Test Lab" in body, "LOCATION round-trips") + href = next(h for h in hrefs if uid in req("GET", h).text) + + print("== GET (single object) ==") + g = req("GET", href) + check(g.status_code == 200 and uid in g.text, "GET returns event with our UID") + + print("== TIME-RANGE QUERY ==") + sc, uids = query_time_range(cal, "20260101T000000Z", "20270101T000000Z") + check(uid in uids, "event IN range is returned") + sc, uids = query_time_range(cal, "20250101T000000Z", "20250201T000000Z") + check(uid not in uids, "event OUT of range is excluded") + + print("== UPDATE (bump SEQUENCE) ==") + et = g.headers.get("ETag") + ics_u = (ics.replace("test event", "test event [UPDATED]") + .replace("SEQUENCE:0", "SEQUENCE:1") + .replace("Test Lab", "Updated Room")) + check(req("PUT", href, ics_u, {"If-Match": et} if et else {}, + "text/calendar").status_code in (200, 201, 204), "UPDATE returns 2xx") + sc, hrefs, body = listobjs(cal) + check("[UPDATED]" in body, "updated SUMMARY reflected") + check("Updated Room" in body, "updated LOCATION reflected") + + print("== DELETE ==") + href2 = next(h for h in hrefs if "[UPDATED]" in req("GET", h).text) + de = req("GET", href2).headers.get("ETag") + check(req("DELETE", href2, None, {"If-Match": de} if de else {}).status_code in (200, 204), + "DELETE returns 200/204") + check("[UPDATED]" not in listobjs(cal)[2], "event gone after delete") + + print(f"\n{len(PASSED)}/{len(PASSED) + len(FAILED)} passed") + if FAILED: + for f in FAILED: + print(" FAILED:", f) + sys.exit(1) + print("ALL PASS") + + +if __name__ == "__main__": + main() From 56042f17095782dcb2fe1dbdc06921275ded3272 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 4 Jun 2026 11:40:23 -0400 Subject: [PATCH 2/4] caldav: drain events channel to avoid deadlocking the shared receiver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CalDAV backend registered an events channel but never read it (empty receiveEvents stub). events.Receiver dispatches each event to every registered channel while holding its lock, over UNBUFFERED channels — so the first real Proton event blocked the dispatcher forever with the lock held. That in turn deadlocked the next events.Manager.Register call, i.e. the first CardDAV request for the same account (manifesting as CardDAV PROPFINDs hanging indefinitely once any calendar/mail event had fired). Drain the channel so the shared receiver stays healthy. --- caldav/caldav.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/caldav/caldav.go b/caldav/caldav.go index 5d33f3da..0b24e249 100644 --- a/caldav/caldav.go +++ b/caldav/caldav.go @@ -26,7 +26,15 @@ type backend struct { } func (b *backend) receiveEvents(events <-chan *protonmail.Event) { - // TODO + // We don't act on calendar events yet, but we MUST drain the channel: + // events.Receiver dispatches to every registered channel while holding its + // lock, over unbuffered channels. If this channel is never read, the first + // real event blocks the dispatcher forever with the lock held, which in + // turn deadlocks any later events.Manager.Register call (e.g. the first + // CardDAV request for the same account). Drain to keep the shared receiver + // healthy. TODO: invalidate calendar caches based on the event. + for range events { + } } func readEventCard(event *ical.Event, eventCard protonmail.CalendarEventCard, userKr openpgp.KeyRing, calKr openpgp.KeyRing, keyPacket string) (ical.Props, error) { From 8c87f322eb9d7b8c14c06a5873de81ceb3beb41a Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 4 Jun 2026 12:05:28 -0400 Subject: [PATCH 3/4] caldav: support event attendees (invitations + read-back) Implements the PR's biggest deferred TODO. Outgoing: parse ATTENDEE props, derive the Proton attendee token (SHA1(UID+normalized email)), build an encrypted attendee card with the event's shared session key, populate the Attendees clear list, and wrap the shared session key to each internal Proton attendee's address key (AddedProtonAttendees) so they can decrypt the event. Read-back: decode the AttendeesEvents card so clients see invitees + RSVP status (and preserve multiple ATTENDEE props on merge). Refactors makeUpdateData to generate the shared session key once up front so the shared part and attendee card share it. Adds a unit test pinning the token derivation. --- caldav/caldav.go | 25 ++++- protonmail/calendar.go | 191 +++++++++++++++++++++++++++++++++--- protonmail/calendar_test.go | 29 ++++++ 3 files changed, 227 insertions(+), 18 deletions(-) diff --git a/caldav/caldav.go b/caldav/caldav.go index 0b24e249..d39f486a 100644 --- a/caldav/caldav.go +++ b/caldav/caldav.go @@ -71,9 +71,16 @@ func readEventCard(event *ical.Event, eventCard protonmail.CalendarEventCard, us } decodedEvent := &children[0] - for _, props := range decodedEvent.Props { - for _, p := range props { - event.Props.Set(&p) + for name, props := range decodedEvent.Props { + for i := range props { + p := props[i] + // ATTENDEE repeats (one per invitee); Set would collapse them to + // one, so Add those and Set the single-valued props. + if strings.EqualFold(name, ical.PropAttendee) { + event.Props.Add(&p) + } else { + event.Props.Set(&p) + } } } @@ -104,6 +111,18 @@ func toIcalCalendar(event *protonmail.CalendarEvent, userKr openpgp.KeyRing, cal } } + // Attendees are stored in their own card, encrypted with the shared session + // key (same key packet as the shared part), so clients see invitees + RSVP. + for _, card := range event.AttendeesEvents { + if propsMap, err := readEventCard(merged, card, userKr, calKr, event.SharedKeyPacket); err != nil { + return nil, fmt.Errorf("caldav/toIcalCalendar: error reading attendees event card: (%w)", err) + } else { + for name, _ := range propsMap { + calProps.Set(propsMap.Get(name)) + } + } + } + for _, notification := range event.Notifications { alarm := ical.NewComponent(ical.CompAlarm) diff --git a/protonmail/calendar.go b/protonmail/calendar.go index d6f757ee..022c11f7 100644 --- a/protonmail/calendar.go +++ b/protonmail/calendar.go @@ -2,7 +2,9 @@ package protonmail import ( "bytes" + "crypto/sha1" "encoding/base64" + "encoding/hex" "errors" "fmt" "github.com/ProtonMail/go-crypto/openpgp" @@ -419,7 +421,41 @@ type CalendarEventCreateOrUpdateData struct { SharedEventContent []CalendarEventCard `json:",omitempty"` Permissions int `json:",omitempty"` IsOrganizer int `json:",omitempty"` - // AttendeesEventContent, AddedProtonAttendees, Attendees, CancelledOccurrenceContent, IsPersonalSingleEdit, RemovedAttendeeAddresses ... + + // Attendee invitation fields. AttendeesEventContent is the attendee card + // (ATTENDEE props with per-attendee tokens), encrypted with the shared + // session key. Attendees is the clear list (token + RSVP status). + // AddedProtonAttendees carries the shared session key wrapped to each + // internal (Proton) attendee's address key so they can decrypt the event. + AttendeesEventContent []CalendarEventCard `json:",omitempty"` + Attendees []CalendarAttendee `json:",omitempty"` + AddedProtonAttendees []ProtonAttendee `json:",omitempty"` + // CancelledOccurrenceContent, IsPersonalSingleEdit, RemovedAttendeeAddresses ... +} + +// Proton attendee RSVP status (ATTENDEE_STATUS_API). +const ( + AttendeeStatusNeedsAction = 0 + AttendeeStatusTentative = 1 + AttendeeStatusAccepted = 2 + AttendeeStatusDeclined = 3 +) + +type CalendarAttendee struct { + Token string + Status int +} + +type ProtonAttendee struct { + Email string + AddressKeyPacket string +} + +// attendeeToken derives the Proton attendee token: SHA1(UID + normalized email) +// as lowercase hex (40 chars). Mirrors WebClients generateAttendeeToken. +func attendeeToken(uid, email string) string { + sum := sha1.Sum([]byte(uid + strings.ToLower(strings.TrimSpace(email)))) + return hex.EncodeToString(sum[:]) } type CalendarEventSyncReq struct { @@ -481,12 +517,19 @@ var requiredSet = map[string]struct{}{ } var personalEncryptedFields = []string{}*/ +// attendeeFields are handled separately (own card + tokens), so they are listed +// in usedFields purely so getEventParts strips them instead of leaking them as +// raw props into the shared encrypted part. +var attendeeFields = []string{"attendee"} + var usedFields = concat([][]string{ sharedSignedFields, sharedEncryptedFields, calendarSignedFields, calendarEncryptedFields, + + attendeeFields, /* personalSignedFields, personalEncryptedFields,*/ @@ -734,6 +777,28 @@ func makeUpdateData(c *Client, calID string, oldEvent *CalendarEvent, event ical return nil, "", fmt.Errorf("makeUpdateData: failed to decrypt keyring: (%w)", err) } + config := &packet.Config{} + + // Capture event UID and attendees before getEventParts strips them; tokens + // are derived from the UID and attendees get their own encrypted card. + eventUID := "" + if p := event.Props.Get(ical.PropUID); p != nil { + eventUID = p.Value + } + attendeeProps := event.Props.Values(ical.PropAttendee) + + // Generate (or reuse) the shared session key once, up front: both the shared + // encrypted part and the attendees card are encrypted with it, and it's what + // gets wrapped to each Proton attendee's key. + sharedKeyPacket := "" + if oldEvent != nil { + sharedKeyPacket = oldEvent.SharedKeyPacket + } + sharedSessionKey, encryptedSharedSessionKey, err := getOrGenerateSessionKey(sharedKeyPacket, calKr, config) + if err != nil { + return nil, "", fmt.Errorf("makeUpdateData: failed to get or generate shared session key: (%w)", err) + } + sharedPartCal, calendarPartCal := getEventParts(&event) sharedPart, err := encodePart(sharedPartCal) if err != nil { @@ -744,7 +809,6 @@ func makeUpdateData(c *Client, calID string, oldEvent *CalendarEvent, event ical return nil, "", fmt.Errorf("makeUpdateData: failed to encode calendar part: (%w)", err) } - config := &packet.Config{} data := CalendarEventCreateOrUpdateData{} data.Permissions = 1 @@ -796,15 +860,6 @@ func makeUpdateData(c *Client, calID string, oldEvent *CalendarEvent, event ical data.SharedEventContent = append(data.SharedEventContent, card) } if encryptedSharedPart, ok := sharedPart[CalendarEventCardEncryptedAndSigned]; ok && encryptedSharedPart != "" { - sharedKeyPacket := "" - if oldEvent != nil { - sharedKeyPacket = oldEvent.SharedKeyPacket - } - sharedSessionKey, encryptedSharedSessionKey, err := getOrGenerateSessionKey(sharedKeyPacket, calKr, config) - if err != nil { - return nil, "", fmt.Errorf("makeUpdateData: failed to get or generate session key for shared part: (%w)", err) - } - if isCreate || sharedKeyPacket == "" { data.SharedKeyPacket = encryptedSharedSessionKey } @@ -875,10 +930,22 @@ func makeUpdateData(c *Client, calID string, oldEvent *CalendarEvent, event ical data.CalendarEventContent = append(data.CalendarEventContent, card) } - // Attendees encrypted and clear parts ... - // Removed attendees emails ... - // Attendees encrypted session keys ... - // Cancelled occurrence parts ... + // Attendees: encrypted attendee card + clear RSVP list + per-attendee + // session-key packets (so internal Proton attendees can decrypt the event). + if len(attendeeProps) > 0 { + attCard, attendees, protonAttendees, err := c.buildAttendees(eventUID, attendeeProps, sharedSessionKey, userKeys, config) + if err != nil { + return nil, "", fmt.Errorf("makeUpdateData: failed to build attendees: (%w)", err) + } + if attCard != nil { + data.AttendeesEventContent = append(data.AttendeesEventContent, *attCard) + } + data.Attendees = attendees + if len(protonAttendees) > 0 { + data.AddedProtonAttendees = protonAttendees + } + } + // Removed attendees emails / cancelled occurrence parts: not handled yet. member, err := FindMemberViewFromKeyring(bootstrap.Members, userKr) if err != nil { @@ -888,6 +955,100 @@ func makeUpdateData(c *Client, calID string, oldEvent *CalendarEvent, event ical return &data, member.ID, nil } +// attendeeEmail extracts the bare email address from an ATTENDEE property value +// ("mailto:user@host", case-insensitive) or the raw value. +func attendeeEmail(prop ical.Prop) string { + v := strings.TrimSpace(prop.Value) + if len(v) >= 7 && strings.EqualFold(v[:7], "mailto:") { + v = v[7:] + } + return strings.TrimSpace(v) +} + +// buildAttendees produces the attendee card (ATTENDEE props with per-attendee +// Proton tokens, encrypted with the event's shared session key), the clear RSVP +// list, and the per-attendee session-key packets. For each attendee that has a +// published Proton key, the shared session key is wrapped to that key so the +// attendee can decrypt the event; attendees without a Proton key are still +// recorded (Proton emails them an iCal invitation). +func (c *Client) buildAttendees(uid string, attendeeProps []ical.Prop, sharedSessionKey *packet.EncryptedKey, signer *openpgp.Entity, config *packet.Config) (*CalendarEventCard, []CalendarAttendee, []ProtonAttendee, error) { + if sharedSessionKey == nil { + return nil, nil, nil, fmt.Errorf("buildAttendees: shared session key is required for attendees") + } + + attEvent := ical.NewEvent() + if uid != "" { + attEvent.Props.SetText(ical.PropUID, uid) + } + attEvent.Props.SetDateTime(ical.PropDateTimeStamp, config.Now()) + + attendees := make([]CalendarAttendee, 0, len(attendeeProps)) + var protonAttendees []ProtonAttendee + + for _, prop := range attendeeProps { + email := attendeeEmail(prop) + if email == "" { + continue + } + token := attendeeToken(uid, email) + + // Clone the property (and its params map) before adding the token, so we + // never mutate the caller's data. + params := make(ical.Params, len(prop.Params)+1) + for k, v := range prop.Params { + params[k] = append([]string(nil), v...) + } + params.Set("X-PM-TOKEN", token) + attEvent.Props.Add(&ical.Prop{Name: ical.PropAttendee, Params: params, Value: prop.Value}) + + attendees = append(attendees, CalendarAttendee{Token: token, Status: AttendeeStatusNeedsAction}) + + keyResp, err := c.GetPublicKeys(email) + if err != nil || keyResp.RecipientType != RecipientInternal || len(keyResp.Keys) == 0 { + continue // external attendee — no key packet, invitation goes by email + } + entity, err := keyResp.Keys[0].Entity() + if err != nil { + continue + } + encKey, ok := encryptionKey(entity, config.Now()) + if !ok { + continue + } + keyPacket, err := serializeEncryptedKey(sharedSessionKey, encKey.PublicKey, config) + if err != nil { + return nil, nil, nil, fmt.Errorf("buildAttendees: failed to wrap session key for %s: (%w)", email, err) + } + protonAttendees = append(protonAttendees, ProtonAttendee{Email: email, AddressKeyPacket: keyPacket}) + } + + if len(attendees) == 0 { + return nil, nil, nil, nil + } + + attCal := makeIcal(nil, attEvent.Component) + buf := new(bytes.Buffer) + if err := ical.NewEncoder(buf).Encode(attCal); err != nil { + return nil, nil, nil, fmt.Errorf("buildAttendees: failed to encode attendee card: (%w)", err) + } + plaintext := buf.String() + + signature, err := signPart(plaintext, signer, config) + if err != nil { + return nil, nil, nil, fmt.Errorf("buildAttendees: failed to sign attendee card: (%w)", err) + } + encryptedData, err := encryptPart(plaintext, sharedSessionKey, nil, config) + if err != nil { + return nil, nil, nil, fmt.Errorf("buildAttendees: failed to encrypt attendee card: (%w)", err) + } + + return &CalendarEventCard{ + Type: CalendarEventCardEncryptedAndSigned, + Data: encryptedData, + Signature: signature, + }, attendees, protonAttendees, nil +} + // calendarEventNotFoundCode is the ProtonMail API error code returned when an // event ID does not exist (e.g. when a CalDAV client PUTs a brand new event // under a UID it picked). It is used to distinguish a create from an update. diff --git a/protonmail/calendar_test.go b/protonmail/calendar_test.go index 038ba389..31ae6027 100644 --- a/protonmail/calendar_test.go +++ b/protonmail/calendar_test.go @@ -1,11 +1,40 @@ package protonmail import ( + "crypto/sha1" + "encoding/hex" "errors" "fmt" "testing" ) +// TestAttendeeToken pins the Proton attendee-token derivation: SHA1(UID + +// lowercased/trimmed email) as 40-char hex (mirrors WebClients +// generateAttendeeToken). A wrong token means Proton can't match the invitee. +func TestAttendeeToken(t *testing.T) { + uid := "abc-123@proton.me" + want := func(email string) string { + sum := sha1.Sum([]byte(uid + email)) + return hex.EncodeToString(sum[:]) + } + + tok := attendeeToken(uid, "Friend@Example.COM") + if len(tok) != 40 { + t.Fatalf("token length = %d, want 40", len(tok)) + } + if tok != want("friend@example.com") { + t.Fatalf("token = %s, want SHA1(uid+lowercased email)", tok) + } + // Case/whitespace in the email must not change the token. + if attendeeToken(uid, " friend@example.com ") != tok { + t.Fatal("token should be invariant to surrounding whitespace and case") + } + // Different email => different token. + if attendeeToken(uid, "other@example.com") == tok { + t.Fatal("different emails must produce different tokens") + } +} + // TestIsEventNotFoundErr guards the create-vs-update detection in // UpdateCalendarEvent. GetCalendarEvent wraps its APIError with // fmt.Errorf("...%w..."), which a bare `err.(*APIError)` type assertion does From 575ee6a52f7d8bc1af6451b719bc36528e1b89f3 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 4 Jun 2026 12:30:41 -0400 Subject: [PATCH 4/4] smtp: sign attachments (Proton requires a detached signature) hydroxide never signed attachments (two TODOs), so Proton rejected any outgoing message with an attachment: [2011] One or more attachments are missing a signature. Add Attachment.Sign (binary detached signature of the plaintext, matching go-proton-api's GetBinary()) and upload it as the Signature multipart field. smtp buffers the part, signs, then encrypts. Unblocks sending attachments generally (and iMIP calendar invitations). --- protonmail/attachments.go | 26 ++++++++++++++++++++++++-- smtp/smtp.go | 12 +++++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/protonmail/attachments.go b/protonmail/attachments.go index 9722b30d..ede4628f 100644 --- a/protonmail/attachments.go +++ b/protonmail/attachments.go @@ -32,7 +32,8 @@ type Attachment struct { //Headers map[string]string Signature string - unencryptedKey *packet.EncryptedKey + unencryptedKey *packet.EncryptedKey + signaturePacket []byte // raw binary detached signature of the plaintext } // GenerateKey generates an encrypted key and encrypts it to the provided @@ -68,6 +69,18 @@ func (att *Attachment) GenerateKey(to []*openpgp.Entity) (*packet.EncryptedKey, return unencryptedKey, nil } +// Sign computes the detached signature of the plaintext attachment data that +// Proton requires on every attachment, storing the raw binary signature for +// CreateAttachment to upload. Must be called before CreateAttachment. +func (att *Attachment) Sign(data []byte, signer *openpgp.Entity) error { + var buf bytes.Buffer + if err := openpgp.DetachSign(&buf, signer, bytes.NewReader(data), &packet.Config{}); err != nil { + return err + } + att.signaturePacket = buf.Bytes() + return nil +} + // Encrypt encrypts to w the data that will be written to the returned // io.WriteCloser. // @@ -173,7 +186,16 @@ func (c *Client) CreateAttachment(att *Attachment, r io.Reader) (created *Attach return } - // TODO: Signature + // Proton requires a detached signature of the plaintext (raw binary). + if len(att.signaturePacket) > 0 { + if w, err := mw.CreateFormFile("Signature", "Signature.pgp"); err != nil { + pw.CloseWithError(err) + return + } else if _, err := w.Write(att.signaturePacket); err != nil { + pw.CloseWithError(err) + return + } + } pw.CloseWithError(mw.Close()) }() diff --git a/smtp/smtp.go b/smtp/smtp.go index 31ecb995..762e270a 100644 --- a/smtp/smtp.go +++ b/smtp/smtp.go @@ -223,6 +223,16 @@ func SendMail(c *protonmail.Client, u *protonmail.User, privateKeys openpgp.Enti return fmt.Errorf("cannot generate attachment key: %v", err) } + // Buffer the plaintext so we can produce the detached signature + // Proton requires on every attachment before encrypting it. + attData, err := io.ReadAll(p.Body) + if err != nil { + return fmt.Errorf("cannot read attachment %q: %v", filename, err) + } + if err := att.Sign(attData, privateKey); err != nil { + return fmt.Errorf("cannot sign attachment %q: %v", filename, err) + } + log.Printf("uploading message attachment %q", filename) pr, pw := io.Pipe() @@ -233,7 +243,7 @@ func SendMail(c *protonmail.Client, u *protonmail.User, privateKeys openpgp.Enti pw.CloseWithError(err) return } - if _, err := io.Copy(cleartext, p.Body); err != nil { + if _, err := io.Copy(cleartext, bytes.NewReader(attData)); err != nil { pw.CloseWithError(err) return }