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..d39f486a --- /dev/null +++ b/caldav/caldav.go @@ -0,0 +1,513 @@ +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) { + // 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) { + 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 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) + } + } + } + + 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)) + } + } + } + + // 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) + + 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/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/protonmail/calendar.go b/protonmail/calendar.go index 0dabd873..022c11f7 100644 --- a/protonmail/calendar.go +++ b/protonmail/calendar.go @@ -1,9 +1,22 @@ package protonmail import ( + "bytes" + "crypto/sha1" + "encoding/base64" + "encoding/hex" + "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 +24,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 + } +} + +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 - req, err := c.newRequest(http.MethodGet, calendarPath+"?"+v.Encode(), nil) + 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 +324,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 +376,776 @@ 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"` + + // 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 { + 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{}*/ + +// 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,*/ +}) + +// ... 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) + } + + 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 { + 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) + } + + 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 != "" { + 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 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 { + return nil, "", fmt.Errorf("makeUpdateData: failed to find member view from keyring: (%w)", err) + } + + 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. +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..31ae6027 --- /dev/null +++ b/protonmail/calendar_test.go @@ -0,0 +1,83 @@ +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 +// 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/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 } 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()