diff --git a/default.yaml b/default.yaml index f3b5cd0b..56068f02 100644 --- a/default.yaml +++ b/default.yaml @@ -922,6 +922,7 @@ fakelag: "MARKREAD": 16 "MONITOR": 1 "WHO": 4 + "WEBPUSH": 1 # the roleplay commands are semi-standardized extensions to IRC that allow # sending and receiving messages from pseudo-nicknames. this can be used either @@ -1067,3 +1068,24 @@ history: # whether to allow customization of the config at runtime using environment variables, # e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details. allow-environment-overrides: true + +# experimental support for mobile push notifications +# see the manual for potential security, privacy, and performance implications. +# DO NOT enable if you are running a Tor or I2P hidden service (i.e. one +# with no public IP listeners, only Tor/I2P listeners). +webpush: + # are push notifications enabled at all? + enabled: false + # request timeout for POST'ing the http notification + timeout: 10s + # delay sending the notification for this amount of time, then suppress it + # if the client sent MARKREAD to indicate that it was read on another device + delay: 0s + # subscriber field for the VAPID JWT authorization: + #subscriber: "https://your-website.com/" + # maximum number of push subscriptions per user + max-subscriptions: 4 + # expiration time for a push subscription; it must be renewed within this time + # by the client reconnecting to IRC. we also detect whether the client is no longer + # successfully receiving push messages. + expiration: 14d diff --git a/docs/MANUAL.md b/docs/MANUAL.md index b38e905a..a84a7a5d 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -44,6 +44,7 @@ _Copyright © Daniel Oaks , Shivaram Lingamneni , Shivaram Lingamneni 0 { + time.Sleep(pause) + } + readTimestamp, ok := client.getMarkreadTime(msg.cftarget) + return ok && utils.ReadMarkerLessThanOrEqual(msg.time, readTimestamp) +} + +func (client *Client) sendAndTrackPush(endpoint string, keys webpush.Keys, msg pushMessage, updateDB bool) { + if endpoint == msg.originatingEndpoint { + return + } + if msg.cftarget != "" && !msg.time.IsZero() { + client.addClearablePushMessage(msg.cftarget, msg.time) + } + switch client.sendPush(endpoint, keys, msg.urgency, msg.msg) { + case nil: + client.recordPush(endpoint, true) + case webpush.Err404: + client.deletePushSubscription(endpoint, updateDB) + default: + client.recordPush(endpoint, false) + } +} + +func (client *Client) sendPush(endpoint string, keys webpush.Keys, urgency webpush.Urgency, msg []byte) error { + config := client.server.Config() + // final sanity check + if !config.WebPush.Enabled { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), config.WebPush.Timeout) + defer cancel() + + err := webpush.SendWebPush(ctx, endpoint, keys, config.WebPush.vapidKeys, webpush.UrgencyHigh, config.WebPush.Subscriber, msg) + if err == nil { + client.server.logger.Debug("webpush", "dispatched push to client", client.Nick(), endpoint) + } else { + client.server.logger.Debug("webpush", "failed to dispatch push to client", client.Nick(), endpoint, err.Error()) + } + return err +} diff --git a/irc/client_lookup_set.go b/irc/client_lookup_set.go index ea9c88b7..80c2d148 100644 --- a/irc/client_lookup_set.go +++ b/irc/client_lookup_set.go @@ -253,15 +253,14 @@ func (clients *ClientManager) AllClients() (result []*Client) { return } -// AllWithCapsNotify returns all clients with the given capabilities, and that support cap-notify. -func (clients *ClientManager) AllWithCapsNotify(capabs ...caps.Capability) (sessions []*Session) { - capabs = append(capabs, caps.CapNotify) +// AllWithCapsNotify returns all sessions that support cap-notify. +func (clients *ClientManager) AllWithCapsNotify() (sessions []*Session) { clients.RLock() defer clients.RUnlock() for _, client := range clients.byNick { for _, session := range client.Sessions() { // cap-notify is implicit in cap version 302 and above - if session.capabilities.HasAll(capabs...) || 302 <= session.capVersion { + if session.capabilities.Has(caps.CapNotify) || 302 <= session.capVersion { sessions = append(sessions, session) } } @@ -270,6 +269,18 @@ func (clients *ClientManager) AllWithCapsNotify(capabs ...caps.Capability) (sess return } +// AllWithPushSubscriptions returns all clients that are always-on with an active push subscription. +func (clients *ClientManager) AllWithPushSubscriptions() (result []*Client) { + clients.RLock() + defer clients.RUnlock() + for _, client := range clients.byNick { + if client.hasPushSubscriptions() && client.AlwaysOn() { + result = append(result, client) + } + } + return result +} + // FindAll returns all clients that match the given userhost mask. func (clients *ClientManager) FindAll(userhost string) (set ClientSet) { set = make(ClientSet) diff --git a/irc/commands.go b/irc/commands.go index 4bd88dd7..01c51df2 100644 --- a/irc/commands.go +++ b/irc/commands.go @@ -367,6 +367,10 @@ func init() { usablePreReg: true, minParams: 4, }, + "WEBPUSH": { + handler: webpushHandler, + minParams: 2, + }, "WHO": { handler: whoHandler, minParams: 1, diff --git a/irc/config.go b/irc/config.go index 347deab2..4bbde6ee 100644 --- a/irc/config.go +++ b/irc/config.go @@ -41,6 +41,7 @@ import ( "github.com/ergochat/ergo/irc/oauth2" "github.com/ergochat/ergo/irc/passwd" "github.com/ergochat/ergo/irc/utils" + "github.com/ergochat/ergo/irc/webpush" ) // here's how this works: exported (capitalized) members of the config structs @@ -708,6 +709,16 @@ type Config struct { } `yaml:"tagmsg-storage"` } + WebPush struct { + Enabled bool + Timeout time.Duration + Delay time.Duration + Subscriber string + MaxSubscriptions int `yaml:"max-subscriptions"` + Expiration custime.Duration + vapidKeys *webpush.VAPIDKeys + } `yaml:"webpush"` + Filename string } @@ -1572,6 +1583,29 @@ func LoadConfig(filename string) (config *Config, err error) { return nil, err } + if config.WebPush.Enabled { + if config.Accounts.Multiclient.AlwaysOn == PersistentDisabled { + return nil, fmt.Errorf("Cannot enable webpush if always-on is disabled") + } + if config.WebPush.Timeout == 0 { + config.WebPush.Timeout = 10 * time.Second + } + if config.WebPush.Subscriber == "" { + config.WebPush.Subscriber = "https://ergo.chat/about" + } + if config.WebPush.MaxSubscriptions <= 0 { + config.WebPush.MaxSubscriptions = 1 + } + if config.WebPush.Expiration == 0 { + config.WebPush.Expiration = custime.Duration(14 * 24 * time.Hour) + } else if config.WebPush.Expiration < custime.Duration(3*24*time.Hour) { + return nil, fmt.Errorf("webpush.expiration is too short (should be several days)") + } + } else { + config.Server.supportedCaps.Disable(caps.WebPush) + config.Server.supportedCaps.Disable(caps.SojuWebPush) + } + // now that all postprocessing is complete, regenerate ISUPPORT: err = config.generateISupport() if err != nil { @@ -1666,6 +1700,13 @@ func (config *Config) generateISupport() (err error) { if config.Server.EnforceUtf8 { isupport.Add("UTF8ONLY", "") } + if config.WebPush.Enabled { + // XXX we typically don't have this at config parse time, so we'll have to regenerate + // the cached reply later + if config.WebPush.vapidKeys != nil { + isupport.Add("VAPID", config.WebPush.vapidKeys.PublicKeyString()) + } + } isupport.Add("WHOX", "") err = isupport.RegenerateCachedReply() diff --git a/irc/database.go b/irc/database.go index a815e483..b144c74b 100644 --- a/irc/database.go +++ b/irc/database.go @@ -18,6 +18,7 @@ import ( "github.com/ergochat/ergo/irc/datastore" "github.com/ergochat/ergo/irc/modes" "github.com/ergochat/ergo/irc/utils" + "github.com/ergochat/ergo/irc/webpush" "github.com/tidwall/buntdb" ) @@ -27,15 +28,17 @@ const ( // 'version' of the database schema // latest schema of the db - latestDbSchema = 23 + latestDbSchema = 24 ) var ( schemaVersionUUID = utils.UUID{0, 255, 85, 13, 212, 10, 191, 121, 245, 152, 142, 89, 97, 141, 219, 87} // AP9VDdQKv3n1mI5ZYY3bVw cloakSecretUUID = utils.UUID{170, 214, 184, 208, 116, 181, 67, 75, 161, 23, 233, 16, 113, 251, 94, 229} // qta40HS1Q0uhF-kQcfte5Q + vapidKeysUUID = utils.UUID{87, 215, 189, 5, 65, 105, 249, 44, 65, 96, 170, 56, 187, 110, 12, 235} // V9e9BUFp-SxBYKo4u24M6w keySchemaVersion = bunt.BuntKey(datastore.TableMetadata, schemaVersionUUID) keyCloakSecret = bunt.BuntKey(datastore.TableMetadata, cloakSecretUUID) + keyVAPIDKeys = bunt.BuntKey(datastore.TableMetadata, vapidKeysUUID) ) type SchemaChanger func(*Config, *buntdb.Tx) error @@ -80,6 +83,15 @@ func initializeDB(path string) error { // set schema version tx.Set(keySchemaVersion, strconv.Itoa(latestDbSchema), nil) tx.Set(keyCloakSecret, utils.GenerateSecretKey(), nil) + vapidKeys, err := webpush.GenerateVAPIDKeys() + if err != nil { + return err + } + j, err := json.Marshal(vapidKeys) + if err != nil { + return err + } + tx.Set(keyVAPIDKeys, string(j), nil) return nil }) @@ -233,6 +245,16 @@ func StoreCloakSecret(dstore datastore.Datastore, secret string) { dstore.Set(datastore.TableMetadata, cloakSecretUUID, []byte(secret), time.Time{}) } +func LoadVAPIDKeys(dstore datastore.Datastore) (*webpush.VAPIDKeys, error) { + val, err := dstore.Get(datastore.TableMetadata, vapidKeysUUID) + if err != nil { + return nil, err + } + result := new(webpush.VAPIDKeys) + err = json.Unmarshal([]byte(val), result) + return result, nil +} + func schemaChangeV1toV2(config *Config, tx *buntdb.Tx) error { // == version 1 -> 2 == // account key changes and account.verified key bugfix. @@ -1218,6 +1240,20 @@ func schemaChangeV22ToV23(config *Config, tx *buntdb.Tx) error { return nil } +// webpush signing key +func schemaChangeV23ToV24(config *Config, tx *buntdb.Tx) error { + keys, err := webpush.GenerateVAPIDKeys() + if err != nil { + return err + } + j, err := json.Marshal(keys) + if err != nil { + return err + } + tx.Set(keyVAPIDKeys, string(j), nil) + return nil +} + func getSchemaChange(initialVersion int) (result SchemaChange, ok bool) { for _, change := range allChanges { if initialVersion == change.InitialVersion { @@ -1338,4 +1374,9 @@ var allChanges = []SchemaChange{ TargetVersion: 23, Changer: schemaChangeV22ToV23, }, + { + InitialVersion: 23, + TargetVersion: 24, + Changer: schemaChangeV23ToV24, + }, } diff --git a/irc/getters.go b/irc/getters.go index c64532a3..5db89ab5 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -13,6 +13,7 @@ import ( "github.com/ergochat/ergo/irc/languages" "github.com/ergochat/ergo/irc/modes" "github.com/ergochat/ergo/irc/utils" + "github.com/ergochat/ergo/irc/webpush" ) func (server *Server) Config() (config *Config) { @@ -511,6 +512,13 @@ func (client *Client) GetReadMarker(cfname string) (result string) { return "*" } +func (client *Client) getMarkreadTime(cfname string) (timestamp time.Time, ok bool) { + client.stateMutex.RLock() + timestamp, ok = client.readMarkers[cfname] + client.stateMutex.RUnlock() + return +} + func (client *Client) copyReadMarkers() (result map[string]time.Time) { client.stateMutex.RLock() defer client.stateMutex.RUnlock() @@ -549,6 +557,28 @@ func updateLRUMap(lru map[string]time.Time, key string, val time.Time, maxItems return val } +func (client *Client) addClearablePushMessage(cftarget string, messageTime time.Time) { + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + + if client.clearablePushMessages == nil { + client.clearablePushMessages = make(map[string]time.Time) + } + updateLRUMap(client.clearablePushMessages, cftarget, messageTime, maxReadMarkers) +} + +func (client *Client) clearClearablePushMessage(cftarget string, readTimestamp time.Time) (ok bool) { + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + + pushMessageTime, ok := client.clearablePushMessages[cftarget] + if ok && utils.ReadMarkerLessThanOrEqual(pushMessageTime, readTimestamp) { + delete(client.clearablePushMessages, cftarget) + return true + } + return false +} + func (client *Client) shouldFlushTimestamps() (result bool) { client.stateMutex.Lock() defer client.stateMutex.Unlock() @@ -564,6 +594,127 @@ func (client *Client) setKlined() { client.stateMutex.Unlock() } +func (client *Client) refreshPushSubscription(endpoint string, keys webpush.Keys) bool { + // do not mark dirty --- defer the write to periodic maintenance + now := time.Now().UTC() + + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + + sub, ok := client.pushSubscriptions[endpoint] + if ok && sub.Keys.Equal(keys) { + sub.LastRefresh = now + return true + } + return false // subscription doesn't exist, we need to send a test message +} + +func (client *Client) addPushSubscription(endpoint string, keys webpush.Keys) error { + changed := false + + defer func() { + if changed { + client.markDirty(IncludeAllAttrs) + } + }() + + config := client.server.Config() + now := time.Now().UTC() + + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + + if client.pushSubscriptions == nil { + client.pushSubscriptions = make(map[string]*pushSubscription) + } + + sub, ok := client.pushSubscriptions[endpoint] + if ok { + changed = !sub.Keys.Equal(keys) + sub.Keys = keys + sub.LastRefresh = now + } else { + if len(client.pushSubscriptions) >= config.WebPush.MaxSubscriptions { + return errLimitExceeded + } + changed = true + sub = newPushSubscription(storedPushSubscription{ + Endpoint: endpoint, + Keys: keys, + LastRefresh: now, + LastSuccess: now, // assume we just sent a successful message to confirm the sub + }) + client.pushSubscriptions[endpoint] = sub + } + + if changed { + client.rebuildPushSubscriptionCache() + } + + return nil +} + +func (client *Client) hasPushSubscriptions() bool { + return client.pushSubscriptionsExist.Load() != 0 +} + +func (client *Client) getPushSubscriptions() []storedPushSubscription { + client.stateMutex.RLock() + defer client.stateMutex.RUnlock() + + return client.cachedPushSubscriptions +} + +func (client *Client) rebuildPushSubscriptionCache() { + // must hold write lock + if len(client.pushSubscriptions) == 0 { + client.cachedPushSubscriptions = nil + client.pushSubscriptionsExist.Store(0) + return + } + + client.cachedPushSubscriptions = make([]storedPushSubscription, 0, len(client.pushSubscriptions)) + for _, subscription := range client.pushSubscriptions { + client.cachedPushSubscriptions = append(client.cachedPushSubscriptions, subscription.storedPushSubscription) + } + client.pushSubscriptionsExist.Store(1) +} + +func (client *Client) deletePushSubscription(endpoint string, writeback bool) (changed bool) { + defer func() { + if writeback && changed { + client.markDirty(IncludeAllAttrs) + } + }() + + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + + _, ok := client.pushSubscriptions[endpoint] + if ok { + changed = true + delete(client.pushSubscriptions, endpoint) + client.rebuildPushSubscriptionCache() + } + return +} + +func (client *Client) recordPush(endpoint string, success bool) { + now := time.Now().UTC() + + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + + subscription, ok := client.pushSubscriptions[endpoint] + if !ok { + return + } + if success { + subscription.LastSuccess = now + } + // TODO we may want to track failures in some way in the future +} + func (channel *Channel) Name() string { channel.stateMutex.RLock() defer channel.stateMutex.RUnlock() diff --git a/irc/handlers.go b/irc/handlers.go index 17fd905f..24db3eac 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -33,6 +33,7 @@ import ( "github.com/ergochat/ergo/irc/oauth2" "github.com/ergochat/ergo/irc/sno" "github.com/ergochat/ergo/irc/utils" + "github.com/ergochat/ergo/irc/webpush" ) // helper function to parse ACC callbacks, e.g., mailto:person@example.com, tel:16505551234 @@ -2465,6 +2466,20 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi Tags: tags, } client.addHistoryItem(user, item, &details, &tDetails, config) + + if config.WebPush.Enabled && histType != history.Tagmsg && user.hasPushSubscriptions() && client != user { + pushMsgBytes, err := webpush.MakePushMessage(command, nickMaskString, accountName, tnick, message) + if err == nil { + user.dispatchPushMessage(pushMessage{ + msg: pushMsgBytes, + urgency: webpush.UrgencyHigh, + cftarget: details.nickCasefolded, + time: message.Time, + }) + } else { + server.logger.Error("internal", "can't serialize push message", err.Error()) + } + } } } @@ -3049,6 +3064,18 @@ func markReadHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res session.Send(nil, server.name, "MARKREAD", unfoldedTarget, readTimestamp) } } + if client.clearClearablePushMessage(cftarget, readTime) { + line, err := webpush.MakePushLine(time.Now().UTC(), "*", server.name, "MARKREAD", unfoldedTarget, readTimestamp) + if err == nil { + client.dispatchPushMessage(pushMessage{ + msg: line, + originatingEndpoint: rb.session.webPushEndpoint, + urgency: webpush.UrgencyNormal, // copied from soju + }) + } else { + server.logger.Error("internal", "couldn't serialize MARKREAD push message", err.Error()) + } + } } return } @@ -3590,6 +3617,88 @@ func webircHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo return true } +// WEBPUSH [key] +func webpushHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) bool { + subcommand := strings.ToUpper(msg.Params[0]) + + config := server.Config() + if !config.WebPush.Enabled { + rb.Add(nil, server.name, "FAIL", "WEBPUSH", "FORBIDDEN", subcommand, client.t("Web push is disabled")) + return false + } + + if client.Account() == "" { + rb.Add(nil, server.name, "FAIL", "WEBPUSH", "FORBIDDEN", subcommand, client.t("You must be logged in to receive push messages")) + return false + } + + // XXX web push can be used to deanonymize a Tor hidden service, but we do not know + // whether an Ergo deployment with a Tor listener is intended to run as a hidden + // service, or as a single onion service where Tor is optional. Hidden service operators + // should disable web push. However, as a sanity check, disallow enabling it over a Tor + // connection: + if rb.session.isTor { + rb.Add(nil, server.name, "FAIL", "WEBPUSH", "FORBIDDEN", subcommand, client.t("Web push cannot be enabled over Tor")) + return false + } + + endpoint := msg.Params[1] + + if err := webpush.SanityCheckWebPushEndpoint(endpoint); err != nil { + rb.Add(nil, server.name, "FAIL", "WEBPUSH", "INVALID_PARAMS", subcommand, client.t("Invalid web push URL")) + } + + switch subcommand { + case "REGISTER": + // allow web push enable even if they are not always-on (they just won't get push messages) + if len(msg.Params) < 3 { + rb.Add(nil, server.name, "FAIL", "WEBPUSH", "INVALID_PARAMS", subcommand, client.t("Insufficient parameters for WEBPUSH REGISTER")) + return false + } + keys, err := webpush.DecodeSubscriptionKeys(msg.Params[2]) + if err != nil { + rb.Add(nil, server.name, "FAIL", "WEBPUSH", "INVALID_PARAMS", subcommand, client.t("Invalid subscription keys for WEBPUSH REGISTER")) + return false + } + if client.refreshPushSubscription(endpoint, keys) { + // success, don't send a test message + rb.Add(nil, server.name, "WEBPUSH", "REGISTER", msg.Params[1], msg.Params[2]) + rb.session.webPushEndpoint = endpoint + return false + } + // send a test message + if err := client.sendPush( + endpoint, + keys, + webpush.UrgencyHigh, + webpush.PingMessage, + ); err == nil { + if err := client.addPushSubscription(endpoint, keys); err == nil { + rb.Add(nil, server.name, "WEBPUSH", "REGISTER", msg.Params[1], msg.Params[2]) + rb.session.webPushEndpoint = endpoint + if !client.AlwaysOn() { + rb.Add(nil, server.name, "WARN", "WEBPUSH", "PERSISTENCE_REQUIRED", client.t("You have enabled push notifications, but you will not receive them unless you become always-on. Try: /msg nickserv set always-on true")) + } + } else if err == errLimitExceeded { + rb.Add(nil, server.name, "FAIL", "WEBPUSH", "FORBIDDEN", "REGISTER", client.t("You have too many push subscriptions already")) + } else { + server.logger.Error("webpush", "Failed to add webpush subscription", err.Error()) + rb.Add(nil, server.name, "FAIL", "WEBPUSH", "INTERNAL_ERROR", "REGISTER", client.t("An error occurred")) + } + } else { + server.logger.Debug("webpush", "WEBPUSH REGISTER failed validation", endpoint, err.Error()) + rb.Add(nil, server.name, "FAIL", "WEBPUSH", "INVALID_PARAMS", "REGISTER", client.t("Test push message failed to send")) + } + case "UNREGISTER": + client.deletePushSubscription(endpoint, true) + rb.session.webPushEndpoint = "" + // this always succeeds + rb.Add(nil, server.name, "WEBPUSH", "UNREGISTER", endpoint) + } + + return false +} + type whoxFields uint32 // bitset to hold the WHOX field values, 'a' through 'z' func (fields whoxFields) Add(field rune) (result whoxFields) { diff --git a/irc/help.go b/irc/help.go index a11bb725..6a6824a0 100644 --- a/irc/help.go +++ b/irc/help.go @@ -610,6 +610,11 @@ ircv3.net/specs/extensions/webirc.html the connection from the client to the gateway, such as: - tls: this flag indicates that the client->gateway connection is secure`, + }, + "webpush": { + text: `WEBPUSH [arguments] + +Configures web push settings. Not for direct use by end users.`, }, "who": { text: `WHO [o] diff --git a/irc/histserv.go b/irc/histserv.go index f0cb2285..8e28dafc 100644 --- a/irc/histserv.go +++ b/irc/histserv.go @@ -177,7 +177,7 @@ func histservExportHandler(service *ircService, server *Server, client *Client, } func histservExportAndNotify(service *ircService, server *Server, cfAccount string, outfile *os.File, filename, alertNick string) { - defer server.HandlePanic() + defer server.HandlePanic(nil) defer outfile.Close() writer := bufio.NewWriter(outfile) diff --git a/irc/import.go b/irc/import.go index 1fb2da32..2f5a6dc5 100644 --- a/irc/import.go +++ b/irc/import.go @@ -17,6 +17,7 @@ import ( "github.com/ergochat/ergo/irc/datastore" "github.com/ergochat/ergo/irc/modes" "github.com/ergochat/ergo/irc/utils" + "github.com/ergochat/ergo/irc/webpush" ) const ( @@ -24,7 +25,7 @@ const ( // XXX instead of referencing, e.g., keyAccountExists, we should write in the string literal // (to ensure that no matter what code changes happen elsewhere, we're still producing a // db of the hardcoded version) - importDBSchemaVersion = 23 + importDBSchemaVersion = 24 ) type userImport struct { @@ -82,6 +83,15 @@ func doImportDBGeneric(config *Config, dbImport databaseImport, credsType Creden tx.Set(keySchemaVersion, strconv.Itoa(importDBSchemaVersion), nil) tx.Set(keyCloakSecret, utils.GenerateSecretKey(), nil) + vapidKeys, err := webpush.GenerateVAPIDKeys() + if err != nil { + return err + } + vapidKeysJSON, err := json.Marshal(vapidKeys) + if err != nil { + return err + } + tx.Set(keyVAPIDKeys, string(vapidKeysJSON), nil) cfUsernames := make(utils.HashSet[string]) skeletonToUsername := make(map[string]string) diff --git a/irc/nickserv.go b/irc/nickserv.go index 117569b2..9a776d06 100644 --- a/irc/nickserv.go +++ b/irc/nickserv.go @@ -241,6 +241,18 @@ indicate an empty password, use * instead.`, "password": { aliasOf: "passwd", }, + "push": { + handler: nsPushHandler, + help: `Syntax: $bPUSH LIST$b +Or: $bPUSH DELETE $b + +PUSH lets you view or modify the state of your push subscriptions.`, + helpShort: `$bPUSH$b lets you view or modify your push subscriptions.`, + enabled: func(config *Config) bool { + return config.WebPush.Enabled + }, + minParams: 1, + }, "get": { handler: nsGetHandler, help: `Syntax: $bGET $b @@ -1659,3 +1671,45 @@ func nsRenameHandler(service *ircService, server *Server, client *Client, comman } } } + +func nsPushHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { + switch strings.ToUpper(params[0]) { + case "LIST": + target := client + if len(params) > 1 && client.HasRoleCapabs("accreg") { + target = server.clients.Get(params[1]) + if target == nil { + service.Notice(rb, client.t("No such nick")) + return + } + } + subscriptions := target.getPushSubscriptions() + service.Notice(rb, fmt.Sprintf(client.t("Nickname %[1]s has %[2]d push subscription(s)"), target.Nick(), len(subscriptions))) + for i, subscription := range subscriptions { + service.Notice(rb, fmt.Sprintf("%d: %s", i, subscription.Endpoint)) + } + case "DELETE": + if len(params) < 2 { + service.Notice(rb, client.t("Invalid parameters")) + return + } + target := client + endpoint := params[1] + if len(params) > 2 && client.HasRoleCapabs("accreg") { + target = server.clients.Get(params[1]) + if target == nil { + service.Notice(rb, client.t("No such nick")) + return + } + endpoint = params[2] + } + changed := target.deletePushSubscription(endpoint, true) + if changed { + service.Notice(rb, client.t("Successfully deleted push subscription")) + } else { + service.Notice(rb, client.t("Push subscription not found")) + } + default: + service.Notice(rb, client.t("Invalid parameters")) + } +} diff --git a/irc/panic.go b/irc/panic.go index ae0b92f4..4dd0ba5f 100644 --- a/irc/panic.go +++ b/irc/panic.go @@ -6,14 +6,19 @@ package irc import ( "fmt" "runtime/debug" + "time" ) // HandlePanic is a general-purpose panic handler for ad-hoc goroutines. // Because of the semantics of `recover`, it must be called directly // from the routine on whose call stack the panic would occur, with `defer`, // e.g. `defer server.HandlePanic()` -func (server *Server) HandlePanic() { +func (server *Server) HandlePanic(restartable func()) { if r := recover(); r != nil { server.logger.Error("internal", fmt.Sprintf("Panic encountered: %v\n%s", r, debug.Stack())) + if restartable != nil { + time.Sleep(time.Second) + go restartable() + } } } diff --git a/irc/server.go b/irc/server.go index 3eeac086..3f8ddbaf 100644 --- a/irc/server.go +++ b/irc/server.go @@ -36,10 +36,12 @@ import ( "github.com/ergochat/ergo/irc/mysql" "github.com/ergochat/ergo/irc/sno" "github.com/ergochat/ergo/irc/utils" + "github.com/ergochat/ergo/irc/webpush" ) const ( alwaysOnMaintenanceInterval = 30 * time.Minute + pushMaintenanceInterval = 24 * time.Hour ) var ( @@ -135,6 +137,7 @@ func NewServer(config *Config, logger *logger.Manager) (*Server, error) { } time.AfterFunc(alwaysOnMaintenanceInterval, server.periodicAlwaysOnMaintenance) + time.AfterFunc(pushMaintenanceInterval, server.periodicPushMaintenance) return server, nil } @@ -267,7 +270,7 @@ func (server *Server) periodicAlwaysOnMaintenance() { time.AfterFunc(alwaysOnMaintenanceInterval, server.periodicAlwaysOnMaintenance) }() - defer server.HandlePanic() + defer server.HandlePanic(nil) server.logger.Info("accounts", "Performing periodic always-on client checks") server.performAlwaysOnMaintenance(true, true) @@ -291,6 +294,47 @@ func (server *Server) performAlwaysOnMaintenance(checkExpiration, flushTimestamp } } +func (server *Server) periodicPushMaintenance() { + defer func() { + // reschedule whether or not there was a panic + time.AfterFunc(pushMaintenanceInterval, server.periodicPushMaintenance) + }() + + defer server.HandlePanic(nil) + + if server.Config().WebPush.Enabled { + server.logger.Info("webpush", "Performing periodic push subscription maintenance") + server.performPushMaintenance() + } // else: reschedule and check again later, the operator may enable it via rehash +} + +func (server *Server) performPushMaintenance() { + expiration := time.Duration(server.Config().WebPush.Expiration) + for _, client := range server.clients.AllWithPushSubscriptions() { + for _, sub := range client.getPushSubscriptions() { + now := time.Now() + // require both periodic successful push messages and renewal of the subscription via WEBPUSH REGISTER + if now.Sub(sub.LastSuccess) > expiration || now.Sub(sub.LastRefresh) > expiration { + server.logger.Debug("webpush", "expiring push subscription for client", client.Nick(), sub.Endpoint) + client.deletePushSubscription(sub.Endpoint, false) + } else if now.Sub(sub.LastSuccess) > expiration/2 { + // we haven't pushed to them recently, make an attempt + server.logger.Debug("webpush", "pinging push subscription for client", client.Nick(), sub.Endpoint) + client.sendAndTrackPush( + sub.Endpoint, sub.Keys, + pushMessage{ + msg: webpush.PingMessage, + urgency: webpush.UrgencyNormal, + }, + false, + ) + } + } + // persist all push subscriptions on the assumption that the timestamps have changed + client.Store(IncludePushSubscriptions) + } +} + // handles server.ip-check-script.exempt-sasl: // run the ip check script at the end of the handshake, only for anonymous connections func (server *Server) checkBanScriptExemptSASL(config *Config, session *Session) (outcome AuthOutcome) { @@ -589,7 +633,7 @@ func (client *Client) getWhoisOf(target *Client, hasPrivs bool, rb *ResponseBuff // rehash reloads the config and applies the changes from the config file. func (server *Server) rehash() error { // #1570; this needs its own panic handling because it can be invoked via SIGHUP - defer server.HandlePanic() + defer server.HandlePanic(nil) server.logger.Info("server", "Attempting rehash") @@ -743,6 +787,16 @@ func (server *Server) applyConfig(config *Config) (err error) { return fmt.Errorf("Could not load cloak secret: %w", err) } config.Server.Cloaks.SetSecret(cloakSecret) + // similarly bring the VAPID keys into the config, which requires regenerating the 005 + if config.WebPush.Enabled { + config.WebPush.vapidKeys, err = LoadVAPIDKeys(server.dstore) + if err != nil { + return fmt.Errorf("Could not load VAPID keys: %w", err) + } + if err = config.generateISupport(); err != nil { + return fmt.Errorf("Could not regenerate cached 005 for VAPID: %w", err) + } + } // activate the new config server.config.Store(config) diff --git a/irc/utils/sync.go b/irc/utils/sync.go deleted file mode 100644 index 563f6185..00000000 --- a/irc/utils/sync.go +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright 2009 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package utils - -import ( - "sync" - "sync/atomic" -) - -// Once is a fork of sync.Once to expose a Done() method. -type Once struct { - done uint32 - m sync.Mutex -} - -func (o *Once) Do(f func()) { - if atomic.LoadUint32(&o.done) == 0 { - o.doSlow(f) - } -} - -func (o *Once) doSlow(f func()) { - o.m.Lock() - defer o.m.Unlock() - if o.done == 0 { - defer atomic.StoreUint32(&o.done, 1) - f() - } -} - -func (o *Once) Done() bool { - return atomic.LoadUint32(&o.done) == 1 -} diff --git a/irc/utils/text.go b/irc/utils/text.go index c42368c6..1fea5e7b 100644 --- a/irc/utils/text.go +++ b/irc/utils/text.go @@ -95,6 +95,20 @@ func (sm *SplitMessage) Is512() bool { return sm.Split == nil } +func (sm *SplitMessage) CombinedValue() string { + if sm.Split == nil { + return sm.Message + } + var buf strings.Builder + for i := range sm.Split { + if i != 0 && !sm.Split[i].Concat { + buf.WriteRune('\n') + } + buf.WriteString(sm.Split[i].Message) + } + return buf.String() +} + // TokenLineBuilder is a helper for building IRC lines composed of delimited tokens, // with a maximum line length. type TokenLineBuilder struct { diff --git a/irc/utils/text_test.go b/irc/utils/text_test.go index a0b4ca45..420f4c44 100644 --- a/irc/utils/text_test.go +++ b/irc/utils/text_test.go @@ -66,3 +66,15 @@ func BenchmarkTokenLines(b *testing.B) { tl.Lines() } } + +func TestCombinedValue(t *testing.T) { + var split = SplitMessage{ + Split: []MessagePair{ + {"hi", false}, + {"hi", false}, + {" again", true}, + {"you", false}, + }, + } + assertEqual(split.CombinedValue(), "hi\nhi again\nyou", t) +} diff --git a/irc/utils/time.go b/irc/utils/time.go new file mode 100644 index 00000000..1f04948a --- /dev/null +++ b/irc/utils/time.go @@ -0,0 +1,15 @@ +package utils + +import ( + "time" +) + +// ReadMarkerLessThanOrEqual compares times from the standpoint of +// draft/read-marker (the presentation format of which truncates the time +// to the millisecond). In future we might want to consider proactively rounding, +// instead of truncating, the time, but this has complex implications. +func ReadMarkerLessThanOrEqual(t1, t2 time.Time) bool { + t1 = t1.Truncate(time.Millisecond) + t2 = t2.Truncate(time.Millisecond) + return t1.Before(t2) || t1.Equal(t2) +} diff --git a/irc/webpush/highlight.go b/irc/webpush/highlight.go new file mode 100644 index 00000000..a189ce88 --- /dev/null +++ b/irc/webpush/highlight.go @@ -0,0 +1,60 @@ +// Copyright (c) 2021-2024 Simon Ser +// Originally released under the AGPLv3, relicensed to the Ergo project under the MIT license + +package webpush + +import ( + "strings" + "unicode" + "unicode/utf8" +) + +func isWordBoundary(r rune) bool { + switch r { + case '-', '_', '|': // inspired from weechat.look.highlight_regex + return false + default: + return !unicode.IsLetter(r) && !unicode.IsNumber(r) + } +} + +func isURIPrefix(text string) bool { + if i := strings.LastIndexFunc(text, unicode.IsSpace); i >= 0 { + text = text[i:] + } + + i := strings.Index(text, "://") + if i < 0 { + return false + } + + // See RFC 3986 section 3 + r, _ := utf8.DecodeLastRuneInString(text[:i]) + switch r { + case '+', '-', '.': + return true + default: + return ('0' <= r && r <= '9') || ('a' <= r && r <= 'z') || ('A' <= r && r <= 'Z') + } +} + +func IsHighlight(text, nick string) bool { + if len(nick) == 0 { + return false + } + + for { + i := strings.Index(text, nick) + if i < 0 { + return false + } + + left, _ := utf8.DecodeLastRuneInString(text[:i]) + right, _ := utf8.DecodeRuneInString(text[i+len(nick):]) + if isWordBoundary(left) && isWordBoundary(right) && !isURIPrefix(text[:i]) { + return true + } + + text = text[i+len(nick):] + } +} diff --git a/irc/webpush/security.go b/irc/webpush/security.go new file mode 100644 index 00000000..378750bf --- /dev/null +++ b/irc/webpush/security.go @@ -0,0 +1,66 @@ +// Copyright (c) 2024 Shivaram Lingamneni +// Released under the MIT license +// Some portions of this code are: +// Copyright (c) 2024 Simon Ser +// Originally released under the AGPLv3, relicensed to the Ergo project under the MIT license + +package webpush + +import ( + "errors" + "fmt" + "net" + "net/http" + "net/netip" + "net/url" + "syscall" +) + +var ( + errInternalIP = errors.New("dialing an internal IP is forbidden") +) + +func SanityCheckWebPushEndpoint(endpoint string) error { + u, err := url.Parse(endpoint) + if err != nil { + return err + } + if u.Scheme != "https" { + return fmt.Errorf("scheme must be HTTPS") + } + return nil +} + +// makeExternalOnlyClient builds an http.Client that can only connect +// to external IP addresses. +func makeExternalOnlyClient() *http.Client { + dialer := &net.Dialer{ + Control: func(network, address string, c syscall.RawConn) error { + ip, _, err := net.SplitHostPort(address) + if err != nil { + return err + } + + parsedIP, err := netip.ParseAddr(ip) + if err != nil { + return err + } + + if isInternalIP(parsedIP) { + return errInternalIP + } + + return nil + }, + } + + return &http.Client{ + Transport: &http.Transport{ + DialContext: dialer.DialContext, + }, + } +} + +func isInternalIP(ip netip.Addr) bool { + return ip.IsLoopback() || ip.IsMulticast() || ip.IsPrivate() +} diff --git a/irc/webpush/security_test.go b/irc/webpush/security_test.go new file mode 100644 index 00000000..813f07f4 --- /dev/null +++ b/irc/webpush/security_test.go @@ -0,0 +1,21 @@ +package webpush + +import ( + "errors" + "testing" +) + +func TestExternalOnlyHTTPClient(t *testing.T) { + client := makeExternalOnlyClient() + + for _, url := range []string{ + "https://127.0.0.2/test", + "https://127.0.0.2:8201", + "https://127.0.0.2:8201/asdf", + } { + _, err := client.Get(url) + if err == nil || !errors.Is(err, errInternalIP) { + t.Errorf("%s was not forbidden as expected (got %v)", url, err) + } + } +} diff --git a/irc/webpush/webpush.go b/irc/webpush/webpush.go new file mode 100644 index 00000000..a97d7ed4 --- /dev/null +++ b/irc/webpush/webpush.go @@ -0,0 +1,148 @@ +// Copyright (c) 2024 Shivaram Lingamneni +// Released under the MIT license +// Some portions of this code are: +// Copyright (c) 2021-2024 Simon Ser +// Originally released under the AGPLv3, relicensed to the Ergo project under the MIT license + +package webpush + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/ergochat/irc-go/ircmsg" + webpush "github.com/ergochat/webpush-go/v2" + + "github.com/ergochat/ergo/irc/utils" +) + +// alias some public types and names from webpush-go +type VAPIDKeys = webpush.VAPIDKeys +type Keys = webpush.Keys + +var ( + GenerateVAPIDKeys = webpush.GenerateVAPIDKeys +) + +// Urgency is a uint8 representation of urgency to save a few +// bytes on channel sizes. +type Urgency uint8 + +const ( + // UrgencyVeryLow requires device state: on power and Wi-Fi + UrgencyVeryLow Urgency = iota // "very-low" + // UrgencyLow requires device state: on either power or Wi-Fi + UrgencyLow // "low" + // UrgencyNormal excludes device state: low battery + UrgencyNormal // "normal" + // UrgencyHigh admits device state: low battery + UrgencyHigh // "high" +) + +var ( + // PingMessage is a valid IRC message that we can send to test that the subscription + // is valid (i.e. responds to POSTs with a 20x). We do not expect that the client will + // actually connect to IRC and send PONG (although it might be nice to have a way to + // hint to a client that they should reconnect to renew their subscription?) + PingMessage = []byte("PING webpush") +) + +func convertUrgency(u Urgency) webpush.Urgency { + switch u { + case UrgencyVeryLow: + return webpush.UrgencyVeryLow + case UrgencyLow: + return webpush.UrgencyLow + case UrgencyNormal: + return webpush.UrgencyNormal + case UrgencyHigh: + return webpush.UrgencyHigh + default: + return webpush.UrgencyNormal // shouldn't happen + } +} + +var httpClient webpush.HTTPClient = makeExternalOnlyClient() + +var ( + Err404 = errors.New("endpoint returned a 404, indicating that the push subscription is no longer valid") + + errInvalidKey = errors.New("invalid key format") +) + +func DecodeSubscriptionKeys(keysParam string) (keys webpush.Keys, err error) { + // The keys parameter is tag-encoded, with each tag value being URL-safe base64 encoded: + // * One public key with the name p256dh set to the client's P-256 ECDH public key. + // * One shared key with the name auth set to a 16-byte client-generated authentication secret. + // since we don't have a separate tag parser implementation, wrap it in a fake IRC line for parsing: + fakeIRCLine := fmt.Sprintf("@%s PING", keysParam) + ircMsg, err := ircmsg.ParseLine(fakeIRCLine) + if err != nil { + return + } + _, auth := ircMsg.GetTag("auth") + _, p256 := ircMsg.GetTag("p256dh") + return webpush.DecodeSubscriptionKeys(auth, p256) +} + +// MakePushMessage serializes a utils.SplitMessage as a web push message (the args are in +// logical order) +func MakePushMessage(command, nuh, accountName, target string, msg utils.SplitMessage) ([]byte, error) { + var messageForPush string + if msg.Is512() { + messageForPush = msg.Message + } else { + messageForPush = msg.Split[0].Message + } + return MakePushLine(msg.Time, accountName, nuh, command, target, messageForPush) +} + +// MakePushLine serializes an arbitrary IRC line as a web push message (the args are in +// IRC syntax order) +func MakePushLine(time time.Time, accountName, source, command string, params ...string) ([]byte, error) { + pushMessage := ircmsg.MakeMessage(nil, source, command, params...) + pushMessage.SetTag("time", time.Format(utils.IRCv3TimestampFormat)) + // "*" is canonical for the unset form of the unfolded account name, but check both: + if accountName != "*" && accountName != "" { + pushMessage.SetTag("account", accountName) + } + if line, err := pushMessage.LineBytesStrict(false, 512); err == nil { + // strip final \r\n + return line[:len(line)-2], nil + } else { + return nil, err + } +} + +func SendWebPush(ctx context.Context, endpoint string, keys Keys, vapidKeys *VAPIDKeys, urgency Urgency, subscriber string, msg []byte) error { + wpsub := webpush.Subscription{ + Endpoint: endpoint, + Keys: keys, + } + + options := webpush.Options{ + HTTPClient: httpClient, + VAPIDKeys: vapidKeys, + Subscriber: subscriber, + TTL: 7 * 24 * 60 * 60, // seconds + Urgency: convertUrgency(urgency), + RecordSize: 2048, + } + + resp, err := webpush.SendNotification(ctx, msg, &wpsub, &options) + if err != nil { + return err + } + resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return Err404 + } else if 200 <= resp.StatusCode && resp.StatusCode < 300 { + return nil + } else { + return fmt.Errorf("HTTP error: %v", resp.Status) + } +} diff --git a/irc/webpush/webpush_test.go b/irc/webpush/webpush_test.go new file mode 100644 index 00000000..4e227fd5 --- /dev/null +++ b/irc/webpush/webpush_test.go @@ -0,0 +1,57 @@ +package webpush + +import ( + "strings" + "testing" + "time" + + "github.com/ergochat/irc-go/ircmsg" + + "github.com/ergochat/ergo/irc/utils" +) + +func TestBuildPushLine(t *testing.T) { + now, err := time.Parse(utils.IRCv3TimestampFormat, "2025-01-12T00:55:44.403Z") + if err != nil { + panic(err) + } + + line, err := MakePushLine(now, "*", "ergo.test", "MARKREAD", "#ergo", "timestamp=2025-01-12T00:07:57.972Z") + if err != nil { + t.Fatal(err) + } + if string(line) != "@time=2025-01-12T00:55:44.403Z :ergo.test MARKREAD #ergo timestamp=2025-01-12T00:07:57.972Z" { + t.Errorf("got wrong line output: %s", line) + } +} + +func TestBuildPushMessage(t *testing.T) { + now, err := time.Parse(utils.IRCv3TimestampFormat, "2025-01-12T01:05:04.422Z") + if err != nil { + panic(err) + } + + lineBytes, err := MakePushMessage("PRIVMSG", "shivaram!~u@kca7nfgniet7q.irc", "shivaram", "#redacted", utils.SplitMessage{ + Message: "[redacted message contents]", + Msgid: "t8st5bb4b9qhed3zs3pwspinca", + Time: now, + }) + if err != nil { + t.Fatal(err) + } + line := string(lineBytes) + parsed, err := ircmsg.ParseLineStrict(line, false, 512) + if err != nil { + t.Fatal(err) + } + if ok, account := parsed.GetTag("account"); !ok || account != "shivaram" { + t.Fatalf("bad account tag %s", account) + } + if ok, timestamp := parsed.GetTag("time"); !ok || timestamp != "2025-01-12T01:05:04.422Z" { + t.Fatal("bad time") + } + idx := strings.IndexByte(line, ' ') + if line[idx+1:] != ":shivaram!~u@kca7nfgniet7q.irc PRIVMSG #redacted :[redacted message contents]" { + t.Fatal("bad line") + } +} diff --git a/traditional.yaml b/traditional.yaml index 3090f664..d8009949 100644 --- a/traditional.yaml +++ b/traditional.yaml @@ -893,6 +893,7 @@ fakelag: "MARKREAD": 16 "MONITOR": 1 "WHO": 4 + "WEBPUSH": 1 # the roleplay commands are semi-standardized extensions to IRC that allow # sending and receiving messages from pseudo-nicknames. this can be used either @@ -1038,3 +1039,24 @@ history: # whether to allow customization of the config at runtime using environment variables, # e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details. allow-environment-overrides: true + +# experimental support for mobile push notifications +# see the manual for potential security, privacy, and performance implications. +# DO NOT enable if you are running a Tor or I2P hidden service (i.e. one +# with no public IP listeners, only Tor/I2P listeners). +webpush: + # are push notifications enabled at all? + enabled: false + # request timeout for POST'ing the http notification + timeout: 10s + # delay sending the notification for this amount of time, then suppress it + # if the client sent MARKREAD to indicate that it was read on another device + delay: 0s + # subscriber field for the VAPID JWT authorization: + #subscriber: "https://your-website.com/" + # maximum number of push subscriptions per user + max-subscriptions: 4 + # expiration time for a push subscription; it must be renewed within this time + # by the client reconnecting to IRC. we also detect whether the client is no longer + # successfully receiving push messages. + expiration: 14d diff --git a/vendor/github.com/ergochat/webpush-go/v2/.check-gofmt.sh b/vendor/github.com/ergochat/webpush-go/v2/.check-gofmt.sh new file mode 100644 index 00000000..48a1aa36 --- /dev/null +++ b/vendor/github.com/ergochat/webpush-go/v2/.check-gofmt.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +SOURCES="." + +if [ "$1" = "--fix" ]; then + exec gofmt -s -w $SOURCES +fi + +if [ -n "$(gofmt -s -l $SOURCES)" ]; then + echo "Go code is not formatted correctly with \`gofmt -s\`:" + gofmt -s -d $SOURCES + exit 1 +fi diff --git a/vendor/github.com/ergochat/webpush-go/v2/.gitignore b/vendor/github.com/ergochat/webpush-go/v2/.gitignore new file mode 100644 index 00000000..3ab8789b --- /dev/null +++ b/vendor/github.com/ergochat/webpush-go/v2/.gitignore @@ -0,0 +1,6 @@ +vendor/** + +.DS_Store +*.out + +*.swp diff --git a/vendor/github.com/ergochat/webpush-go/v2/CHANGELOG.md b/vendor/github.com/ergochat/webpush-go/v2/CHANGELOG.md new file mode 100644 index 00000000..3dd34d5a --- /dev/null +++ b/vendor/github.com/ergochat/webpush-go/v2/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog +All notable changes to webpush-go will be documented in this file. + +## [2.0.0] - 2025-01-01 + +* Update the `Keys` struct definition to store `Auth` as `[16]byte` and `P256dh` as `*ecdh.PublicKey` + * `Keys` can no longer be compared with `==`; use `(*Keys.Equal)` instead + * The JSON representation has not changed and is backwards and forwards compatible with v1 + * `DecodeSubscriptionKeys` is a helper to decode base64-encoded auth and p256dh parameters into a `Keys`, with validation +* Update the `VAPIDKeys` struct to contain a `(*ecdsa.PrivateKey)` + * `VAPIDKeys` can no longer be compared with `==`; use `(*VAPIDKeys).Equal` instead + * The JSON representation is now a JSON string containing the PEM of the PKCS8-encoded private key + * To parse the legacy representation (raw bytes of the private key encoded in base64), use `DecodeLegacyVAPIDPrivateKey` +* Renamed `SendNotificationWithContext` to `SendNotification`, removing the earlier `SendNotification` API. (Pass `context.Background()` as the context to restore the former behavior.) diff --git a/vendor/github.com/ergochat/webpush-go/v2/LICENSE b/vendor/github.com/ergochat/webpush-go/v2/LICENSE new file mode 100644 index 00000000..161eac77 --- /dev/null +++ b/vendor/github.com/ergochat/webpush-go/v2/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 Ethan Holmes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/github.com/ergochat/webpush-go/v2/Makefile b/vendor/github.com/ergochat/webpush-go/v2/Makefile new file mode 100644 index 00000000..7b72a66f --- /dev/null +++ b/vendor/github.com/ergochat/webpush-go/v2/Makefile @@ -0,0 +1,6 @@ +.PHONY: test + +test: + go test . + go vet . + ./.check-gofmt.sh diff --git a/vendor/github.com/ergochat/webpush-go/v2/README.md b/vendor/github.com/ergochat/webpush-go/v2/README.md new file mode 100644 index 00000000..461b4c84 --- /dev/null +++ b/vendor/github.com/ergochat/webpush-go/v2/README.md @@ -0,0 +1,65 @@ +# webpush-go + +[![GoDoc](https://godoc.org/github.com/ergochat/webpush-go?status.svg)](https://godoc.org/github.com/ergochat/webpush-go) + +Web Push API Encryption with VAPID support. + +This library is a fork of [SherClockHolmes/webpush-go](https://github.com/SherClockHolmes/webpush-go). + +```bash +go get -u github.com/ergochat/webpush-go/v2 +``` + +## Example + +For a full example, refer to the code in the [example](example/) directory. + +```go +package main + +import ( + "encoding/json" + + webpush "github.com/ergochat/webpush-go/v2" +) + +func main() { + // Decode subscription + s := &webpush.Subscription{} + json.Unmarshal([]byte(""), s) + vapidKeys := new(webpush.VAPIDKeys) + json.Unmarshal([]byte("), vapidKeys) + + // Send Notification + resp, err := webpush.SendNotification([]byte("Test"), s, &webpush.Options{ + Subscriber: "example@example.com", + VAPIDKeys: vapidKeys, + TTL: 3600, // seconds + }) + if err != nil { + // TODO: Handle error + } + defer resp.Body.Close() +} +``` + +### Generating VAPID Keys + +Use the helper method `GenerateVAPIDKeys` to generate the VAPID key pair. + +```golang +vapidKeys, err := webpush.GenerateVAPIDKeys() +if err != nil { + // TODO: Handle error +} +``` + +## Development + +1. Install [Go 1.20+](https://golang.org/) +2. `go mod vendor` +3. `go test` + +#### For other language implementations visit: + +[WebPush Libs](https://github.com/web-push-libs) diff --git a/vendor/github.com/ergochat/webpush-go/v2/legacy.go b/vendor/github.com/ergochat/webpush-go/v2/legacy.go new file mode 100644 index 00000000..b151da99 --- /dev/null +++ b/vendor/github.com/ergochat/webpush-go/v2/legacy.go @@ -0,0 +1,76 @@ +package webpush + +import ( + "crypto/ecdh" + "crypto/ecdsa" + "crypto/elliptic" + "encoding/base64" + "fmt" + "math/big" +) + +// ecdhPublicKeyToECDSA converts an ECDH key to an ECDSA key. +// This is deprecated as per https://github.com/golang/go/issues/63963 +// but we need to do it in order to parse the legacy private key format. +func ecdhPublicKeyToECDSA(key *ecdh.PublicKey) (*ecdsa.PublicKey, error) { + rawKey := key.Bytes() + switch key.Curve() { + case ecdh.P256(): + return &ecdsa.PublicKey{ + Curve: elliptic.P256(), + X: big.NewInt(0).SetBytes(rawKey[1:33]), + Y: big.NewInt(0).SetBytes(rawKey[33:]), + }, nil + case ecdh.P384(): + return &ecdsa.PublicKey{ + Curve: elliptic.P384(), + X: big.NewInt(0).SetBytes(rawKey[1:49]), + Y: big.NewInt(0).SetBytes(rawKey[49:]), + }, nil + case ecdh.P521(): + return &ecdsa.PublicKey{ + Curve: elliptic.P521(), + X: big.NewInt(0).SetBytes(rawKey[1:67]), + Y: big.NewInt(0).SetBytes(rawKey[67:]), + }, nil + default: + return nil, fmt.Errorf("cannot convert non-NIST *ecdh.PublicKey to *ecdsa.PublicKey") + } +} + +func ecdhPrivateKeyToECDSA(key *ecdh.PrivateKey) (*ecdsa.PrivateKey, error) { + // see https://github.com/golang/go/issues/63963 + pubKey, err := ecdhPublicKeyToECDSA(key.PublicKey()) + if err != nil { + return nil, fmt.Errorf("converting PublicKey part of *ecdh.PrivateKey: %w", err) + } + return &ecdsa.PrivateKey{ + PublicKey: *pubKey, + D: big.NewInt(0).SetBytes(key.Bytes()), + }, nil +} + +// DecodeLegacyVAPIDPrivateKey decodes the legacy string private key format +// returned by GenerateVAPIDKeys in v1. +func DecodeLegacyVAPIDPrivateKey(key string) (*VAPIDKeys, error) { + bytes, err := decodeSubscriptionKey(key) + if err != nil { + return nil, err + } + + ecdhPrivKey, err := ecdh.P256().NewPrivateKey(bytes) + if err != nil { + return nil, err + } + + ecdsaPrivKey, err := ecdhPrivateKeyToECDSA(ecdhPrivKey) + if err != nil { + return nil, err + } + + publicKey := base64.RawURLEncoding.EncodeToString(ecdhPrivKey.PublicKey().Bytes()) + return &VAPIDKeys{ + privateKey: ecdsaPrivKey, + publicKey: publicKey, + }, nil +} diff --git a/vendor/github.com/ergochat/webpush-go/v2/urgency.go b/vendor/github.com/ergochat/webpush-go/v2/urgency.go new file mode 100644 index 00000000..97c4a32b --- /dev/null +++ b/vendor/github.com/ergochat/webpush-go/v2/urgency.go @@ -0,0 +1,26 @@ +package webpush + +// Urgency indicates to the push service how important a message is to the user. +// This can be used by the push service to help conserve the battery life of a user's device +// by only waking up for important messages when battery is low. +type Urgency string + +const ( + // UrgencyVeryLow requires device state: on power and Wi-Fi + UrgencyVeryLow Urgency = "very-low" + // UrgencyLow requires device state: on either power or Wi-Fi + UrgencyLow Urgency = "low" + // UrgencyNormal excludes device state: low battery + UrgencyNormal Urgency = "normal" + // UrgencyHigh admits device state: low battery + UrgencyHigh Urgency = "high" +) + +// Checking allowable values for the urgency header +func isValidUrgency(urgency Urgency) bool { + switch urgency { + case UrgencyVeryLow, UrgencyLow, UrgencyNormal, UrgencyHigh: + return true + } + return false +} diff --git a/vendor/github.com/ergochat/webpush-go/v2/vapid.go b/vendor/github.com/ergochat/webpush-go/v2/vapid.go new file mode 100644 index 00000000..f4b0b536 --- /dev/null +++ b/vendor/github.com/ergochat/webpush-go/v2/vapid.go @@ -0,0 +1,177 @@ +package webpush + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "net/url" + "strings" + "time" + + jwt "github.com/golang-jwt/jwt/v5" +) + +// VAPIDKeys is a public-private keypair for use in VAPID. +// It marshals to a JSON string containing the PEM of the PKCS8 +// of the private key. +type VAPIDKeys struct { + privateKey *ecdsa.PrivateKey + publicKey string // raw bytes encoding in urlsafe base64, as per RFC +} + +// PublicKeyString returns the base64url-encoded uncompressed public key of the keypair, +// as defined in RFC8292. +func (v *VAPIDKeys) PublicKeyString() string { + return v.publicKey +} + +// PrivateKey returns the private key of the keypair. +func (v *VAPIDKeys) PrivateKey() *ecdsa.PrivateKey { + return v.privateKey +} + +// Equal compares two VAPIDKeys for equality. +func (v *VAPIDKeys) Equal(o *VAPIDKeys) bool { + return v.privateKey.Equal(o.privateKey) +} + +var _ json.Marshaler = (*VAPIDKeys)(nil) +var _ json.Unmarshaler = (*VAPIDKeys)(nil) + +// MarshalJSON implements json.Marshaler, allowing serialization to JSON. +func (v *VAPIDKeys) MarshalJSON() ([]byte, error) { + pkcs8bytes, err := x509.MarshalPKCS8PrivateKey(v.privateKey) + if err != nil { + return nil, err + } + pemBlock := pem.Block{ + Type: "PRIVATE KEY", + Bytes: pkcs8bytes, + } + pemBytes := pem.EncodeToMemory(&pemBlock) + if pemBytes == nil { + return nil, fmt.Errorf("could not encode VAPID keys as PEM") + } + return json.Marshal(string(pemBytes)) +} + +// MarshalJSON implements json.Unmarshaler, allowing deserialization from JSON. +func (v *VAPIDKeys) UnmarshalJSON(b []byte) error { + var pemKey string + if err := json.Unmarshal(b, &pemKey); err != nil { + return err + } + pemBlock, _ := pem.Decode([]byte(pemKey)) + if pemBlock == nil { + return fmt.Errorf("could not decode PEM block with VAPID keys") + } + privKey, err := x509.ParsePKCS8PrivateKey(pemBlock.Bytes) + if err != nil { + return err + } + privateKey, ok := privKey.(*ecdsa.PrivateKey) + if !ok { + return fmt.Errorf("Invalid type of private key %T", privateKey) + } + if privateKey.Curve != elliptic.P256() { + return fmt.Errorf("Invalid curve for private key %v", privateKey.Curve) + } + publicKeyStr, err := makePublicKeyString(privateKey) + if err != nil { + return err // should not be possible since we confirmed P256 already + } + + // success + v.privateKey = privateKey + v.publicKey = publicKeyStr + return nil +} + +// GenerateVAPIDKeys generates a VAPID keypair (an ECDSA keypair on +// the P-256 curve). +func GenerateVAPIDKeys() (result *VAPIDKeys, err error) { + private, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return + } + + pubKeyECDH, err := private.PublicKey.ECDH() + if err != nil { + return + } + publicKey := base64.RawURLEncoding.EncodeToString(pubKeyECDH.Bytes()) + + return &VAPIDKeys{ + privateKey: private, + publicKey: publicKey, + }, nil +} + +// ECDSAToVAPIDKeys wraps an existing ecdsa.PrivateKey in VAPIDKeys for use in +// VAPID header signing. +func ECDSAToVAPIDKeys(privKey *ecdsa.PrivateKey) (result *VAPIDKeys, err error) { + if privKey.Curve != elliptic.P256() { + return nil, fmt.Errorf("Invalid curve for private key %v", privKey.Curve) + } + publicKeyString, err := makePublicKeyString(privKey) + if err != nil { + return nil, err + } + return &VAPIDKeys{ + privateKey: privKey, + publicKey: publicKeyString, + }, nil +} + +func makePublicKeyString(privKey *ecdsa.PrivateKey) (result string, err error) { + // to get the raw bytes we have to convert the public key to *ecdh.PublicKey + // this type assertion (from the crypto.PublicKey returned by (*ecdsa.PrivateKey).Public() + // to *ecdsa.PublicKey) cannot fail: + publicKey, err := privKey.Public().(*ecdsa.PublicKey).ECDH() + if err != nil { + return // should not be possible if we confirmed P256 already + } + return base64.RawURLEncoding.EncodeToString(publicKey.Bytes()), nil +} + +// getVAPIDAuthorizationHeader +func getVAPIDAuthorizationHeader( + endpoint string, + subscriber string, + vapidKeys *VAPIDKeys, + expiration time.Time, +) (string, error) { + if expiration.IsZero() { + expiration = time.Now().Add(time.Hour * 12) + } + + // Create the JWT token + subURL, err := url.Parse(endpoint) + if err != nil { + return "", err + } + + // Unless subscriber is an HTTPS URL, assume an e-mail address + if !strings.HasPrefix(subscriber, "https:") && !strings.HasPrefix(subscriber, "mailto:") { + subscriber = "mailto:" + subscriber + } + + token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{ + "aud": subURL.Scheme + "://" + subURL.Host, + "exp": expiration.Unix(), + "sub": subscriber, + }) + + // Sign token with private key + jwtString, err := token.SignedString(vapidKeys.privateKey) + if err != nil { + return "", err + } + + return "vapid t=" + jwtString + ", k=" + vapidKeys.publicKey, nil +} diff --git a/vendor/github.com/ergochat/webpush-go/v2/webpush.go b/vendor/github.com/ergochat/webpush-go/v2/webpush.go new file mode 100644 index 00000000..abdc9b17 --- /dev/null +++ b/vendor/github.com/ergochat/webpush-go/v2/webpush.go @@ -0,0 +1,323 @@ +package webpush + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/hkdf" +) + +const MaxRecordSize uint32 = 4096 + +var ( + ErrRecordSizeTooSmall = errors.New("record size too small for message") + + invalidAuthKeyLength = errors.New("invalid auth key length (must be 16)") + + defaultHTTPClient = &http.Client{} +) + +// HTTPClient is an interface for sending the notification HTTP request / testing +type HTTPClient interface { + Do(*http.Request) (*http.Response, error) +} + +// Options are config and extra params needed to send a notification +type Options struct { + HTTPClient HTTPClient // Will replace with *http.Client by default if not included + RecordSize uint32 // Limit the record size + Subscriber string // Sub in VAPID JWT token + Topic string // Set the Topic header to collapse a pending messages (Optional) + TTL int // Set the TTL on the endpoint POST request, in seconds + Urgency Urgency // Set the Urgency header to change a message priority (Optional) + VAPIDKeys *VAPIDKeys // VAPID public-private keypair to generate the VAPID Authorization header + VapidExpiration time.Time // optional expiration for VAPID JWT token (defaults to now + 12 hours) +} + +// Keys represents a subscription's keys (its ECDH public key on the P-256 curve +// and its 16-byte authentication secret). +type Keys struct { + Auth [16]byte + P256dh *ecdh.PublicKey +} + +// Equal compares two Keys for equality. +func (k *Keys) Equal(o Keys) bool { + return k.Auth == o.Auth && k.P256dh.Equal(o.P256dh) +} + +var _ json.Marshaler = (*Keys)(nil) +var _ json.Unmarshaler = (*Keys)(nil) + +type marshaledKeys struct { + Auth string `json:"auth"` + P256dh string `json:"p256dh"` +} + +// MarshalJSON implements json.Marshaler, allowing serialization to JSON. +func (k *Keys) MarshalJSON() ([]byte, error) { + m := marshaledKeys{ + Auth: base64.RawStdEncoding.EncodeToString(k.Auth[:]), + P256dh: base64.RawStdEncoding.EncodeToString(k.P256dh.Bytes()), + } + return json.Marshal(&m) +} + +// MarshalJSON implements json.Unmarshaler, allowing deserialization from JSON. +func (k *Keys) UnmarshalJSON(b []byte) (err error) { + var m marshaledKeys + if err := json.Unmarshal(b, &m); err != nil { + return err + } + authBytes, err := decodeSubscriptionKey(m.Auth) + if err != nil { + return err + } + if len(authBytes) != 16 { + return fmt.Errorf("invalid auth bytes length %d (must be 16)", len(authBytes)) + } + copy(k.Auth[:], authBytes) + rawDHKey, err := decodeSubscriptionKey(m.P256dh) + if err != nil { + return err + } + k.P256dh, err = ecdh.P256().NewPublicKey(rawDHKey) + return err +} + +// DecodeSubscriptionKeys decodes and validates a base64-encoded pair of subscription keys +// (the authentication secret and ECDH public key). +func DecodeSubscriptionKeys(auth, p256dh string) (keys Keys, err error) { + authBytes, err := decodeSubscriptionKey(auth) + if err != nil { + return + } + if len(authBytes) != 16 { + err = invalidAuthKeyLength + return + } + copy(keys.Auth[:], authBytes) + dhBytes, err := decodeSubscriptionKey(p256dh) + if err != nil { + return + } + keys.P256dh, err = ecdh.P256().NewPublicKey(dhBytes) + if err != nil { + return + } + return +} + +// Subscription represents a PushSubscription object from the Push API +type Subscription struct { + Endpoint string `json:"endpoint"` + Keys Keys `json:"keys"` +} + +// SendNotification sends a push notification to a subscription's endpoint, +// applying encryption (RFC 8291) and adding a VAPID header (RFC 8292). +func SendNotification(ctx context.Context, message []byte, s *Subscription, options *Options) (*http.Response, error) { + // Compose message body (RFC8291 encryption of the message) + body, err := EncryptNotification(message, s.Keys, options.RecordSize) + if err != nil { + return nil, err + } + + // Get VAPID Authorization header + vapidAuthHeader, err := getVAPIDAuthorizationHeader( + s.Endpoint, + options.Subscriber, + options.VAPIDKeys, + options.VapidExpiration, + ) + if err != nil { + return nil, err + } + + // Compose and send the HTTP request + return sendNotification(ctx, s.Endpoint, options, vapidAuthHeader, body) +} + +// EncryptNotification implements the encryption algorithm specified by RFC 8291 for web push +// (RFC 8188's aes128gcm content-encoding, with the key material derived from +// elliptic curve Diffie-Hellman over the P-256 curve). +func EncryptNotification(message []byte, keys Keys, recordSize uint32) ([]byte, error) { + // Get the record size + if recordSize == 0 { + recordSize = MaxRecordSize + } else if recordSize < 128 { + return nil, ErrRecordSizeTooSmall + } + + // Allocate buffer to hold the eventual message + // [ header block ] [ ciphertext ] [ 16 byte AEAD tag ], totaling RecordSize bytes + // the ciphertext is the encryption of: [ message ] [ \x02 ] [ 0 or more \x00 as needed ] + recordBuf := make([]byte, recordSize) + // remainingBuf tracks our current writing position in recordBuf: + remainingBuf := recordBuf + + // Application server key pairs (single use) + localPrivateKey, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + return nil, err + } + localPublicKey := localPrivateKey.PublicKey() + + // Encryption Content-Coding Header + // +-----------+--------+-----------+---------------+ + // | salt (16) | rs (4) | idlen (1) | keyid (idlen) | + // +-----------+--------+-----------+---------------+ + // in our case the keyid is localPublicKey.Bytes(), so 65 bytes + // First, generate the salt + _, err = rand.Read(remainingBuf[:16]) + if err != nil { + return nil, err + } + salt := remainingBuf[:16] + remainingBuf = remainingBuf[16:] + binary.BigEndian.PutUint32(remainingBuf[:], recordSize) + remainingBuf = remainingBuf[4:] + localPublicKeyBytes := localPublicKey.Bytes() + remainingBuf[0] = byte(len(localPublicKeyBytes)) + remainingBuf = remainingBuf[1:] + copy(remainingBuf[:], localPublicKeyBytes) + remainingBuf = remainingBuf[len(localPublicKeyBytes):] + + // Combine application keys with receiver's EC public key to derive ECDH shared secret + sharedECDHSecret, err := localPrivateKey.ECDH(keys.P256dh) + if err != nil { + return nil, fmt.Errorf("deriving shared secret: %w", err) + } + + // ikm + prkInfoBuf := bytes.NewBuffer([]byte("WebPush: info\x00")) + prkInfoBuf.Write(keys.P256dh.Bytes()) + prkInfoBuf.Write(localPublicKey.Bytes()) + + prkHKDF := hkdf.New(sha256.New, sharedECDHSecret, keys.Auth[:], prkInfoBuf.Bytes()) + ikm, err := getHKDFKey(prkHKDF, 32) + if err != nil { + return nil, err + } + + // Derive Content Encryption Key + contentEncryptionKeyInfo := []byte("Content-Encoding: aes128gcm\x00") + contentHKDF := hkdf.New(sha256.New, ikm, salt, contentEncryptionKeyInfo) + contentEncryptionKey, err := getHKDFKey(contentHKDF, 16) + if err != nil { + return nil, err + } + + // Derive the Nonce + nonceInfo := []byte("Content-Encoding: nonce\x00") + nonceHKDF := hkdf.New(sha256.New, ikm, salt, nonceInfo) + nonce, err := getHKDFKey(nonceHKDF, 12) + if err != nil { + return nil, err + } + + // Cipher + c, err := aes.NewCipher(contentEncryptionKey) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(c) + if err != nil { + return nil, err + } + + // need 1 byte for the 0x02 delimiter, 16 bytes for the AEAD tag + if len(remainingBuf) < len(message)+17 { + return nil, ErrRecordSizeTooSmall + } + // Copy the message plaintext into the buffer + copy(remainingBuf[:], message[:]) + // The plaintext to be encrypted will include the padding delimiter and the padding; + // cut off the final 16 bytes that are reserved for the AEAD tag + plaintext := remainingBuf[:len(remainingBuf)-16] + remainingBuf = remainingBuf[len(message):] + // Add padding delimiter + remainingBuf[0] = '\x02' + remainingBuf = remainingBuf[1:] + // The rest of the buffer is already zero-padded + + // Encipher the plaintext in place, then add the AEAD tag at the end. + // "To reuse plaintext's storage for the encrypted output, use plaintext[:0] + // as dst. Otherwise, the remaining capacity of dst must not overlap plaintext." + gcm.Seal(plaintext[:0], nonce, plaintext, nil) + + return recordBuf, nil +} + +func sendNotification(ctx context.Context, endpoint string, options *Options, vapidAuthHeader string, body []byte) (*http.Response, error) { + // POST request + req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + + if ctx != nil { + req = req.WithContext(ctx) + } + + req.Header.Set("Content-Encoding", "aes128gcm") + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("TTL", strconv.Itoa(options.TTL)) + + // Сheck the optional headers + if len(options.Topic) > 0 { + req.Header.Set("Topic", options.Topic) + } + + if isValidUrgency(options.Urgency) { + req.Header.Set("Urgency", string(options.Urgency)) + } + + req.Header.Set("Authorization", vapidAuthHeader) + + // Send the request + var client HTTPClient + if options.HTTPClient != nil { + client = options.HTTPClient + } else { + client = defaultHTTPClient + } + + return client.Do(req) +} + +// decodeSubscriptionKey decodes a base64 subscription key. +func decodeSubscriptionKey(key string) ([]byte, error) { + key = strings.TrimRight(key, "=") + + if strings.IndexByte(key, '+') != -1 || strings.IndexByte(key, '/') != -1 { + return base64.RawStdEncoding.DecodeString(key) + } + return base64.RawURLEncoding.DecodeString(key) +} + +// Returns a key of length "length" given an hkdf function +func getHKDFKey(hkdf io.Reader, length int) ([]byte, error) { + key := make([]byte, length) + n, err := io.ReadFull(hkdf, key) + if n != len(key) || err != nil { + return key, err + } + + return key, nil +} diff --git a/vendor/golang.org/x/crypto/hkdf/hkdf.go b/vendor/golang.org/x/crypto/hkdf/hkdf.go new file mode 100644 index 00000000..3bee6629 --- /dev/null +++ b/vendor/golang.org/x/crypto/hkdf/hkdf.go @@ -0,0 +1,95 @@ +// Copyright 2014 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package hkdf implements the HMAC-based Extract-and-Expand Key Derivation +// Function (HKDF) as defined in RFC 5869. +// +// HKDF is a cryptographic key derivation function (KDF) with the goal of +// expanding limited input keying material into one or more cryptographically +// strong secret keys. +package hkdf + +import ( + "crypto/hmac" + "errors" + "hash" + "io" +) + +// Extract generates a pseudorandom key for use with Expand from an input secret +// and an optional independent salt. +// +// Only use this function if you need to reuse the extracted key with multiple +// Expand invocations and different context values. Most common scenarios, +// including the generation of multiple keys, should use New instead. +func Extract(hash func() hash.Hash, secret, salt []byte) []byte { + if salt == nil { + salt = make([]byte, hash().Size()) + } + extractor := hmac.New(hash, salt) + extractor.Write(secret) + return extractor.Sum(nil) +} + +type hkdf struct { + expander hash.Hash + size int + + info []byte + counter byte + + prev []byte + buf []byte +} + +func (f *hkdf) Read(p []byte) (int, error) { + // Check whether enough data can be generated + need := len(p) + remains := len(f.buf) + int(255-f.counter+1)*f.size + if remains < need { + return 0, errors.New("hkdf: entropy limit reached") + } + // Read any leftover from the buffer + n := copy(p, f.buf) + p = p[n:] + + // Fill the rest of the buffer + for len(p) > 0 { + if f.counter > 1 { + f.expander.Reset() + } + f.expander.Write(f.prev) + f.expander.Write(f.info) + f.expander.Write([]byte{f.counter}) + f.prev = f.expander.Sum(f.prev[:0]) + f.counter++ + + // Copy the new batch into p + f.buf = f.prev + n = copy(p, f.buf) + p = p[n:] + } + // Save leftovers for next run + f.buf = f.buf[n:] + + return need, nil +} + +// Expand returns a Reader, from which keys can be read, using the given +// pseudorandom key and optional context info, skipping the extraction step. +// +// The pseudorandomKey should have been generated by Extract, or be a uniformly +// random or pseudorandom cryptographically strong key. See RFC 5869, Section +// 3.3. Most common scenarios will want to use New instead. +func Expand(hash func() hash.Hash, pseudorandomKey, info []byte) io.Reader { + expander := hmac.New(hash, pseudorandomKey) + return &hkdf{expander, expander.Size(), info, 1, nil, nil} +} + +// New returns a Reader, from which keys can be read, using the given hash, +// secret, salt and context info. Salt and info can be nil. +func New(hash func() hash.Hash, secret, salt, info []byte) io.Reader { + prk := Extract(hash, secret, salt) + return Expand(hash, prk, info) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 948ba994..d123950f 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -22,6 +22,9 @@ github.com/ergochat/irc-go/ircfmt github.com/ergochat/irc-go/ircmsg github.com/ergochat/irc-go/ircreader github.com/ergochat/irc-go/ircutils +# github.com/ergochat/webpush-go/v2 v2.0.0-rc1 +## explicit; go 1.20 +github.com/ergochat/webpush-go/v2 # github.com/go-sql-driver/mysql v1.7.0 ## explicit; go 1.13 github.com/go-sql-driver/mysql @@ -83,6 +86,7 @@ github.com/xdg-go/scram ## explicit; go 1.20 golang.org/x/crypto/bcrypt golang.org/x/crypto/blowfish +golang.org/x/crypto/hkdf golang.org/x/crypto/pbkdf2 golang.org/x/crypto/sha3 # golang.org/x/sys v0.22.0