3
0
mirror of https://github.com/ergochat/ergo.git synced 2025-01-21 01:34:20 +01:00

implement draft/webpush (#2205)

This commit is contained in:
Shivaram Lingamneni 2025-01-13 18:47:21 -08:00 committed by GitHub
parent efd3764337
commit 36e5451aa5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 2091 additions and 100 deletions

View File

@ -922,6 +922,7 @@ fakelag:
"MARKREAD": 16 "MARKREAD": 16
"MONITOR": 1 "MONITOR": 1
"WHO": 4 "WHO": 4
"WEBPUSH": 1
# the roleplay commands are semi-standardized extensions to IRC that allow # the roleplay commands are semi-standardized extensions to IRC that allow
# sending and receiving messages from pseudo-nicknames. this can be used either # 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, # 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. # e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details.
allow-environment-overrides: true 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

View File

@ -44,6 +44,7 @@ _Copyright © Daniel Oaks <daniel@danieloaks.net>, Shivaram Lingamneni <slingamn
- [Persistent history with MySQL](#persistent-history-with-mysql) - [Persistent history with MySQL](#persistent-history-with-mysql)
- [IP cloaking](#ip-cloaking) - [IP cloaking](#ip-cloaking)
- [Moderation](#moderation) - [Moderation](#moderation)
- [Push notifications](#push-notifications)
- [Frequently Asked Questions](#frequently-asked-questions) - [Frequently Asked Questions](#frequently-asked-questions)
- [IRC over TLS](#irc-over-tls) - [IRC over TLS](#irc-over-tls)
- [Redirect from plaintext to TLS](#how-can-i-redirect-users-from-plaintext-to-tls) - [Redirect from plaintext to TLS](#how-can-i-redirect-users-from-plaintext-to-tls)
@ -483,6 +484,18 @@ These techniques require operator privileges: `UBAN` requires the `ban` operator
For channel operators, `/msg ChanServ HOWTOBAN #channel nickname` will provide similar information about the best way to ban a user from a channel. For channel operators, `/msg ChanServ HOWTOBAN #channel nickname` will provide similar information about the best way to ban a user from a channel.
## Push notifications
Ergo now has experimental support for push notifications via the [draft/webpush](https://github.com/ircv3/ircv3-specifications/pull/471) IRCv3 specification. Support for push notifications is disabled by default; operators can enable it by setting `webpush.enabled` to `true` in the configuration file. This has security, privacy, and performance implications:
* If push notifications are enabled, Ergo will send HTTP POST requests to HTTP endpoints of the user's choosing. Although the user has limited control over the POST body (since it is encrypted with random key material), and Ergo disallows requests to local or internal IP addresses, this may potentially impact the IP reputation of the Ergo host, or allow an attacker to probe endpoints that whitelist the Ergo host's IP address.
* Push notifications result in the disclosure of metadata (that the user received a message, and the approximate time of the message) to third-party messaging infrastructure. In the typical case, this will include a push endpoint controlled by the application vendor, plus the push infrastructure controlled by Apple or Google.
* The message contents (including the sender's identity) are protected by [encryption](https://datatracker.ietf.org/doc/html/rfc8291) between the server and the user's endpoint device. However, the encryption algorithm is not forward-secret (a long-term private key is stored on the user's device) or post-quantum (the server retains a copy of the corresponding elliptic curve public key).
* Push notifications are relatively expensive to process, and may increase the impact of spam or denial-of-service attacks on the Ergo server.
Operators and end users are invited to share feedback about push notifications, either via the project issue tracker or the support channel. Note that in order to receive push notifications, the user must be logged in with always-on enabled.
------------------------------------------------------------------------------------------- -------------------------------------------------------------------------------------------

View File

@ -23,6 +23,7 @@ _Copyright © Daniel Oaks <daniel@danieloaks.net>, Shivaram Lingamneni <slingamn
- [Always-on](#always-on) - [Always-on](#always-on)
- [Multiclient](#multiclient) - [Multiclient](#multiclient)
- [History](#history) - [History](#history)
- [Push notifications](#push-notifications)
-------------------------------------------------------------------------------------------- --------------------------------------------------------------------------------------------
@ -121,3 +122,7 @@ If you have registered a channel, you can make it private. The best way to do th
1. Identify the users you want to be able to access the channel. Ensure that they have registered their accounts (you should be able to see their registration status if you `/WHOIS` their nicknames). 1. Identify the users you want to be able to access the channel. Ensure that they have registered their accounts (you should be able to see their registration status if you `/WHOIS` their nicknames).
1. Add the desired nick/account names to the invite exception list (`/mode #example +I alice`) or give them persistent voice (`/msg ChanServ AMODE #example +v alice`) 1. Add the desired nick/account names to the invite exception list (`/mode #example +I alice`) or give them persistent voice (`/msg ChanServ AMODE #example +v alice`)
1. If you want to grant a persistent channel privilege to a user, you can do it with `CS AMODE` (`/msg ChanServ AMODE #example +o bob`) 1. If you want to grant a persistent channel privilege to a user, you can do it with `CS AMODE` (`/msg ChanServ AMODE #example +o bob`)
# Push notifications
Ergo has experimental support for mobile push notifications. The server operator must enable this functionality; to check whether this is the case, you can send `/msg NickServ push list`. You must additionally be using a client that supports the functionality, and your account must be set to always-on (`/msg NickServ set always-on true`, as described above).

View File

@ -225,6 +225,18 @@ CAPDEFS = [
url="https://github.com/ircv3/ircv3-specifications/pull/543", url="https://github.com/ircv3/ircv3-specifications/pull/543",
standard="proposed IRCv3", standard="proposed IRCv3",
), ),
CapDef(
identifier="WebPush",
name="draft/webpush",
url="https://github.com/ircv3/ircv3-specifications/pull/471",
standard="proposed IRCv3",
),
CapDef(
identifier="SojuWebPush",
name="soju.im/webpush",
url="https://github.com/ircv3/ircv3-specifications/pull/471",
standard="Soju/Goguma vendor",
),
] ]
def validate_defs(): def validate_defs():

5
go.mod
View File

@ -26,7 +26,10 @@ require (
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
) )
require github.com/golang-jwt/jwt/v5 v5.2.1 require (
github.com/ergochat/webpush-go/v2 v2.0.0-rc1
github.com/golang-jwt/jwt/v5 v5.2.1
)
require ( require (
github.com/tidwall/btree v1.4.2 // indirect github.com/tidwall/btree v1.4.2 // indirect

4
go.sum
View File

@ -10,12 +10,12 @@ github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1 h1:WLHTOodthV
github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1/go.mod h1:mov+uh1DPWsltdQnOdzn08UO9GsJ3MEvhtu0Ci37fdk= github.com/ergochat/confusables v0.0.0-20201108231250-4ab98ab61fb1/go.mod h1:mov+uh1DPWsltdQnOdzn08UO9GsJ3MEvhtu0Ci37fdk=
github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881 h1:+J5m88nvybxB5AnBVGzTXM/yHVytt48rXBGcJGzSbms= github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881 h1:+J5m88nvybxB5AnBVGzTXM/yHVytt48rXBGcJGzSbms=
github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881/go.mod h1:ASYJtQujNitna6cVHsNQTGrfWvMPJ5Sa2lZlmsH65uM= github.com/ergochat/go-ident v0.0.0-20230911071154-8c30606d6881/go.mod h1:ASYJtQujNitna6cVHsNQTGrfWvMPJ5Sa2lZlmsH65uM=
github.com/ergochat/irc-go v0.5.0-rc1 h1:kFoIHExoNFQ2CV+iShAVna/H4xrXQB4t4jK5Sep2j9k=
github.com/ergochat/irc-go v0.5.0-rc1/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
github.com/ergochat/irc-go v0.5.0-rc2 h1:VuSQJF5K4hWvYSzGa4b8vgL6kzw8HF6LSOejE+RWpAo= github.com/ergochat/irc-go v0.5.0-rc2 h1:VuSQJF5K4hWvYSzGa4b8vgL6kzw8HF6LSOejE+RWpAo=
github.com/ergochat/irc-go v0.5.0-rc2/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0= github.com/ergochat/irc-go v0.5.0-rc2/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
github.com/ergochat/scram v1.0.2-ergo1 h1:2bYXiRFQH636pT0msOG39fmEYl4Eq+OuutcyDsCix/g= github.com/ergochat/scram v1.0.2-ergo1 h1:2bYXiRFQH636pT0msOG39fmEYl4Eq+OuutcyDsCix/g=
github.com/ergochat/scram v1.0.2-ergo1/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs= github.com/ergochat/scram v1.0.2-ergo1/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
github.com/ergochat/webpush-go/v2 v2.0.0-rc1 h1:CzSebM2OFM1zkAviYtkrBj5xtQc7Ka+Po607xbmZ+40=
github.com/ergochat/webpush-go/v2 v2.0.0-rc1/go.mod h1:OQlhnq8JeHDzRzAy6bdDObr19uqbHliOV+z7mHbYr4c=
github.com/ergochat/websocket v1.4.2-oragono1 h1:plMUunFBM6UoSCIYCKKclTdy/TkkHfUslhOfJQzfueM= github.com/ergochat/websocket v1.4.2-oragono1 h1:plMUunFBM6UoSCIYCKKclTdy/TkkHfUslhOfJQzfueM=
github.com/ergochat/websocket v1.4.2-oragono1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/ergochat/websocket v1.4.2-oragono1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=

View File

@ -50,7 +50,8 @@ const (
keyAccountEmailChange = "account.emailchange %s" keyAccountEmailChange = "account.emailchange %s"
// for an always-on client, a map of channel names they're in to their current modes // for an always-on client, a map of channel names they're in to their current modes
// (not to be confused with their amodes, which a non-always-on client can have): // (not to be confused with their amodes, which a non-always-on client can have):
keyAccountChannelToModes = "account.channeltomodes %s" keyAccountChannelToModes = "account.channeltomodes %s"
keyAccountPushSubscriptions = "account.pushsubscriptions %s"
maxCertfpsPerAccount = 5 maxCertfpsPerAccount = 5
) )
@ -135,6 +136,7 @@ func (am *AccountManager) createAlwaysOnClients(config *Config) {
am.loadTimeMap(keyAccountReadMarkers, accountName), am.loadTimeMap(keyAccountReadMarkers, accountName),
am.loadModes(accountName), am.loadModes(accountName),
am.loadRealname(accountName), am.loadRealname(accountName),
am.loadPushSubscriptions(accountName),
) )
} }
} }
@ -715,6 +717,40 @@ func (am *AccountManager) loadRealname(account string) (realname string) {
return return
} }
func (am *AccountManager) savePushSubscriptions(account string, subs []storedPushSubscription) {
j, err := json.Marshal(subs)
if err != nil {
am.server.logger.Error("internal", "error storing push subscriptions", err.Error())
return
}
val := string(j)
key := fmt.Sprintf(keyAccountPushSubscriptions, account)
am.server.store.Update(func(tx *buntdb.Tx) error {
tx.Set(key, val, nil)
return nil
})
return
}
func (am *AccountManager) loadPushSubscriptions(account string) (result []storedPushSubscription) {
key := fmt.Sprintf(keyAccountPushSubscriptions, account)
var val string
am.server.store.View(func(tx *buntdb.Tx) error {
val, _ = tx.Get(key)
return nil
})
if val == "" {
return nil
}
if err := json.Unmarshal([]byte(val), &result); err == nil {
return result
} else {
am.server.logger.Error("internal", "error loading push subscriptions", err.Error())
return nil
}
}
func (am *AccountManager) addRemoveCertfp(account, certfp string, add bool, hasPrivs bool) (err error) { func (am *AccountManager) addRemoveCertfp(account, certfp string, add bool, hasPrivs bool) (err error) {
certfp, err = utils.NormalizeCertfp(certfp) certfp, err = utils.NormalizeCertfp(certfp)
if err != nil { if err != nil {

View File

@ -7,7 +7,7 @@ package caps
const ( const (
// number of recognized capabilities: // number of recognized capabilities:
numCapabs = 35 numCapabs = 37
// length of the uint32 array that represents the bitset: // length of the uint32 array that represents the bitset:
bitsetLen = 2 bitsetLen = 2
) )
@ -89,6 +89,10 @@ const (
// https://github.com/ircv3/ircv3-specifications/pull/417 // https://github.com/ircv3/ircv3-specifications/pull/417
Relaymsg Capability = iota Relaymsg Capability = iota
// WebPush is the proposed IRCv3 capability named "draft/webpush":
// https://github.com/ircv3/ircv3-specifications/pull/471
WebPush Capability = iota
// EchoMessage is the IRCv3 capability named "echo-message": // EchoMessage is the IRCv3 capability named "echo-message":
// https://ircv3.net/specs/extensions/echo-message-3.2.html // https://ircv3.net/specs/extensions/echo-message-3.2.html
EchoMessage Capability = iota EchoMessage Capability = iota
@ -133,6 +137,10 @@ const (
// https://ircv3.net/specs/extensions/setname.html // https://ircv3.net/specs/extensions/setname.html
SetName Capability = iota SetName Capability = iota
// SojuWebPush is the Soju/Goguma vendor capability named "soju.im/webpush":
// https://github.com/ircv3/ircv3-specifications/pull/471
SojuWebPush Capability = iota
// StandardReplies is the IRCv3 capability named "standard-replies": // StandardReplies is the IRCv3 capability named "standard-replies":
// https://github.com/ircv3/ircv3-specifications/pull/506 // https://github.com/ircv3/ircv3-specifications/pull/506
StandardReplies Capability = iota StandardReplies Capability = iota
@ -176,6 +184,7 @@ var (
"draft/pre-away", "draft/pre-away",
"draft/read-marker", "draft/read-marker",
"draft/relaymsg", "draft/relaymsg",
"draft/webpush",
"echo-message", "echo-message",
"ergo.chat/nope", "ergo.chat/nope",
"extended-join", "extended-join",
@ -187,6 +196,7 @@ var (
"sasl", "sasl",
"server-time", "server-time",
"setname", "setname",
"soju.im/webpush",
"standard-replies", "standard-replies",
"sts", "sts",
"userhost-in-names", "userhost-in-names",

View File

@ -21,6 +21,7 @@ import (
"github.com/ergochat/ergo/irc/history" "github.com/ergochat/ergo/irc/history"
"github.com/ergochat/ergo/irc/modes" "github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/utils" "github.com/ergochat/ergo/irc/utils"
"github.com/ergochat/ergo/irc/webpush"
) )
type ChannelSettings struct { type ChannelSettings struct {
@ -222,7 +223,7 @@ func (channel *Channel) wakeWriter() {
// equivalent of Socket.send() // equivalent of Socket.send()
func (channel *Channel) writeLoop() { func (channel *Channel) writeLoop() {
defer channel.server.HandlePanic() defer channel.server.HandlePanic(nil)
for { for {
// TODO(#357) check the error value of this and implement timed backoff // TODO(#357) check the error value of this and implement timed backoff
@ -1325,7 +1326,10 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
chname = fmt.Sprintf("%s%s", modes.ChannelModePrefixes[minPrefixMode], chname) chname = fmt.Sprintf("%s%s", modes.ChannelModePrefixes[minPrefixMode], chname)
} }
if !client.server.Config().Server.Compatibility.allowTruncation { config := client.server.Config()
dispatchWebPush := false
if !config.Server.Compatibility.allowTruncation {
if !validateSplitMessageLen(histType, details.nickMask, chname, message) { if !validateSplitMessageLen(histType, details.nickMask, chname, message) {
rb.Add(nil, client.server.name, ERR_INPUTTOOLONG, details.nick, client.t("Line too long to be relayed without truncation")) rb.Add(nil, client.server.name, ERR_INPUTTOOLONG, details.nick, client.t("Line too long to be relayed without truncation"))
return return
@ -1355,6 +1359,9 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
continue continue
} }
// TODO consider when we might want to push TAGMSG
dispatchWebPush = dispatchWebPush || (config.WebPush.Enabled && histType != history.Tagmsg && member.hasPushSubscriptions())
for _, session := range member.Sessions() { for _, session := range member.Sessions() {
if session == rb.session { if session == rb.session {
continue // we already sent echo-message, if applicable continue // we already sent echo-message, if applicable
@ -1378,6 +1385,42 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
Tags: clientOnlyTags, Tags: clientOnlyTags,
IsBot: isBot, IsBot: isBot,
}, details.account) }, details.account)
if dispatchWebPush {
channel.dispatchWebPush(client, command, details.nickMask, details.accountName, chname, message)
}
}
}
func (channel *Channel) dispatchWebPush(client *Client, command, nuh, accountName, chname string, msg utils.SplitMessage) {
msgBytes, err := webpush.MakePushMessage(command, nuh, accountName, chname, msg)
if err != nil {
channel.server.logger.Error("internal", "can't serialize push message", err.Error())
return
}
messageText := strings.ToLower(msg.CombinedValue())
for _, member := range channel.Members() {
if member == client {
continue // don't push to the client's own devices even if they mentioned themself
}
if !member.hasPushSubscriptions() {
continue
}
// this is the casefolded account name for comparison to the casefolded message text:
account := member.Account()
if account == "" {
continue
}
if !webpush.IsHighlight(messageText, account) {
continue
}
member.dispatchPushMessage(pushMessage{
msg: msgBytes,
urgency: webpush.UrgencyHigh,
cftarget: channel.NameCasefolded(),
time: msg.Time,
})
} }
} }

View File

@ -6,6 +6,7 @@
package irc package irc
import ( import (
"context"
"crypto/x509" "crypto/x509"
"fmt" "fmt"
"maps" "maps"
@ -32,6 +33,7 @@ import (
"github.com/ergochat/ergo/irc/oauth2" "github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/sno" "github.com/ergochat/ergo/irc/sno"
"github.com/ergochat/ergo/irc/utils" "github.com/ergochat/ergo/irc/utils"
"github.com/ergochat/ergo/irc/webpush"
) )
const ( const (
@ -46,6 +48,10 @@ const (
// maximum total read markers that can be stored // maximum total read markers that can be stored
// (writeback of read markers is controlled by lastSeen logic) // (writeback of read markers is controlled by lastSeen logic)
maxReadMarkers = 256 maxReadMarkers = 256
// should be long enough to handle multiple notifications in rapid succession,
// short enough that it doesn't waste a lot of RAM per client
pushQueueLengthPerClient = 16
) )
const ( const (
@ -71,52 +77,57 @@ var (
// Client is an IRC client. // Client is an IRC client.
type Client struct { type Client struct {
account string account string
accountName string // display name of the account: uncasefolded, '*' if not logged in accountName string // display name of the account: uncasefolded, '*' if not logged in
accountRegDate time.Time accountRegDate time.Time
accountSettings AccountSettings accountSettings AccountSettings
awayMessage string awayMessage string
channels ChannelSet channels ChannelSet
ctime time.Time ctime time.Time
destroyed bool destroyed bool
modes modes.ModeSet modes modes.ModeSet
hostname string hostname string
invitedTo map[string]channelInvite invitedTo map[string]channelInvite
isSTSOnly bool isSTSOnly bool
isKlined bool // #1941: k-line kills are special-cased to suppress some triggered notices/events isKlined bool // #1941: k-line kills are special-cased to suppress some triggered notices/events
languages []string languages []string
lastActive time.Time // last time they sent a command that wasn't PONG or similar lastActive time.Time // last time they sent a command that wasn't PONG or similar
lastSeen map[string]time.Time // maps device ID (including "") to time of last received command lastSeen map[string]time.Time // maps device ID (including "") to time of last received command
readMarkers map[string]time.Time // maps casefolded target to time of last read marker readMarkers map[string]time.Time // maps casefolded target to time of last read marker
loginThrottle connection_limits.GenericThrottle loginThrottle connection_limits.GenericThrottle
nextSessionID int64 // Incremented when a new session is established nextSessionID int64 // Incremented when a new session is established
nick string nick string
nickCasefolded string nickCasefolded string
nickMaskCasefolded string nickMaskCasefolded string
nickMaskString string // cache for nickmask string since it's used with lots of replies nickMaskString string // cache for nickmask string since it's used with lots of replies
oper *Oper oper *Oper
preregNick string preregNick string
proxiedIP net.IP // actual remote IP if using the PROXY protocol proxiedIP net.IP // actual remote IP if using the PROXY protocol
rawHostname string rawHostname string
cloakedHostname string cloakedHostname string
realname string realname string
realIP net.IP realIP net.IP
requireSASLMessage string requireSASLMessage string
requireSASL bool requireSASL bool
registered bool registered bool
registerCmdSent bool // already sent the draft/register command, can't send it again registerCmdSent bool // already sent the draft/register command, can't send it again
dirtyTimestamps bool // lastSeen or readMarkers is dirty dirtyTimestamps bool // lastSeen or readMarkers is dirty
registrationTimer *time.Timer registrationTimer *time.Timer
server *Server server *Server
skeleton string skeleton string
sessions []*Session sessions []*Session
stateMutex sync.RWMutex // tier 1 stateMutex sync.RWMutex // tier 1
alwaysOn bool alwaysOn bool
username string username string
vhost string vhost string
history history.Buffer history history.Buffer
dirtyBits uint dirtyBits uint
writebackLock sync.Mutex // tier 1.5 writebackLock sync.Mutex // tier 1.5
pushSubscriptions map[string]*pushSubscription
cachedPushSubscriptions []storedPushSubscription
clearablePushMessages map[string]time.Time
pushSubscriptionsExist atomic.Uint32 // this is a cache on len(pushSubscriptions) != 0
pushQueue pushQueue
} }
type saslStatus struct { type saslStatus struct {
@ -198,6 +209,8 @@ type Session struct {
autoreplayMissedSince time.Time autoreplayMissedSince time.Time
batch MultilineBatch batch MultilineBatch
webPushEndpoint string // goroutine-local: web push endpoint registered by the current session
} }
// MultilineBatch tracks the state of a client-to-server multiline batch. // MultilineBatch tracks the state of a client-to-server multiline batch.
@ -407,7 +420,7 @@ func (server *Server) RunClient(conn IRCConn) {
client.run(session) client.run(session)
} }
func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus map[string]alwaysOnChannelStatus, lastSeen, readMarkers map[string]time.Time, uModes modes.Modes, realname string) { func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus map[string]alwaysOnChannelStatus, lastSeen, readMarkers map[string]time.Time, uModes modes.Modes, realname string, pushSubscriptions []storedPushSubscription) {
now := time.Now().UTC() now := time.Now().UTC()
config := server.Config() config := server.Config()
if lastSeen == nil && account.Settings.AutoreplayMissed { if lastSeen == nil && account.Settings.AutoreplayMissed {
@ -484,6 +497,14 @@ func (server *Server) AddAlwaysOnClient(account ClientAccount, channelToStatus m
if persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway) { if persistenceEnabled(config.Accounts.Multiclient.AutoAway, client.accountSettings.AutoAway) {
client.setAutoAwayNoMutex(config) client.setAutoAwayNoMutex(config)
} }
if len(pushSubscriptions) != 0 {
client.pushSubscriptions = make(map[string]*pushSubscription, len(pushSubscriptions))
for _, sub := range pushSubscriptions {
client.pushSubscriptions[sub.Endpoint] = newPushSubscription(sub)
}
}
client.rebuildPushSubscriptionCache()
} }
func (client *Client) resizeHistory(config *Config) { func (client *Client) resizeHistory(config *Config) {
@ -1780,6 +1801,7 @@ const (
IncludeChannels uint = 1 << iota IncludeChannels uint = 1 << iota
IncludeUserModes IncludeUserModes
IncludeRealname IncludeRealname
IncludePushSubscriptions
) )
func (client *Client) markDirty(dirtyBits uint) { func (client *Client) markDirty(dirtyBits uint) {
@ -1800,7 +1822,7 @@ func (client *Client) wakeWriter() {
} }
func (client *Client) writeLoop() { func (client *Client) writeLoop() {
defer client.server.HandlePanic() defer client.server.HandlePanic(nil)
for { for {
client.performWrite(0) client.performWrite(0)
@ -1858,6 +1880,9 @@ func (client *Client) performWrite(additionalDirtyBits uint) {
if (dirtyBits & IncludeRealname) != 0 { if (dirtyBits & IncludeRealname) != 0 {
client.server.accounts.saveRealname(account, client.realname) client.server.accounts.saveRealname(account, client.realname)
} }
if (dirtyBits & IncludePushSubscriptions) != 0 {
client.server.accounts.savePushSubscriptions(account, client.getPushSubscriptions())
}
} }
// Blocking store; see Channel.Store and Socket.BlockingWrite // Blocking store; see Channel.Store and Socket.BlockingWrite
@ -1877,3 +1902,134 @@ func (client *Client) Store(dirtyBits uint) (err error) {
client.performWrite(dirtyBits) client.performWrite(dirtyBits)
return nil return nil
} }
// pushSubscription represents all the data we track about the state of a push subscription;
// right now every field is persisted, but we may want to persist only a subset in future
type pushSubscription struct {
storedPushSubscription
}
// storedPushSubscription represents a subscription as stored in the database
type storedPushSubscription struct {
Endpoint string
Keys webpush.Keys
LastRefresh time.Time // last time the client sent WEBPUSH REGISTER for this endpoint
LastSuccess time.Time // last time we successfully pushed to this endpoint
}
func newPushSubscription(sub storedPushSubscription) *pushSubscription {
return &pushSubscription{
storedPushSubscription: sub,
// TODO any other initialization here, like rate limiting
}
}
type pushMessage struct {
msg []byte
urgency webpush.Urgency
originatingEndpoint string
cftarget string
time time.Time
}
type pushQueue struct {
workerLock sync.Mutex
queue chan pushMessage
once sync.Once
dropped atomic.Uint64
}
func (c *Client) ensurePushInitialized() {
c.pushQueue.once.Do(c.initializePush)
}
func (c *Client) initializePush() {
// allocate the queue
c.pushQueue.queue = make(chan pushMessage, pushQueueLengthPerClient)
}
func (client *Client) dispatchPushMessage(msg pushMessage) {
client.ensurePushInitialized()
select {
case client.pushQueue.queue <- msg:
if client.pushQueue.workerLock.TryLock() {
go client.pushWorker()
}
default:
client.pushQueue.dropped.Add(1)
}
}
func (client *Client) pushWorker() {
defer client.server.HandlePanic(nil)
defer client.pushQueue.workerLock.Unlock()
for {
select {
case msg := <-client.pushQueue.queue:
for _, sub := range client.getPushSubscriptions() {
if !client.skipPushMessage(msg) {
client.sendAndTrackPush(sub.Endpoint, sub.Keys, msg, true)
}
}
default:
// no more messages, end the goroutine and release the trylock
return
}
}
}
// skipPushMessage waits up to the configured delay for the client to send MARKREAD;
// it returns whether the message has been read
func (client *Client) skipPushMessage(msg pushMessage) bool {
if msg.cftarget == "" || msg.time.IsZero() {
return false
}
config := client.server.Config()
if config.WebPush.Delay == 0 {
return false
}
deadline := msg.time.Add(config.WebPush.Delay)
pause := time.Until(deadline)
if pause > 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
}

View File

@ -253,15 +253,14 @@ func (clients *ClientManager) AllClients() (result []*Client) {
return return
} }
// AllWithCapsNotify returns all clients with the given capabilities, and that support cap-notify. // AllWithCapsNotify returns all sessions that support cap-notify.
func (clients *ClientManager) AllWithCapsNotify(capabs ...caps.Capability) (sessions []*Session) { func (clients *ClientManager) AllWithCapsNotify() (sessions []*Session) {
capabs = append(capabs, caps.CapNotify)
clients.RLock() clients.RLock()
defer clients.RUnlock() defer clients.RUnlock()
for _, client := range clients.byNick { for _, client := range clients.byNick {
for _, session := range client.Sessions() { for _, session := range client.Sessions() {
// cap-notify is implicit in cap version 302 and above // 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) sessions = append(sessions, session)
} }
} }
@ -270,6 +269,18 @@ func (clients *ClientManager) AllWithCapsNotify(capabs ...caps.Capability) (sess
return 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. // FindAll returns all clients that match the given userhost mask.
func (clients *ClientManager) FindAll(userhost string) (set ClientSet) { func (clients *ClientManager) FindAll(userhost string) (set ClientSet) {
set = make(ClientSet) set = make(ClientSet)

View File

@ -367,6 +367,10 @@ func init() {
usablePreReg: true, usablePreReg: true,
minParams: 4, minParams: 4,
}, },
"WEBPUSH": {
handler: webpushHandler,
minParams: 2,
},
"WHO": { "WHO": {
handler: whoHandler, handler: whoHandler,
minParams: 1, minParams: 1,

View File

@ -41,6 +41,7 @@ import (
"github.com/ergochat/ergo/irc/oauth2" "github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/passwd" "github.com/ergochat/ergo/irc/passwd"
"github.com/ergochat/ergo/irc/utils" "github.com/ergochat/ergo/irc/utils"
"github.com/ergochat/ergo/irc/webpush"
) )
// here's how this works: exported (capitalized) members of the config structs // here's how this works: exported (capitalized) members of the config structs
@ -708,6 +709,16 @@ type Config struct {
} `yaml:"tagmsg-storage"` } `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 Filename string
} }
@ -1572,6 +1583,29 @@ func LoadConfig(filename string) (config *Config, err error) {
return nil, err 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: // now that all postprocessing is complete, regenerate ISUPPORT:
err = config.generateISupport() err = config.generateISupport()
if err != nil { if err != nil {
@ -1666,6 +1700,13 @@ func (config *Config) generateISupport() (err error) {
if config.Server.EnforceUtf8 { if config.Server.EnforceUtf8 {
isupport.Add("UTF8ONLY", "") 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", "") isupport.Add("WHOX", "")
err = isupport.RegenerateCachedReply() err = isupport.RegenerateCachedReply()

View File

@ -18,6 +18,7 @@ import (
"github.com/ergochat/ergo/irc/datastore" "github.com/ergochat/ergo/irc/datastore"
"github.com/ergochat/ergo/irc/modes" "github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/utils" "github.com/ergochat/ergo/irc/utils"
"github.com/ergochat/ergo/irc/webpush"
"github.com/tidwall/buntdb" "github.com/tidwall/buntdb"
) )
@ -27,15 +28,17 @@ const (
// 'version' of the database schema // 'version' of the database schema
// latest schema of the db // latest schema of the db
latestDbSchema = 23 latestDbSchema = 24
) )
var ( var (
schemaVersionUUID = utils.UUID{0, 255, 85, 13, 212, 10, 191, 121, 245, 152, 142, 89, 97, 141, 219, 87} // AP9VDdQKv3n1mI5ZYY3bVw 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 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) keySchemaVersion = bunt.BuntKey(datastore.TableMetadata, schemaVersionUUID)
keyCloakSecret = bunt.BuntKey(datastore.TableMetadata, cloakSecretUUID) keyCloakSecret = bunt.BuntKey(datastore.TableMetadata, cloakSecretUUID)
keyVAPIDKeys = bunt.BuntKey(datastore.TableMetadata, vapidKeysUUID)
) )
type SchemaChanger func(*Config, *buntdb.Tx) error type SchemaChanger func(*Config, *buntdb.Tx) error
@ -80,6 +83,15 @@ func initializeDB(path string) error {
// set schema version // set schema version
tx.Set(keySchemaVersion, strconv.Itoa(latestDbSchema), nil) tx.Set(keySchemaVersion, strconv.Itoa(latestDbSchema), nil)
tx.Set(keyCloakSecret, utils.GenerateSecretKey(), 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 return nil
}) })
@ -233,6 +245,16 @@ func StoreCloakSecret(dstore datastore.Datastore, secret string) {
dstore.Set(datastore.TableMetadata, cloakSecretUUID, []byte(secret), time.Time{}) 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 { func schemaChangeV1toV2(config *Config, tx *buntdb.Tx) error {
// == version 1 -> 2 == // == version 1 -> 2 ==
// account key changes and account.verified key bugfix. // account key changes and account.verified key bugfix.
@ -1218,6 +1240,20 @@ func schemaChangeV22ToV23(config *Config, tx *buntdb.Tx) error {
return nil 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) { func getSchemaChange(initialVersion int) (result SchemaChange, ok bool) {
for _, change := range allChanges { for _, change := range allChanges {
if initialVersion == change.InitialVersion { if initialVersion == change.InitialVersion {
@ -1338,4 +1374,9 @@ var allChanges = []SchemaChange{
TargetVersion: 23, TargetVersion: 23,
Changer: schemaChangeV22ToV23, Changer: schemaChangeV22ToV23,
}, },
{
InitialVersion: 23,
TargetVersion: 24,
Changer: schemaChangeV23ToV24,
},
} }

View File

@ -13,6 +13,7 @@ import (
"github.com/ergochat/ergo/irc/languages" "github.com/ergochat/ergo/irc/languages"
"github.com/ergochat/ergo/irc/modes" "github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/utils" "github.com/ergochat/ergo/irc/utils"
"github.com/ergochat/ergo/irc/webpush"
) )
func (server *Server) Config() (config *Config) { func (server *Server) Config() (config *Config) {
@ -511,6 +512,13 @@ func (client *Client) GetReadMarker(cfname string) (result string) {
return "*" 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) { func (client *Client) copyReadMarkers() (result map[string]time.Time) {
client.stateMutex.RLock() client.stateMutex.RLock()
defer client.stateMutex.RUnlock() defer client.stateMutex.RUnlock()
@ -549,6 +557,28 @@ func updateLRUMap(lru map[string]time.Time, key string, val time.Time, maxItems
return val 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) { func (client *Client) shouldFlushTimestamps() (result bool) {
client.stateMutex.Lock() client.stateMutex.Lock()
defer client.stateMutex.Unlock() defer client.stateMutex.Unlock()
@ -564,6 +594,127 @@ func (client *Client) setKlined() {
client.stateMutex.Unlock() 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 { func (channel *Channel) Name() string {
channel.stateMutex.RLock() channel.stateMutex.RLock()
defer channel.stateMutex.RUnlock() defer channel.stateMutex.RUnlock()

View File

@ -33,6 +33,7 @@ import (
"github.com/ergochat/ergo/irc/oauth2" "github.com/ergochat/ergo/irc/oauth2"
"github.com/ergochat/ergo/irc/sno" "github.com/ergochat/ergo/irc/sno"
"github.com/ergochat/ergo/irc/utils" "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 // 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, Tags: tags,
} }
client.addHistoryItem(user, item, &details, &tDetails, config) 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) 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 return
} }
@ -3590,6 +3617,88 @@ func webircHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
return true return true
} }
// WEBPUSH <subcommand> <endpoint> [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' type whoxFields uint32 // bitset to hold the WHOX field values, 'a' through 'z'
func (fields whoxFields) Add(field rune) (result whoxFields) { func (fields whoxFields) Add(field rune) (result whoxFields) {

View File

@ -610,6 +610,11 @@ ircv3.net/specs/extensions/webirc.html
the connection from the client to the gateway, such as: the connection from the client to the gateway, such as:
- tls: this flag indicates that the client->gateway connection is secure`, - tls: this flag indicates that the client->gateway connection is secure`,
},
"webpush": {
text: `WEBPUSH <subcommand> [arguments]
Configures web push settings. Not for direct use by end users.`,
}, },
"who": { "who": {
text: `WHO <name> [o] text: `WHO <name> [o]

View File

@ -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) { func histservExportAndNotify(service *ircService, server *Server, cfAccount string, outfile *os.File, filename, alertNick string) {
defer server.HandlePanic() defer server.HandlePanic(nil)
defer outfile.Close() defer outfile.Close()
writer := bufio.NewWriter(outfile) writer := bufio.NewWriter(outfile)

View File

@ -17,6 +17,7 @@ import (
"github.com/ergochat/ergo/irc/datastore" "github.com/ergochat/ergo/irc/datastore"
"github.com/ergochat/ergo/irc/modes" "github.com/ergochat/ergo/irc/modes"
"github.com/ergochat/ergo/irc/utils" "github.com/ergochat/ergo/irc/utils"
"github.com/ergochat/ergo/irc/webpush"
) )
const ( const (
@ -24,7 +25,7 @@ const (
// XXX instead of referencing, e.g., keyAccountExists, we should write in the string literal // 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 // (to ensure that no matter what code changes happen elsewhere, we're still producing a
// db of the hardcoded version) // db of the hardcoded version)
importDBSchemaVersion = 23 importDBSchemaVersion = 24
) )
type userImport struct { type userImport struct {
@ -82,6 +83,15 @@ func doImportDBGeneric(config *Config, dbImport databaseImport, credsType Creden
tx.Set(keySchemaVersion, strconv.Itoa(importDBSchemaVersion), nil) tx.Set(keySchemaVersion, strconv.Itoa(importDBSchemaVersion), nil)
tx.Set(keyCloakSecret, utils.GenerateSecretKey(), 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]) cfUsernames := make(utils.HashSet[string])
skeletonToUsername := make(map[string]string) skeletonToUsername := make(map[string]string)

View File

@ -241,6 +241,18 @@ indicate an empty password, use * instead.`,
"password": { "password": {
aliasOf: "passwd", aliasOf: "passwd",
}, },
"push": {
handler: nsPushHandler,
help: `Syntax: $bPUSH LIST$b
Or: $bPUSH DELETE <endpoint>$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": { "get": {
handler: nsGetHandler, handler: nsGetHandler,
help: `Syntax: $bGET <setting>$b help: `Syntax: $bGET <setting>$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"))
}
}

View File

@ -6,14 +6,19 @@ package irc
import ( import (
"fmt" "fmt"
"runtime/debug" "runtime/debug"
"time"
) )
// HandlePanic is a general-purpose panic handler for ad-hoc goroutines. // HandlePanic is a general-purpose panic handler for ad-hoc goroutines.
// Because of the semantics of `recover`, it must be called directly // Because of the semantics of `recover`, it must be called directly
// from the routine on whose call stack the panic would occur, with `defer`, // from the routine on whose call stack the panic would occur, with `defer`,
// e.g. `defer server.HandlePanic()` // e.g. `defer server.HandlePanic()`
func (server *Server) HandlePanic() { func (server *Server) HandlePanic(restartable func()) {
if r := recover(); r != nil { if r := recover(); r != nil {
server.logger.Error("internal", fmt.Sprintf("Panic encountered: %v\n%s", r, debug.Stack())) server.logger.Error("internal", fmt.Sprintf("Panic encountered: %v\n%s", r, debug.Stack()))
if restartable != nil {
time.Sleep(time.Second)
go restartable()
}
} }
} }

View File

@ -36,10 +36,12 @@ import (
"github.com/ergochat/ergo/irc/mysql" "github.com/ergochat/ergo/irc/mysql"
"github.com/ergochat/ergo/irc/sno" "github.com/ergochat/ergo/irc/sno"
"github.com/ergochat/ergo/irc/utils" "github.com/ergochat/ergo/irc/utils"
"github.com/ergochat/ergo/irc/webpush"
) )
const ( const (
alwaysOnMaintenanceInterval = 30 * time.Minute alwaysOnMaintenanceInterval = 30 * time.Minute
pushMaintenanceInterval = 24 * time.Hour
) )
var ( var (
@ -135,6 +137,7 @@ func NewServer(config *Config, logger *logger.Manager) (*Server, error) {
} }
time.AfterFunc(alwaysOnMaintenanceInterval, server.periodicAlwaysOnMaintenance) time.AfterFunc(alwaysOnMaintenanceInterval, server.periodicAlwaysOnMaintenance)
time.AfterFunc(pushMaintenanceInterval, server.periodicPushMaintenance)
return server, nil return server, nil
} }
@ -267,7 +270,7 @@ func (server *Server) periodicAlwaysOnMaintenance() {
time.AfterFunc(alwaysOnMaintenanceInterval, 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.logger.Info("accounts", "Performing periodic always-on client checks")
server.performAlwaysOnMaintenance(true, true) 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: // handles server.ip-check-script.exempt-sasl:
// run the ip check script at the end of the handshake, only for anonymous connections // 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) { 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. // rehash reloads the config and applies the changes from the config file.
func (server *Server) rehash() error { func (server *Server) rehash() error {
// #1570; this needs its own panic handling because it can be invoked via SIGHUP // #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") 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) return fmt.Errorf("Could not load cloak secret: %w", err)
} }
config.Server.Cloaks.SetSecret(cloakSecret) 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 // activate the new config
server.config.Store(config) server.config.Store(config)

View File

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

View File

@ -95,6 +95,20 @@ func (sm *SplitMessage) Is512() bool {
return sm.Split == nil 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, // TokenLineBuilder is a helper for building IRC lines composed of delimited tokens,
// with a maximum line length. // with a maximum line length.
type TokenLineBuilder struct { type TokenLineBuilder struct {

View File

@ -66,3 +66,15 @@ func BenchmarkTokenLines(b *testing.B) {
tl.Lines() 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)
}

15
irc/utils/time.go Normal file
View File

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

60
irc/webpush/highlight.go Normal file
View File

@ -0,0 +1,60 @@
// Copyright (c) 2021-2024 Simon Ser <contact@emersion.fr>
// 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):]
}
}

66
irc/webpush/security.go Normal file
View File

@ -0,0 +1,66 @@
// Copyright (c) 2024 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// Released under the MIT license
// Some portions of this code are:
// Copyright (c) 2024 Simon Ser <contact@emersion.fr>
// 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()
}

View File

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

148
irc/webpush/webpush.go Normal file
View File

@ -0,0 +1,148 @@
// Copyright (c) 2024 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// Released under the MIT license
// Some portions of this code are:
// Copyright (c) 2021-2024 Simon Ser <contact@emersion.fr>
// 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)
}
}

View File

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

View File

@ -893,6 +893,7 @@ fakelag:
"MARKREAD": 16 "MARKREAD": 16
"MONITOR": 1 "MONITOR": 1
"WHO": 4 "WHO": 4
"WEBPUSH": 1
# the roleplay commands are semi-standardized extensions to IRC that allow # the roleplay commands are semi-standardized extensions to IRC that allow
# sending and receiving messages from pseudo-nicknames. this can be used either # 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, # 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. # e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details.
allow-environment-overrides: true 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

View File

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

6
vendor/github.com/ergochat/webpush-go/v2/.gitignore generated vendored Normal file
View File

@ -0,0 +1,6 @@
vendor/**
.DS_Store
*.out
*.swp

14
vendor/github.com/ergochat/webpush-go/v2/CHANGELOG.md generated vendored Normal file
View File

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

21
vendor/github.com/ergochat/webpush-go/v2/LICENSE generated vendored Normal file
View File

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

6
vendor/github.com/ergochat/webpush-go/v2/Makefile generated vendored Normal file
View File

@ -0,0 +1,6 @@
.PHONY: test
test:
go test .
go vet .
./.check-gofmt.sh

65
vendor/github.com/ergochat/webpush-go/v2/README.md generated vendored Normal file
View File

@ -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("<YOUR_SUBSCRIPTION>"), s)
vapidKeys := new(webpush.VAPIDKeys)
json.Unmarshal([]byte("<YOUR_VAPID_KEYS">), 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)

76
vendor/github.com/ergochat/webpush-go/v2/legacy.go generated vendored Normal file
View File

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

26
vendor/github.com/ergochat/webpush-go/v2/urgency.go generated vendored Normal file
View File

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

177
vendor/github.com/ergochat/webpush-go/v2/vapid.go generated vendored Normal file
View File

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

323
vendor/github.com/ergochat/webpush-go/v2/webpush.go generated vendored Normal file
View File

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

95
vendor/golang.org/x/crypto/hkdf/hkdf.go generated vendored Normal file
View File

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

4
vendor/modules.txt vendored
View File

@ -22,6 +22,9 @@ github.com/ergochat/irc-go/ircfmt
github.com/ergochat/irc-go/ircmsg github.com/ergochat/irc-go/ircmsg
github.com/ergochat/irc-go/ircreader github.com/ergochat/irc-go/ircreader
github.com/ergochat/irc-go/ircutils 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 # github.com/go-sql-driver/mysql v1.7.0
## explicit; go 1.13 ## explicit; go 1.13
github.com/go-sql-driver/mysql github.com/go-sql-driver/mysql
@ -83,6 +86,7 @@ github.com/xdg-go/scram
## explicit; go 1.20 ## explicit; go 1.20
golang.org/x/crypto/bcrypt golang.org/x/crypto/bcrypt
golang.org/x/crypto/blowfish golang.org/x/crypto/blowfish
golang.org/x/crypto/hkdf
golang.org/x/crypto/pbkdf2 golang.org/x/crypto/pbkdf2
golang.org/x/crypto/sha3 golang.org/x/crypto/sha3
# golang.org/x/sys v0.22.0 # golang.org/x/sys v0.22.0