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:
parent
efd3764337
commit
36e5451aa5
22
default.yaml
22
default.yaml
@ -922,6 +922,7 @@ fakelag:
|
||||
"MARKREAD": 16
|
||||
"MONITOR": 1
|
||||
"WHO": 4
|
||||
"WEBPUSH": 1
|
||||
|
||||
# the roleplay commands are semi-standardized extensions to IRC that allow
|
||||
# sending and receiving messages from pseudo-nicknames. this can be used either
|
||||
@ -1067,3 +1068,24 @@ history:
|
||||
# whether to allow customization of the config at runtime using environment variables,
|
||||
# e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details.
|
||||
allow-environment-overrides: true
|
||||
|
||||
# experimental support for mobile push notifications
|
||||
# see the manual for potential security, privacy, and performance implications.
|
||||
# DO NOT enable if you are running a Tor or I2P hidden service (i.e. one
|
||||
# with no public IP listeners, only Tor/I2P listeners).
|
||||
webpush:
|
||||
# are push notifications enabled at all?
|
||||
enabled: false
|
||||
# request timeout for POST'ing the http notification
|
||||
timeout: 10s
|
||||
# delay sending the notification for this amount of time, then suppress it
|
||||
# if the client sent MARKREAD to indicate that it was read on another device
|
||||
delay: 0s
|
||||
# subscriber field for the VAPID JWT authorization:
|
||||
#subscriber: "https://your-website.com/"
|
||||
# maximum number of push subscriptions per user
|
||||
max-subscriptions: 4
|
||||
# expiration time for a push subscription; it must be renewed within this time
|
||||
# by the client reconnecting to IRC. we also detect whether the client is no longer
|
||||
# successfully receiving push messages.
|
||||
expiration: 14d
|
||||
|
@ -44,6 +44,7 @@ _Copyright © Daniel Oaks <daniel@danieloaks.net>, Shivaram Lingamneni <slingamn
|
||||
- [Persistent history with MySQL](#persistent-history-with-mysql)
|
||||
- [IP cloaking](#ip-cloaking)
|
||||
- [Moderation](#moderation)
|
||||
- [Push notifications](#push-notifications)
|
||||
- [Frequently Asked Questions](#frequently-asked-questions)
|
||||
- [IRC over TLS](#irc-over-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.
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
-------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
@ -23,6 +23,7 @@ _Copyright © Daniel Oaks <daniel@danieloaks.net>, Shivaram Lingamneni <slingamn
|
||||
- [Always-on](#always-on)
|
||||
- [Multiclient](#multiclient)
|
||||
- [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. 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`)
|
||||
|
||||
# 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).
|
||||
|
@ -225,6 +225,18 @@ CAPDEFS = [
|
||||
url="https://github.com/ircv3/ircv3-specifications/pull/543",
|
||||
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():
|
||||
|
5
go.mod
5
go.mod
@ -26,7 +26,10 @@ require (
|
||||
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 (
|
||||
github.com/tidwall/btree v1.4.2 // indirect
|
||||
|
4
go.sum
4
go.sum
@ -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/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/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/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/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/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
|
@ -51,6 +51,7 @@ const (
|
||||
// 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):
|
||||
keyAccountChannelToModes = "account.channeltomodes %s"
|
||||
keyAccountPushSubscriptions = "account.pushsubscriptions %s"
|
||||
|
||||
maxCertfpsPerAccount = 5
|
||||
)
|
||||
@ -135,6 +136,7 @@ func (am *AccountManager) createAlwaysOnClients(config *Config) {
|
||||
am.loadTimeMap(keyAccountReadMarkers, accountName),
|
||||
am.loadModes(accountName),
|
||||
am.loadRealname(accountName),
|
||||
am.loadPushSubscriptions(accountName),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -715,6 +717,40 @@ func (am *AccountManager) loadRealname(account string) (realname string) {
|
||||
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) {
|
||||
certfp, err = utils.NormalizeCertfp(certfp)
|
||||
if err != nil {
|
||||
|
@ -7,7 +7,7 @@ package caps
|
||||
|
||||
const (
|
||||
// number of recognized capabilities:
|
||||
numCapabs = 35
|
||||
numCapabs = 37
|
||||
// length of the uint32 array that represents the bitset:
|
||||
bitsetLen = 2
|
||||
)
|
||||
@ -89,6 +89,10 @@ const (
|
||||
// https://github.com/ircv3/ircv3-specifications/pull/417
|
||||
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":
|
||||
// https://ircv3.net/specs/extensions/echo-message-3.2.html
|
||||
EchoMessage Capability = iota
|
||||
@ -133,6 +137,10 @@ const (
|
||||
// https://ircv3.net/specs/extensions/setname.html
|
||||
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":
|
||||
// https://github.com/ircv3/ircv3-specifications/pull/506
|
||||
StandardReplies Capability = iota
|
||||
@ -176,6 +184,7 @@ var (
|
||||
"draft/pre-away",
|
||||
"draft/read-marker",
|
||||
"draft/relaymsg",
|
||||
"draft/webpush",
|
||||
"echo-message",
|
||||
"ergo.chat/nope",
|
||||
"extended-join",
|
||||
@ -187,6 +196,7 @@ var (
|
||||
"sasl",
|
||||
"server-time",
|
||||
"setname",
|
||||
"soju.im/webpush",
|
||||
"standard-replies",
|
||||
"sts",
|
||||
"userhost-in-names",
|
||||
|
@ -21,6 +21,7 @@ import (
|
||||
"github.com/ergochat/ergo/irc/history"
|
||||
"github.com/ergochat/ergo/irc/modes"
|
||||
"github.com/ergochat/ergo/irc/utils"
|
||||
"github.com/ergochat/ergo/irc/webpush"
|
||||
)
|
||||
|
||||
type ChannelSettings struct {
|
||||
@ -222,7 +223,7 @@ func (channel *Channel) wakeWriter() {
|
||||
|
||||
// equivalent of Socket.send()
|
||||
func (channel *Channel) writeLoop() {
|
||||
defer channel.server.HandlePanic()
|
||||
defer channel.server.HandlePanic(nil)
|
||||
|
||||
for {
|
||||
// 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)
|
||||
}
|
||||
|
||||
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) {
|
||||
rb.Add(nil, client.server.name, ERR_INPUTTOOLONG, details.nick, client.t("Line too long to be relayed without truncation"))
|
||||
return
|
||||
@ -1355,6 +1359,9 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
|
||||
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() {
|
||||
if session == rb.session {
|
||||
continue // we already sent echo-message, if applicable
|
||||
@ -1378,6 +1385,42 @@ func (channel *Channel) SendSplitMessage(command string, minPrefixMode modes.Mod
|
||||
Tags: clientOnlyTags,
|
||||
IsBot: isBot,
|
||||
}, 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
160
irc/client.go
160
irc/client.go
@ -6,6 +6,7 @@
|
||||
package irc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"maps"
|
||||
@ -32,6 +33,7 @@ import (
|
||||
"github.com/ergochat/ergo/irc/oauth2"
|
||||
"github.com/ergochat/ergo/irc/sno"
|
||||
"github.com/ergochat/ergo/irc/utils"
|
||||
"github.com/ergochat/ergo/irc/webpush"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -46,6 +48,10 @@ const (
|
||||
// maximum total read markers that can be stored
|
||||
// (writeback of read markers is controlled by lastSeen logic)
|
||||
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 (
|
||||
@ -117,6 +123,11 @@ type Client struct {
|
||||
history history.Buffer
|
||||
dirtyBits uint
|
||||
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 {
|
||||
@ -198,6 +209,8 @@ type Session struct {
|
||||
autoreplayMissedSince time.Time
|
||||
|
||||
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.
|
||||
@ -407,7 +420,7 @@ func (server *Server) RunClient(conn IRCConn) {
|
||||
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()
|
||||
config := server.Config()
|
||||
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) {
|
||||
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) {
|
||||
@ -1780,6 +1801,7 @@ const (
|
||||
IncludeChannels uint = 1 << iota
|
||||
IncludeUserModes
|
||||
IncludeRealname
|
||||
IncludePushSubscriptions
|
||||
)
|
||||
|
||||
func (client *Client) markDirty(dirtyBits uint) {
|
||||
@ -1800,7 +1822,7 @@ func (client *Client) wakeWriter() {
|
||||
}
|
||||
|
||||
func (client *Client) writeLoop() {
|
||||
defer client.server.HandlePanic()
|
||||
defer client.server.HandlePanic(nil)
|
||||
|
||||
for {
|
||||
client.performWrite(0)
|
||||
@ -1858,6 +1880,9 @@ func (client *Client) performWrite(additionalDirtyBits uint) {
|
||||
if (dirtyBits & IncludeRealname) != 0 {
|
||||
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
|
||||
@ -1877,3 +1902,134 @@ func (client *Client) Store(dirtyBits uint) (err error) {
|
||||
client.performWrite(dirtyBits)
|
||||
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
|
||||
}
|
||||
|
@ -253,15 +253,14 @@ func (clients *ClientManager) AllClients() (result []*Client) {
|
||||
return
|
||||
}
|
||||
|
||||
// AllWithCapsNotify returns all clients with the given capabilities, and that support cap-notify.
|
||||
func (clients *ClientManager) AllWithCapsNotify(capabs ...caps.Capability) (sessions []*Session) {
|
||||
capabs = append(capabs, caps.CapNotify)
|
||||
// AllWithCapsNotify returns all sessions that support cap-notify.
|
||||
func (clients *ClientManager) AllWithCapsNotify() (sessions []*Session) {
|
||||
clients.RLock()
|
||||
defer clients.RUnlock()
|
||||
for _, client := range clients.byNick {
|
||||
for _, session := range client.Sessions() {
|
||||
// cap-notify is implicit in cap version 302 and above
|
||||
if session.capabilities.HasAll(capabs...) || 302 <= session.capVersion {
|
||||
if session.capabilities.Has(caps.CapNotify) || 302 <= session.capVersion {
|
||||
sessions = append(sessions, session)
|
||||
}
|
||||
}
|
||||
@ -270,6 +269,18 @@ func (clients *ClientManager) AllWithCapsNotify(capabs ...caps.Capability) (sess
|
||||
return
|
||||
}
|
||||
|
||||
// AllWithPushSubscriptions returns all clients that are always-on with an active push subscription.
|
||||
func (clients *ClientManager) AllWithPushSubscriptions() (result []*Client) {
|
||||
clients.RLock()
|
||||
defer clients.RUnlock()
|
||||
for _, client := range clients.byNick {
|
||||
if client.hasPushSubscriptions() && client.AlwaysOn() {
|
||||
result = append(result, client)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// FindAll returns all clients that match the given userhost mask.
|
||||
func (clients *ClientManager) FindAll(userhost string) (set ClientSet) {
|
||||
set = make(ClientSet)
|
||||
|
@ -367,6 +367,10 @@ func init() {
|
||||
usablePreReg: true,
|
||||
minParams: 4,
|
||||
},
|
||||
"WEBPUSH": {
|
||||
handler: webpushHandler,
|
||||
minParams: 2,
|
||||
},
|
||||
"WHO": {
|
||||
handler: whoHandler,
|
||||
minParams: 1,
|
||||
|
@ -41,6 +41,7 @@ import (
|
||||
"github.com/ergochat/ergo/irc/oauth2"
|
||||
"github.com/ergochat/ergo/irc/passwd"
|
||||
"github.com/ergochat/ergo/irc/utils"
|
||||
"github.com/ergochat/ergo/irc/webpush"
|
||||
)
|
||||
|
||||
// here's how this works: exported (capitalized) members of the config structs
|
||||
@ -708,6 +709,16 @@ type Config struct {
|
||||
} `yaml:"tagmsg-storage"`
|
||||
}
|
||||
|
||||
WebPush struct {
|
||||
Enabled bool
|
||||
Timeout time.Duration
|
||||
Delay time.Duration
|
||||
Subscriber string
|
||||
MaxSubscriptions int `yaml:"max-subscriptions"`
|
||||
Expiration custime.Duration
|
||||
vapidKeys *webpush.VAPIDKeys
|
||||
} `yaml:"webpush"`
|
||||
|
||||
Filename string
|
||||
}
|
||||
|
||||
@ -1572,6 +1583,29 @@ func LoadConfig(filename string) (config *Config, err error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if config.WebPush.Enabled {
|
||||
if config.Accounts.Multiclient.AlwaysOn == PersistentDisabled {
|
||||
return nil, fmt.Errorf("Cannot enable webpush if always-on is disabled")
|
||||
}
|
||||
if config.WebPush.Timeout == 0 {
|
||||
config.WebPush.Timeout = 10 * time.Second
|
||||
}
|
||||
if config.WebPush.Subscriber == "" {
|
||||
config.WebPush.Subscriber = "https://ergo.chat/about"
|
||||
}
|
||||
if config.WebPush.MaxSubscriptions <= 0 {
|
||||
config.WebPush.MaxSubscriptions = 1
|
||||
}
|
||||
if config.WebPush.Expiration == 0 {
|
||||
config.WebPush.Expiration = custime.Duration(14 * 24 * time.Hour)
|
||||
} else if config.WebPush.Expiration < custime.Duration(3*24*time.Hour) {
|
||||
return nil, fmt.Errorf("webpush.expiration is too short (should be several days)")
|
||||
}
|
||||
} else {
|
||||
config.Server.supportedCaps.Disable(caps.WebPush)
|
||||
config.Server.supportedCaps.Disable(caps.SojuWebPush)
|
||||
}
|
||||
|
||||
// now that all postprocessing is complete, regenerate ISUPPORT:
|
||||
err = config.generateISupport()
|
||||
if err != nil {
|
||||
@ -1666,6 +1700,13 @@ func (config *Config) generateISupport() (err error) {
|
||||
if config.Server.EnforceUtf8 {
|
||||
isupport.Add("UTF8ONLY", "")
|
||||
}
|
||||
if config.WebPush.Enabled {
|
||||
// XXX we typically don't have this at config parse time, so we'll have to regenerate
|
||||
// the cached reply later
|
||||
if config.WebPush.vapidKeys != nil {
|
||||
isupport.Add("VAPID", config.WebPush.vapidKeys.PublicKeyString())
|
||||
}
|
||||
}
|
||||
isupport.Add("WHOX", "")
|
||||
|
||||
err = isupport.RegenerateCachedReply()
|
||||
|
@ -18,6 +18,7 @@ import (
|
||||
"github.com/ergochat/ergo/irc/datastore"
|
||||
"github.com/ergochat/ergo/irc/modes"
|
||||
"github.com/ergochat/ergo/irc/utils"
|
||||
"github.com/ergochat/ergo/irc/webpush"
|
||||
|
||||
"github.com/tidwall/buntdb"
|
||||
)
|
||||
@ -27,15 +28,17 @@ const (
|
||||
|
||||
// 'version' of the database schema
|
||||
// latest schema of the db
|
||||
latestDbSchema = 23
|
||||
latestDbSchema = 24
|
||||
)
|
||||
|
||||
var (
|
||||
schemaVersionUUID = utils.UUID{0, 255, 85, 13, 212, 10, 191, 121, 245, 152, 142, 89, 97, 141, 219, 87} // AP9VDdQKv3n1mI5ZYY3bVw
|
||||
cloakSecretUUID = utils.UUID{170, 214, 184, 208, 116, 181, 67, 75, 161, 23, 233, 16, 113, 251, 94, 229} // qta40HS1Q0uhF-kQcfte5Q
|
||||
vapidKeysUUID = utils.UUID{87, 215, 189, 5, 65, 105, 249, 44, 65, 96, 170, 56, 187, 110, 12, 235} // V9e9BUFp-SxBYKo4u24M6w
|
||||
|
||||
keySchemaVersion = bunt.BuntKey(datastore.TableMetadata, schemaVersionUUID)
|
||||
keyCloakSecret = bunt.BuntKey(datastore.TableMetadata, cloakSecretUUID)
|
||||
keyVAPIDKeys = bunt.BuntKey(datastore.TableMetadata, vapidKeysUUID)
|
||||
)
|
||||
|
||||
type SchemaChanger func(*Config, *buntdb.Tx) error
|
||||
@ -80,6 +83,15 @@ func initializeDB(path string) error {
|
||||
// set schema version
|
||||
tx.Set(keySchemaVersion, strconv.Itoa(latestDbSchema), nil)
|
||||
tx.Set(keyCloakSecret, utils.GenerateSecretKey(), nil)
|
||||
vapidKeys, err := webpush.GenerateVAPIDKeys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
j, err := json.Marshal(vapidKeys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx.Set(keyVAPIDKeys, string(j), nil)
|
||||
return nil
|
||||
})
|
||||
|
||||
@ -233,6 +245,16 @@ func StoreCloakSecret(dstore datastore.Datastore, secret string) {
|
||||
dstore.Set(datastore.TableMetadata, cloakSecretUUID, []byte(secret), time.Time{})
|
||||
}
|
||||
|
||||
func LoadVAPIDKeys(dstore datastore.Datastore) (*webpush.VAPIDKeys, error) {
|
||||
val, err := dstore.Get(datastore.TableMetadata, vapidKeysUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := new(webpush.VAPIDKeys)
|
||||
err = json.Unmarshal([]byte(val), result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func schemaChangeV1toV2(config *Config, tx *buntdb.Tx) error {
|
||||
// == version 1 -> 2 ==
|
||||
// account key changes and account.verified key bugfix.
|
||||
@ -1218,6 +1240,20 @@ func schemaChangeV22ToV23(config *Config, tx *buntdb.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// webpush signing key
|
||||
func schemaChangeV23ToV24(config *Config, tx *buntdb.Tx) error {
|
||||
keys, err := webpush.GenerateVAPIDKeys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
j, err := json.Marshal(keys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx.Set(keyVAPIDKeys, string(j), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func getSchemaChange(initialVersion int) (result SchemaChange, ok bool) {
|
||||
for _, change := range allChanges {
|
||||
if initialVersion == change.InitialVersion {
|
||||
@ -1338,4 +1374,9 @@ var allChanges = []SchemaChange{
|
||||
TargetVersion: 23,
|
||||
Changer: schemaChangeV22ToV23,
|
||||
},
|
||||
{
|
||||
InitialVersion: 23,
|
||||
TargetVersion: 24,
|
||||
Changer: schemaChangeV23ToV24,
|
||||
},
|
||||
}
|
||||
|
151
irc/getters.go
151
irc/getters.go
@ -13,6 +13,7 @@ import (
|
||||
"github.com/ergochat/ergo/irc/languages"
|
||||
"github.com/ergochat/ergo/irc/modes"
|
||||
"github.com/ergochat/ergo/irc/utils"
|
||||
"github.com/ergochat/ergo/irc/webpush"
|
||||
)
|
||||
|
||||
func (server *Server) Config() (config *Config) {
|
||||
@ -511,6 +512,13 @@ func (client *Client) GetReadMarker(cfname string) (result string) {
|
||||
return "*"
|
||||
}
|
||||
|
||||
func (client *Client) getMarkreadTime(cfname string) (timestamp time.Time, ok bool) {
|
||||
client.stateMutex.RLock()
|
||||
timestamp, ok = client.readMarkers[cfname]
|
||||
client.stateMutex.RUnlock()
|
||||
return
|
||||
}
|
||||
|
||||
func (client *Client) copyReadMarkers() (result map[string]time.Time) {
|
||||
client.stateMutex.RLock()
|
||||
defer client.stateMutex.RUnlock()
|
||||
@ -549,6 +557,28 @@ func updateLRUMap(lru map[string]time.Time, key string, val time.Time, maxItems
|
||||
return val
|
||||
}
|
||||
|
||||
func (client *Client) addClearablePushMessage(cftarget string, messageTime time.Time) {
|
||||
client.stateMutex.Lock()
|
||||
defer client.stateMutex.Unlock()
|
||||
|
||||
if client.clearablePushMessages == nil {
|
||||
client.clearablePushMessages = make(map[string]time.Time)
|
||||
}
|
||||
updateLRUMap(client.clearablePushMessages, cftarget, messageTime, maxReadMarkers)
|
||||
}
|
||||
|
||||
func (client *Client) clearClearablePushMessage(cftarget string, readTimestamp time.Time) (ok bool) {
|
||||
client.stateMutex.Lock()
|
||||
defer client.stateMutex.Unlock()
|
||||
|
||||
pushMessageTime, ok := client.clearablePushMessages[cftarget]
|
||||
if ok && utils.ReadMarkerLessThanOrEqual(pushMessageTime, readTimestamp) {
|
||||
delete(client.clearablePushMessages, cftarget)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (client *Client) shouldFlushTimestamps() (result bool) {
|
||||
client.stateMutex.Lock()
|
||||
defer client.stateMutex.Unlock()
|
||||
@ -564,6 +594,127 @@ func (client *Client) setKlined() {
|
||||
client.stateMutex.Unlock()
|
||||
}
|
||||
|
||||
func (client *Client) refreshPushSubscription(endpoint string, keys webpush.Keys) bool {
|
||||
// do not mark dirty --- defer the write to periodic maintenance
|
||||
now := time.Now().UTC()
|
||||
|
||||
client.stateMutex.Lock()
|
||||
defer client.stateMutex.Unlock()
|
||||
|
||||
sub, ok := client.pushSubscriptions[endpoint]
|
||||
if ok && sub.Keys.Equal(keys) {
|
||||
sub.LastRefresh = now
|
||||
return true
|
||||
}
|
||||
return false // subscription doesn't exist, we need to send a test message
|
||||
}
|
||||
|
||||
func (client *Client) addPushSubscription(endpoint string, keys webpush.Keys) error {
|
||||
changed := false
|
||||
|
||||
defer func() {
|
||||
if changed {
|
||||
client.markDirty(IncludeAllAttrs)
|
||||
}
|
||||
}()
|
||||
|
||||
config := client.server.Config()
|
||||
now := time.Now().UTC()
|
||||
|
||||
client.stateMutex.Lock()
|
||||
defer client.stateMutex.Unlock()
|
||||
|
||||
if client.pushSubscriptions == nil {
|
||||
client.pushSubscriptions = make(map[string]*pushSubscription)
|
||||
}
|
||||
|
||||
sub, ok := client.pushSubscriptions[endpoint]
|
||||
if ok {
|
||||
changed = !sub.Keys.Equal(keys)
|
||||
sub.Keys = keys
|
||||
sub.LastRefresh = now
|
||||
} else {
|
||||
if len(client.pushSubscriptions) >= config.WebPush.MaxSubscriptions {
|
||||
return errLimitExceeded
|
||||
}
|
||||
changed = true
|
||||
sub = newPushSubscription(storedPushSubscription{
|
||||
Endpoint: endpoint,
|
||||
Keys: keys,
|
||||
LastRefresh: now,
|
||||
LastSuccess: now, // assume we just sent a successful message to confirm the sub
|
||||
})
|
||||
client.pushSubscriptions[endpoint] = sub
|
||||
}
|
||||
|
||||
if changed {
|
||||
client.rebuildPushSubscriptionCache()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *Client) hasPushSubscriptions() bool {
|
||||
return client.pushSubscriptionsExist.Load() != 0
|
||||
}
|
||||
|
||||
func (client *Client) getPushSubscriptions() []storedPushSubscription {
|
||||
client.stateMutex.RLock()
|
||||
defer client.stateMutex.RUnlock()
|
||||
|
||||
return client.cachedPushSubscriptions
|
||||
}
|
||||
|
||||
func (client *Client) rebuildPushSubscriptionCache() {
|
||||
// must hold write lock
|
||||
if len(client.pushSubscriptions) == 0 {
|
||||
client.cachedPushSubscriptions = nil
|
||||
client.pushSubscriptionsExist.Store(0)
|
||||
return
|
||||
}
|
||||
|
||||
client.cachedPushSubscriptions = make([]storedPushSubscription, 0, len(client.pushSubscriptions))
|
||||
for _, subscription := range client.pushSubscriptions {
|
||||
client.cachedPushSubscriptions = append(client.cachedPushSubscriptions, subscription.storedPushSubscription)
|
||||
}
|
||||
client.pushSubscriptionsExist.Store(1)
|
||||
}
|
||||
|
||||
func (client *Client) deletePushSubscription(endpoint string, writeback bool) (changed bool) {
|
||||
defer func() {
|
||||
if writeback && changed {
|
||||
client.markDirty(IncludeAllAttrs)
|
||||
}
|
||||
}()
|
||||
|
||||
client.stateMutex.Lock()
|
||||
defer client.stateMutex.Unlock()
|
||||
|
||||
_, ok := client.pushSubscriptions[endpoint]
|
||||
if ok {
|
||||
changed = true
|
||||
delete(client.pushSubscriptions, endpoint)
|
||||
client.rebuildPushSubscriptionCache()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (client *Client) recordPush(endpoint string, success bool) {
|
||||
now := time.Now().UTC()
|
||||
|
||||
client.stateMutex.Lock()
|
||||
defer client.stateMutex.Unlock()
|
||||
|
||||
subscription, ok := client.pushSubscriptions[endpoint]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if success {
|
||||
subscription.LastSuccess = now
|
||||
}
|
||||
// TODO we may want to track failures in some way in the future
|
||||
}
|
||||
|
||||
func (channel *Channel) Name() string {
|
||||
channel.stateMutex.RLock()
|
||||
defer channel.stateMutex.RUnlock()
|
||||
|
109
irc/handlers.go
109
irc/handlers.go
@ -33,6 +33,7 @@ import (
|
||||
"github.com/ergochat/ergo/irc/oauth2"
|
||||
"github.com/ergochat/ergo/irc/sno"
|
||||
"github.com/ergochat/ergo/irc/utils"
|
||||
"github.com/ergochat/ergo/irc/webpush"
|
||||
)
|
||||
|
||||
// helper function to parse ACC callbacks, e.g., mailto:person@example.com, tel:16505551234
|
||||
@ -2465,6 +2466,20 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi
|
||||
Tags: tags,
|
||||
}
|
||||
client.addHistoryItem(user, item, &details, &tDetails, config)
|
||||
|
||||
if config.WebPush.Enabled && histType != history.Tagmsg && user.hasPushSubscriptions() && client != user {
|
||||
pushMsgBytes, err := webpush.MakePushMessage(command, nickMaskString, accountName, tnick, message)
|
||||
if err == nil {
|
||||
user.dispatchPushMessage(pushMessage{
|
||||
msg: pushMsgBytes,
|
||||
urgency: webpush.UrgencyHigh,
|
||||
cftarget: details.nickCasefolded,
|
||||
time: message.Time,
|
||||
})
|
||||
} else {
|
||||
server.logger.Error("internal", "can't serialize push message", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3049,6 +3064,18 @@ func markReadHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res
|
||||
session.Send(nil, server.name, "MARKREAD", unfoldedTarget, readTimestamp)
|
||||
}
|
||||
}
|
||||
if client.clearClearablePushMessage(cftarget, readTime) {
|
||||
line, err := webpush.MakePushLine(time.Now().UTC(), "*", server.name, "MARKREAD", unfoldedTarget, readTimestamp)
|
||||
if err == nil {
|
||||
client.dispatchPushMessage(pushMessage{
|
||||
msg: line,
|
||||
originatingEndpoint: rb.session.webPushEndpoint,
|
||||
urgency: webpush.UrgencyNormal, // copied from soju
|
||||
})
|
||||
} else {
|
||||
server.logger.Error("internal", "couldn't serialize MARKREAD push message", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@ -3590,6 +3617,88 @@ func webircHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo
|
||||
return true
|
||||
}
|
||||
|
||||
// WEBPUSH <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'
|
||||
|
||||
func (fields whoxFields) Add(field rune) (result whoxFields) {
|
||||
|
@ -610,6 +610,11 @@ ircv3.net/specs/extensions/webirc.html
|
||||
the connection from the client to the gateway, such as:
|
||||
|
||||
- tls: this flag indicates that the client->gateway connection is secure`,
|
||||
},
|
||||
"webpush": {
|
||||
text: `WEBPUSH <subcommand> [arguments]
|
||||
|
||||
Configures web push settings. Not for direct use by end users.`,
|
||||
},
|
||||
"who": {
|
||||
text: `WHO <name> [o]
|
||||
|
@ -177,7 +177,7 @@ func histservExportHandler(service *ircService, server *Server, client *Client,
|
||||
}
|
||||
|
||||
func histservExportAndNotify(service *ircService, server *Server, cfAccount string, outfile *os.File, filename, alertNick string) {
|
||||
defer server.HandlePanic()
|
||||
defer server.HandlePanic(nil)
|
||||
|
||||
defer outfile.Close()
|
||||
writer := bufio.NewWriter(outfile)
|
||||
|
@ -17,6 +17,7 @@ import (
|
||||
"github.com/ergochat/ergo/irc/datastore"
|
||||
"github.com/ergochat/ergo/irc/modes"
|
||||
"github.com/ergochat/ergo/irc/utils"
|
||||
"github.com/ergochat/ergo/irc/webpush"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -24,7 +25,7 @@ const (
|
||||
// XXX instead of referencing, e.g., keyAccountExists, we should write in the string literal
|
||||
// (to ensure that no matter what code changes happen elsewhere, we're still producing a
|
||||
// db of the hardcoded version)
|
||||
importDBSchemaVersion = 23
|
||||
importDBSchemaVersion = 24
|
||||
)
|
||||
|
||||
type userImport struct {
|
||||
@ -82,6 +83,15 @@ func doImportDBGeneric(config *Config, dbImport databaseImport, credsType Creden
|
||||
|
||||
tx.Set(keySchemaVersion, strconv.Itoa(importDBSchemaVersion), nil)
|
||||
tx.Set(keyCloakSecret, utils.GenerateSecretKey(), nil)
|
||||
vapidKeys, err := webpush.GenerateVAPIDKeys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vapidKeysJSON, err := json.Marshal(vapidKeys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx.Set(keyVAPIDKeys, string(vapidKeysJSON), nil)
|
||||
|
||||
cfUsernames := make(utils.HashSet[string])
|
||||
skeletonToUsername := make(map[string]string)
|
||||
|
@ -241,6 +241,18 @@ indicate an empty password, use * instead.`,
|
||||
"password": {
|
||||
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": {
|
||||
handler: nsGetHandler,
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
@ -6,14 +6,19 @@ package irc
|
||||
import (
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HandlePanic is a general-purpose panic handler for ad-hoc goroutines.
|
||||
// Because of the semantics of `recover`, it must be called directly
|
||||
// from the routine on whose call stack the panic would occur, with `defer`,
|
||||
// e.g. `defer server.HandlePanic()`
|
||||
func (server *Server) HandlePanic() {
|
||||
func (server *Server) HandlePanic(restartable func()) {
|
||||
if r := recover(); r != nil {
|
||||
server.logger.Error("internal", fmt.Sprintf("Panic encountered: %v\n%s", r, debug.Stack()))
|
||||
if restartable != nil {
|
||||
time.Sleep(time.Second)
|
||||
go restartable()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -36,10 +36,12 @@ import (
|
||||
"github.com/ergochat/ergo/irc/mysql"
|
||||
"github.com/ergochat/ergo/irc/sno"
|
||||
"github.com/ergochat/ergo/irc/utils"
|
||||
"github.com/ergochat/ergo/irc/webpush"
|
||||
)
|
||||
|
||||
const (
|
||||
alwaysOnMaintenanceInterval = 30 * time.Minute
|
||||
pushMaintenanceInterval = 24 * time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
@ -135,6 +137,7 @@ func NewServer(config *Config, logger *logger.Manager) (*Server, error) {
|
||||
}
|
||||
|
||||
time.AfterFunc(alwaysOnMaintenanceInterval, server.periodicAlwaysOnMaintenance)
|
||||
time.AfterFunc(pushMaintenanceInterval, server.periodicPushMaintenance)
|
||||
|
||||
return server, nil
|
||||
}
|
||||
@ -267,7 +270,7 @@ func (server *Server) periodicAlwaysOnMaintenance() {
|
||||
time.AfterFunc(alwaysOnMaintenanceInterval, server.periodicAlwaysOnMaintenance)
|
||||
}()
|
||||
|
||||
defer server.HandlePanic()
|
||||
defer server.HandlePanic(nil)
|
||||
|
||||
server.logger.Info("accounts", "Performing periodic always-on client checks")
|
||||
server.performAlwaysOnMaintenance(true, true)
|
||||
@ -291,6 +294,47 @@ func (server *Server) performAlwaysOnMaintenance(checkExpiration, flushTimestamp
|
||||
}
|
||||
}
|
||||
|
||||
func (server *Server) periodicPushMaintenance() {
|
||||
defer func() {
|
||||
// reschedule whether or not there was a panic
|
||||
time.AfterFunc(pushMaintenanceInterval, server.periodicPushMaintenance)
|
||||
}()
|
||||
|
||||
defer server.HandlePanic(nil)
|
||||
|
||||
if server.Config().WebPush.Enabled {
|
||||
server.logger.Info("webpush", "Performing periodic push subscription maintenance")
|
||||
server.performPushMaintenance()
|
||||
} // else: reschedule and check again later, the operator may enable it via rehash
|
||||
}
|
||||
|
||||
func (server *Server) performPushMaintenance() {
|
||||
expiration := time.Duration(server.Config().WebPush.Expiration)
|
||||
for _, client := range server.clients.AllWithPushSubscriptions() {
|
||||
for _, sub := range client.getPushSubscriptions() {
|
||||
now := time.Now()
|
||||
// require both periodic successful push messages and renewal of the subscription via WEBPUSH REGISTER
|
||||
if now.Sub(sub.LastSuccess) > expiration || now.Sub(sub.LastRefresh) > expiration {
|
||||
server.logger.Debug("webpush", "expiring push subscription for client", client.Nick(), sub.Endpoint)
|
||||
client.deletePushSubscription(sub.Endpoint, false)
|
||||
} else if now.Sub(sub.LastSuccess) > expiration/2 {
|
||||
// we haven't pushed to them recently, make an attempt
|
||||
server.logger.Debug("webpush", "pinging push subscription for client", client.Nick(), sub.Endpoint)
|
||||
client.sendAndTrackPush(
|
||||
sub.Endpoint, sub.Keys,
|
||||
pushMessage{
|
||||
msg: webpush.PingMessage,
|
||||
urgency: webpush.UrgencyNormal,
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
// persist all push subscriptions on the assumption that the timestamps have changed
|
||||
client.Store(IncludePushSubscriptions)
|
||||
}
|
||||
}
|
||||
|
||||
// handles server.ip-check-script.exempt-sasl:
|
||||
// run the ip check script at the end of the handshake, only for anonymous connections
|
||||
func (server *Server) checkBanScriptExemptSASL(config *Config, session *Session) (outcome AuthOutcome) {
|
||||
@ -589,7 +633,7 @@ func (client *Client) getWhoisOf(target *Client, hasPrivs bool, rb *ResponseBuff
|
||||
// rehash reloads the config and applies the changes from the config file.
|
||||
func (server *Server) rehash() error {
|
||||
// #1570; this needs its own panic handling because it can be invoked via SIGHUP
|
||||
defer server.HandlePanic()
|
||||
defer server.HandlePanic(nil)
|
||||
|
||||
server.logger.Info("server", "Attempting rehash")
|
||||
|
||||
@ -743,6 +787,16 @@ func (server *Server) applyConfig(config *Config) (err error) {
|
||||
return fmt.Errorf("Could not load cloak secret: %w", err)
|
||||
}
|
||||
config.Server.Cloaks.SetSecret(cloakSecret)
|
||||
// similarly bring the VAPID keys into the config, which requires regenerating the 005
|
||||
if config.WebPush.Enabled {
|
||||
config.WebPush.vapidKeys, err = LoadVAPIDKeys(server.dstore)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Could not load VAPID keys: %w", err)
|
||||
}
|
||||
if err = config.generateISupport(); err != nil {
|
||||
return fmt.Errorf("Could not regenerate cached 005 for VAPID: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// activate the new config
|
||||
server.config.Store(config)
|
||||
|
@ -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
|
||||
}
|
@ -95,6 +95,20 @@ func (sm *SplitMessage) Is512() bool {
|
||||
return sm.Split == nil
|
||||
}
|
||||
|
||||
func (sm *SplitMessage) CombinedValue() string {
|
||||
if sm.Split == nil {
|
||||
return sm.Message
|
||||
}
|
||||
var buf strings.Builder
|
||||
for i := range sm.Split {
|
||||
if i != 0 && !sm.Split[i].Concat {
|
||||
buf.WriteRune('\n')
|
||||
}
|
||||
buf.WriteString(sm.Split[i].Message)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// TokenLineBuilder is a helper for building IRC lines composed of delimited tokens,
|
||||
// with a maximum line length.
|
||||
type TokenLineBuilder struct {
|
||||
|
@ -66,3 +66,15 @@ func BenchmarkTokenLines(b *testing.B) {
|
||||
tl.Lines()
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombinedValue(t *testing.T) {
|
||||
var split = SplitMessage{
|
||||
Split: []MessagePair{
|
||||
{"hi", false},
|
||||
{"hi", false},
|
||||
{" again", true},
|
||||
{"you", false},
|
||||
},
|
||||
}
|
||||
assertEqual(split.CombinedValue(), "hi\nhi again\nyou", t)
|
||||
}
|
||||
|
15
irc/utils/time.go
Normal file
15
irc/utils/time.go
Normal 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
60
irc/webpush/highlight.go
Normal 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
66
irc/webpush/security.go
Normal 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()
|
||||
}
|
21
irc/webpush/security_test.go
Normal file
21
irc/webpush/security_test.go
Normal 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
148
irc/webpush/webpush.go
Normal 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)
|
||||
}
|
||||
}
|
57
irc/webpush/webpush_test.go
Normal file
57
irc/webpush/webpush_test.go
Normal 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")
|
||||
}
|
||||
}
|
@ -893,6 +893,7 @@ fakelag:
|
||||
"MARKREAD": 16
|
||||
"MONITOR": 1
|
||||
"WHO": 4
|
||||
"WEBPUSH": 1
|
||||
|
||||
# the roleplay commands are semi-standardized extensions to IRC that allow
|
||||
# sending and receiving messages from pseudo-nicknames. this can be used either
|
||||
@ -1038,3 +1039,24 @@ history:
|
||||
# whether to allow customization of the config at runtime using environment variables,
|
||||
# e.g., ERGO__SERVER__MAX_SENDQ=128k. see the manual for more details.
|
||||
allow-environment-overrides: true
|
||||
|
||||
# experimental support for mobile push notifications
|
||||
# see the manual for potential security, privacy, and performance implications.
|
||||
# DO NOT enable if you are running a Tor or I2P hidden service (i.e. one
|
||||
# with no public IP listeners, only Tor/I2P listeners).
|
||||
webpush:
|
||||
# are push notifications enabled at all?
|
||||
enabled: false
|
||||
# request timeout for POST'ing the http notification
|
||||
timeout: 10s
|
||||
# delay sending the notification for this amount of time, then suppress it
|
||||
# if the client sent MARKREAD to indicate that it was read on another device
|
||||
delay: 0s
|
||||
# subscriber field for the VAPID JWT authorization:
|
||||
#subscriber: "https://your-website.com/"
|
||||
# maximum number of push subscriptions per user
|
||||
max-subscriptions: 4
|
||||
# expiration time for a push subscription; it must be renewed within this time
|
||||
# by the client reconnecting to IRC. we also detect whether the client is no longer
|
||||
# successfully receiving push messages.
|
||||
expiration: 14d
|
||||
|
13
vendor/github.com/ergochat/webpush-go/v2/.check-gofmt.sh
generated
vendored
Normal file
13
vendor/github.com/ergochat/webpush-go/v2/.check-gofmt.sh
generated
vendored
Normal 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
6
vendor/github.com/ergochat/webpush-go/v2/.gitignore
generated
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
vendor/**
|
||||
|
||||
.DS_Store
|
||||
*.out
|
||||
|
||||
*.swp
|
14
vendor/github.com/ergochat/webpush-go/v2/CHANGELOG.md
generated
vendored
Normal file
14
vendor/github.com/ergochat/webpush-go/v2/CHANGELOG.md
generated
vendored
Normal 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
21
vendor/github.com/ergochat/webpush-go/v2/LICENSE
generated
vendored
Normal 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
6
vendor/github.com/ergochat/webpush-go/v2/Makefile
generated
vendored
Normal 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
65
vendor/github.com/ergochat/webpush-go/v2/README.md
generated
vendored
Normal 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
76
vendor/github.com/ergochat/webpush-go/v2/legacy.go
generated
vendored
Normal 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
26
vendor/github.com/ergochat/webpush-go/v2/urgency.go
generated
vendored
Normal 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
177
vendor/github.com/ergochat/webpush-go/v2/vapid.go
generated
vendored
Normal 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
323
vendor/github.com/ergochat/webpush-go/v2/webpush.go
generated
vendored
Normal 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
95
vendor/golang.org/x/crypto/hkdf/hkdf.go
generated
vendored
Normal 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
4
vendor/modules.txt
vendored
@ -22,6 +22,9 @@ github.com/ergochat/irc-go/ircfmt
|
||||
github.com/ergochat/irc-go/ircmsg
|
||||
github.com/ergochat/irc-go/ircreader
|
||||
github.com/ergochat/irc-go/ircutils
|
||||
# github.com/ergochat/webpush-go/v2 v2.0.0-rc1
|
||||
## explicit; go 1.20
|
||||
github.com/ergochat/webpush-go/v2
|
||||
# github.com/go-sql-driver/mysql v1.7.0
|
||||
## explicit; go 1.13
|
||||
github.com/go-sql-driver/mysql
|
||||
@ -83,6 +86,7 @@ github.com/xdg-go/scram
|
||||
## explicit; go 1.20
|
||||
golang.org/x/crypto/bcrypt
|
||||
golang.org/x/crypto/blowfish
|
||||
golang.org/x/crypto/hkdf
|
||||
golang.org/x/crypto/pbkdf2
|
||||
golang.org/x/crypto/sha3
|
||||
# golang.org/x/sys v0.22.0
|
||||
|
Loading…
Reference in New Issue
Block a user