By far, one of the most popular technologies for delivering alerts to users ’devices is Push Notifications. The technology is such that its operation requires constant access to the Internet, namely access to the servers on which user devices are registered to receive notifications. In this article, we will examine the full range of WebPush notification technology mechanisms hidden behind the words WebSocket, ServiceWorker, vapid, register, broadcast, message encryption, etc. The main reason that prompted me to reverse and study the mechanism was the need to deliver monitoring notifications to technical support workstations located in a closed network segment without Internet access. And yes, it is possible! Details under the cut.


This article discusses the mode of delivering notifications to users as part of using the Mozilla Firefox browser. This is due to the fact that at the moment this is the only product that allows you to change the settings of push servers used by default. For security reasons, the settings of the Google Chrome, Chromium, and derivatives browsers are firmly “protected” by the manufacturer in the product code.

The article is divided into two parts

  • Theoretical information
  • Practical notes for implementing the WebPush notification mechanism

Used technologies and terms


The transport core of the Push notification system is the WebSocket protocol, which allows establishing a permanent two-way communication channel between the client and server as part of the standard HTTP/HTTPS connection to the Web server. Within the established communication channel, any, including binary, client-server interaction protocols laid down by the developers of the service can be used.


ServiceWorker is an external standalone event processing and response engine that integrates into the browser by loading event processing logic into the browser event machine. The list of events is firmly fixed in the browser code and cannot be changed quickly. ServiceWorker is inherently part of executable code that runs outside the context of a user session. This is an important condition that ensures the security of user data. Direct user interaction with ServiceWorker is almost impossible.


A mechanism for generating authorization headers to identify intermediate servers that initiate message sending to the user.
VAPID specification


The mechanism for delivering messages to the recipient.
WebPush docs and specifications


There are quite a lot of WebPush documentation (see spoiler), but it exists only in the paradigm
Client & lt; - > Push Service & lt; - > Application

Detailed specifications on how the mechanism works in Google and Mozilla products

The interaction model assumes the following scheme.

Thus, it seems that the mechanism requires at least specialized services for registering and receiving/delivering notifications, and VAPID, special headers and keys are required to send messages. The documentation does not describe some internal mechanisms of interaction between the Push server and the client that really affect the work.

Let's try to consider all the processes in detail.

Message processing phase

I have long been interested in what types of messages can be sent to the browser and what it will respond to.

The question disappeared when I turned on debugging mode for push messages in the browser and got into Firefox source code .

Code block has dotted all AND
try { reply=JSON.parse(message); } catch (e) { console.warn("wsOnMessageAvailable: Invalid JSON", message, e); return; }//If we receive a message, we know the connection succeeded. Reset the//connection attempt and ping interval counters. this._retryFailCount=0; let doNotHandle=false; if ( message === "{}" || reply.messageType === undefined || reply.messageType === "ping" || typeof reply.messageType != "string" ) { console.debug("wsOnMessageAvailable: Pong received"); doNotHandle=true; }//Reset the ping timer. Note: This path is executed at every step of the//handshake, so this timer does not need to be set explicitly at startup. this._startPingTimer();//If it is a ping, do not handle the message. if (doNotHandle) { return; }//A whitelist of protocol handlers. Add to these if new messages are added//in the protocol. let handlers=[ "Hello", "Register", "Unregister", "Notification", "Broadcast", ];//Build up the handler name to call from messageType.//e.g. messageType == "register" -> _handleRegisterReply. let handlerName=reply.messageType[0].toUpperCase() + reply.messageType.slice(1).toLowerCase(); if (!handlers.includes(handlerName)) { console.warn( "wsOnMessageAvailable: No whitelisted handler", handlerName, "for message", reply.messageType ); return; } let handler="_handle" + handlerName + "Reply"; 

Not a single message sent through the websocket to the browser side will be processed if it is not a system message to check the availability of the end side "{}" or as a response to a request from the Push server. This means that the Push server has no way of influencing the work of the client side, except for checking its availability. Similarly, except for 5 types of response messages, nothing will be processed.

Initialization phase

When Firefox launches, its internal mechanism automatically initiates a connection to the WebSocket (WS) server located in the system setting dom.push.serverURL with a message of the following format.

{ "messageType": "hello", "broadcasts": { "remote-settings/monitor_changes": "v923" }, "use_webpush": True } 

During the initial initialization of the connection (first launch of the browser after installation/launch of a new profile), the "uaid" field is absent, which is a signal to the Push server to register a new identifier. As we see in the section "broadcasts" there is a certain pair of "remote-settings/monitor_changes": "v923". This pair is used as a buffer for storing information sent to the server when establishing a connection. In the Mozilla autopush product, an industrial version of the webpush server used on the Mozilla server side, this variable is used as the identifier of the last message received by the user from the global server queue. We will talk about changing this identifier later. So, after receiving a message from the client, the server responds with a message of the following form

{ "messageType": "hello", "status": 200, "uaid": "b4ab795089784bbb978e6c894fe753c0", "use_webpush": True } 

The uaid field is filled either with the value sent by the client or with a new random value if uaid was undefined.

At this stage, the initialization phase ends and, in principle, nothing happens between the client and server until the moment of registration or disconnection.

Registration phase

Under the registration phase is meant a process that involves the readiness of the event machine to receive specially crafted messages containing data transmitted to the user.

The registration phase consists of several steps:

  • Checking user permission to receive information
  • ServiceWorker Registration
  • Getting subscription options
  • Generation of encryption keys for subscription service

Check user permissions to receive information

At this stage, the browser, before installing ServiceWorker, asks the user and system settings: "is the user ready to receive subscription messages?"
In the event of one of the failures, ServiceWorker installation is interrupted

Register ServiceWorker

As we mentioned earlier, ServiceWorker is a stand-alone page with event handlers in a separate browser space inaccessible to the user.

There are quite serious restrictions on working with this component:

  • The ServiceWorker component must be loaded through a secure connection (HTTPS), or for debugging purposes with localhost.The inclusion of flags for the "unsafe" use of external resources is possible, but this is not recommended
  • A WebSocket connection must be established using a secure connection (WSS), or for debugging purposes using a regular WS connection with localhost
  • if on the local network the name of the server (resource) from which ServiceWorker is registered is different from the full fqdn of the resource on which ServiceWorker is located, an exception about an unsafe call will be raised
    Google’s ServiceWorker Life Cycle
    Mozilla ServiceWorker Life Cycle

Subscription process

The subscription process involves launching a mechanism for generating encryption keys to create messages and then decrypt data from the Push server.

It consists of the following steps:

  • receiving the public encryption key from the Web server, which is an intermediate link between the Push server and the application sending messages
  • browser generation of encryption keys to protect messages
  • calling the subscription mechanism through the WebSocket channel and receiving a point for sending messages

Getting the public key

In order for the application to send a message to the client through the Push server, the Push server must make sure that the intermediate server sending the message is trusted for the recipient.

To obtain a public key, you must contact the resource providing the preparation of the message for sending to the recipient. Initial access to the server implies the issuance of a public key for VAPID authentication

Starting the process of generating encryption keys

After receiving the public VAPID key, the ServiceWorker subscription process is called. The launched subscription process, using the public VAPID key as an identifier, generates a session set of encryption keys (private key, public key, authorization key). The session set of public keys is exported and after the end of the subscription can be obtained from the user session.

Getting a point to send messages

After the encryption keys are generated, a process inside the browser called register is called. Toward the Push server, through the WebSocket, the browser sends a request of the form

{ "channelID": "f9cb8f1c-05e0-403f-a09b-dd7864a03eb7", "messageType": "register", "key": "BO_C-Ou.......zKu2U4HZ9XeElUIdRfc6EBbRudAjq4=" } 

This is a connection registration request. As registration parameters, a unique channel number is generated, generated each time anew at the start of the registration process, as well as the public VAPID key of the server through which preliminary processing and encryption of messages will be performed.

In response to the registration request, the browser expects a message of the form

{ "messageType": "register", "channelID": "f9cb8f1c-05e0-403f-a09b-dd7864a03eb7", "status": 200, "pushEndpoint": "", "scope": "" } 

This message contains the endpoint address generated by the Push (WebSocket) server, to which an encrypted message must be sent to be received by the user. To send a message to the recipient, a logical connection must be established between the WEB server that accepts external requests and the WS server that sends alerts.

In total, at the end of the registration and subscription process, we have the following data set:


  • private message encryption key
  • public message encryption key
  • authorization key (DH)
  • endpoint for delivering messages to the recipient
  • channel number registered on the WebSocket server
  • client identifier inside the WS connection
  • public key of the WebPush server

WebPush server:

  • public key of the WebPush server
  • WebPush server private key

Push (WebSocket) server:

  • public key of the WebPush server
  • client endpoint address
  • client channel number associated with the endpoint
  • client identifier inside the WS connection

Of the entire data set, the WebPush server looks the strangest.For a long time I could not understand how the entire process of message delivery to the user occurs, but after reversing all the mechanisms, as well as the autopush debug, the following scheme turned out:

  • someone wants to send a message to the user's browser
  • to protect the message, you need to extract the settings of the current subscription to the Push server from the browser (endpoint for sending the message, public encryption key of the message, authorization key)
  • the received settings are transferred to the intermediate WebPush server along with the message text
  • the intermediate WebPush server generates an authorization JWT token containing the message creation time, WebPush server administrator address, message validity time and signs it using its private key
  • the intermediate WebPush server encrypts the message using the public key and the authorization key from the browser
  • the intermediate WebPush server calls the endpoint received from the browser, passing the JWT token + public key to it for verification in the Authorization header, as well as a binary array of the encrypted message in the request body
  • Push server on the called endpoint binds the request to the recipient channel
  • Push server checks the validity of the JWT token
  • Push server converts the binary array of received data to base64, generates a notification message with the recipient channel, puts the message in the queue, after which the queue control mechanism sends a message via the WebSocket channel to the client

Here we interrupt the process for describing the format of the message type "notification".

The fact is that the message format of the notification type has two options. The logic of how to receive and display a message depends on what the browser received and transmitted to ServiceWorker. The first option is a "blank" message:

{ "messageType": "notification", "channelID": "f7dfeed8-f868-47ca-a066-fbe629879fbf", "version": "bf82eea1-69fd-4be0-b943-da96ff0041fb" } 

An "empty" message says to the browser, "Hey, data is waiting for you here, come for them." According to the logic of work, the browser should make a GET request to the endpoint URL and get the first record from the queue to display it to the user. The scheme is of course good, only completely unsafe. In most cases, it does not apply.

The second option is to transfer data along with the message.

{ "messageType": "notification", "channelID": "f7dfeed8-f868-47ca-a066-fbe629879fbf", "version": "bf82eea1-69fd-4be0-b943-da96ff0041fb", "data": "I_j8p....eMlYK6jxE2-pHv-TRhqQ", "headers": { "encoding": "aes128gcm" } } 

The browser responds to the headers field in the notification message type structure. If this field is present, the mechanism for processing encrypted data from the "data" field is automatically turned on. Based on the channel number, the browser event machine selects a set of encryption keys and tries to decrypt the received data. After decryption, the decrypted data is passed to the "push" handler of the ServiceWorker messages. As you can see, a notification message has a version field, which is a unique message number. A unique message number is used in the message delivery and display system for data deduplication.

It works as follows:

  • any received message with the "version" field is entered into the internal exception registry
  • correctly received and processed messages remain in the exception registry
  • incorrectly received and not processed messages from the exception registry are deleted
    Information on the reasons for this behavior will be lower.

Let's continue the process parsing.

  • If the message is received and decrypted, a new message with the "ack" type is generated from the browser towards the Push server, including the channel number and the number of the processed message. This is a signal to delete a message from the message queue for this channel
  • If the message cannot be processed for some reason, a new message with the "noack" type is generated from the browser towards the Push server, including the channel number and the number of the rejected message. This is a signal to send a message for re-delivery after 60 seconds

Let's go back to messages with type "broadcast". Mozilla's "autopush" product uses them as a client-side repository to determine the last message sent to the client. The fact is that sending a message of type "broadcast" with changing the key value "remote-settings/monitor_changes" leads to the operation of a mechanism that stores the received value in the browser storage.If the connection is lost or some software failure occurs, the stored value will be automatically transferred to the Push server side at the time of the connection initialization and will be the starting point for the subsequent resending of the missed messages from the queue.

It makes no sense to describe messages like "unregister", because it does not affect anything except deleting the session.

Why was there a detailed description of all the processes that occur during Push notifications?

The point is that based on this data, you can quickly build your Push server with the necessary functionality. Mozilla's "autopush" product is an industrial-scale product designed for multi-million customer connections. It includes TornadoDB, PyPy, CPython. Unfortunately, the engine is written in Python 2.7, which is being massively decommissioned.

We need a small server with simple, preferably asynchronous code. Namely, without an intermediate WebPush server, VAPID, unnecessary interserver checks and other things. The server should be able to bind client connections of the Push server to user names, and also have the ability to organize endpoints and webhooks to send messages to these users.

Writing your server

We have the following data:

  • User with Mozilla Firefox;
  • The user registration point on the notification server to receive these notifications;
  • WebSocket server serving connections of the notification engine built into the browser;
  • Web server that forms the interface for the user and serves as a point for sending notifications;

Step 1
First of all, we need to prepare a WebSocket server that serves the previously described logic of working and connecting clients to it.

AsyncIO Python is used as a framework for implementing server logic.

Initially, it’s worth immediately separating the concept of “registration” for the WebSocket browser engine and the concept of “registration” on the notification server. The difference is that the "registration" of the WebSocket browser engine occurs automatically without user intervention, while the permission to "register" on the notification server is a conscious action by the user.

The primary task of the WebSocket server is to accept the incoming connection and control it throughout the entire time the browser connects to the server. Therefore, we must accept the external connection, bind it to the channel and save for future work.

After the server accepts the connection, we connect an event handler to it, inside which will contain all the information necessary for the work and the functionality of sending messages.

For convenience, we use two META directories, one for the list of connections, the second for detailed information about the connection.

WebSocket Handler
# внешнее имя сервера SERVERNAME='' # вебсокеты WS=set() # каналы CHANNELS=dict() async def register(websocket): try: WS.add(websocket) websocket.handler=PushConnectionHandler(websocket) except Exception as ex: logger.error('Register exception: %s' % ex) async def unregister(websocket): try: CHANNELS.remove(websocket.handler.channel_id) WS.remove(websocket) logger.debug('UnregisterWebsocket[websocket]: %s'%websocket) except Exception as ex: logger.error('Unregister exception: %s' % ex) async def pushserver(websocket, path): await register(websocket) try: await websocket.send(json.dumps({})) async for message in websocket: data=json.loads(message)'Incoming message[data]: %s => %s '%(message, data)) if message == '{}': await websocket.send(json.dumps({})) elif 'messageType' in data:'Processing WebSocket Data') # Подключение к вебсокету из браузера if data['messageType'] == 'hello': # Если это первичное подключение, то нужно задать идентификатор подключения и вернуть его браузеру if 'uaid' not in data: data['uaid']='%s' % uuid.uuid4() # Принудительно включить webpush if 'use_webpush' not in data: data['use_webpush']=True helloreturn={ "messageType": "hello", "status": 200, "uaid": data['uaid'], "use_webpush": data['use_webpush'] } websocket.handler.uaid=data['uaid'] if 'broadcasts' in data: websocket.handler.register_broadcasts(data['broadcasts']) logger.debug('Hello websocket: %s' % vars(websocket.handler)) CHANNELS.update({ data['uaid'] : websocket.handler }) await websocket.send(json.dumps(helloreturn)) elif data['messageType'] == 'register': # Регистрация serviceWorker logger.debug('Register[data]: %s'%data) registerreturn={ "messageType": "register", "channelID": data['channelID'], "status": 200, "pushEndpoint": "https://%s/wpush/%s/" % (SERVERNAME,data['channelID']), "scope": "https://%s/" % SERVERNAME } websocket.handler.channel_id=data['channelID'] if 'key' in data: websocket.handler.server_public_key=data['key'] logger.debug('Register[registerreturn]: %s'%registerreturn) CHANNELS.update({ data['channelID'] : websocket.handler }) await websocket.send(json.dumps(registerreturn)) elif data['messageType'] == 'unregister': unregisterreturn={ "messageType": "unregister", "channelID": data['channelID'], "status": 200 } if data['channelID'] in CHANNELS: del CHANNELS[data['channelID']] logger.debug('Unregister[unregisterreturn]: %s'%unregisterreturn) logger.debug('Unregister[CHANNELS]: %s'%CHANNELS) await websocket.send(json.dumps(unregisterreturn)) elif data['messageType'] == 'ack': logger.debug('Ack: %s' % data) for update in data['updates']: if CHANNELS[update['channelID']].mqueue.count(update['version']) > 0: CHANNELS[update['channelID']].mqueue.remove(update['version']) logger.debug('Mqueue for channel %s is %s' % (websocket.handler.channel_id, websocket.handler.mqueue)) await websocket.send('{}') elif data['messageType'] == 'nack': await websocket.send('{}') else: logger.error("unsupported event: {}", data) finally: await unregister(websocket) 

As you can see, no big magic has been laid in the WebSocket service. Only the list of basic commands within the WS session is processed according to the specification.

Step 2
The next step is to ensure that the client session is registered on the alert server.
To do this, you must use the registration and installation mechanism of ServiceWorker. There are quite a few examples on the network, so I took a ready-made example from the network and changed it by adding a logic extension.

'use strict'; let isSubscribed=false; let swRegistration=null; var wait=ms => new Promise((r, j)=>setTimeout(r, ms)); function urlB64ToUint8Array(base64String) { const padding='='.repeat((4 - base64String.length % 4) % 4); const base64=(base64String + padding) .replace(/\-/g, '+') .replace(/_/g, '/'); const rawData=window.atob(base64); const outputArray=new Uint8Array(rawData.length); for (let i=0; i < rawData.length; ++i) { outputArray[i]=rawData.charCodeAt(i); } return outputArray; } function subscribeUser() { const applicationServerPublicKey=localStorage.getItem('applicationServerPublicKey'); const applicationServerKey=urlB64ToUint8Array(applicationServerPublicKey); swRegistration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: applicationServerKey }) .then(function(subscription) { console.log('User is subscribed.', JSON.stringify(subscription)); localStorage.setItem('sub_token',JSON.stringify(subscription)); isSubscribed=true; fetch(subscription.endpoint, { method: 'POST', cache: 'no-cache', body: JSON.stringify(subscription) }) .then(function(response) { console.log('Push keys Update Response: ' + JSON.stringify(response)); }) }) .catch(function(err) { console.log('Failed to subscribe the user: ', err); }); } function unsubscribeUser() { swRegistration.pushManager.getSubscription() .then(function(subscription) { if (subscription) { return subscription.unsubscribe(); } }) .catch(function(error) { console.log('Error unsubscribing', error); }) .then(function() { console.log('User is unsubscribed.'); isSubscribed=false; }); } function initializeUI() {//Set the initial subscription value swRegistration.pushManager.getSubscription() .then(function(subscription) { isSubscribed=!(subscription === null); if (isSubscribed) { console.log('User IS subscribed. Unsubscribing.'); subscription.unsubscribe(); } else { console.log('User is NOT subscribed. Subscribing.'); subscribeUser(); } }); (async () => { await wait(2000); console.warn('Wait for operation is ok'); swRegistration.pushManager.getSubscription() .then(function(subscription) { isSubscribed=!(subscription === null); if (!isSubscribed) { console.log('ReSubscribe user'); subscribeUser(); } }) })() } console.log(navigator); console.log(window); if ('serviceWorker' in navigator && 'PushManager' in window) { console.log('Service Worker and Push is supported'); navigator.serviceWorker.register("/sw.js") .then(function(swReg) { console.log('Service Worker is registered', swReg); swRegistration=swReg; initializeUI(); }) .catch(function(error) { console.error('Service Worker Error', error); }); } else { console.warn('Push messaging application ServerPublicKey is not supported'); } $(document).ready(function(){ $.ajax({ type:"GET", url:'/subscription/', success:function(response){ console.log("response",response); localStorage.setItem('applicationServerPublicKey',response.public_key); } }) }); 

The main point that you should pay attention to is the automatic re-subscription of the user when visiting the page. The server is designed to send a notification by username or any other user ID, so the re-subscription mechanism will always generate a new subscription for the user ID. This saves us problems with "lost" subscriptions on the server.

'use strict';/* eslint-disable max-len *//* eslint-enable max-len */function urlB64ToUint8Array(base64String) { const padding='='.repeat((4 - base64String.length % 4) % 4); const base64=(base64String + padding) .replace(/\-/g, '+') .replace(/_/g, '/'); const rawData=window.atob(base64); const outputArray=new Uint8Array(rawData.length); for (let i=0; i < rawData.length; ++i) { outputArray[i]=rawData.charCodeAt(i); } return outputArray; } function getEndpoint() { return self.registration.pushManager.getSubscription() .then(function(subscription) { if (subscription) { return subscription.endpoint; } throw new Error('User not subscribed'); }); } self.popNotification=function(title, body, tag, icon, url) { console.debug('Popup data:', tag, body, title, icon, url); self.registration.showNotification(title, { body: body, tag: tag, icon: icon }); self.onnotificationclick=function(event){ console.debug('On notification click: ', event.notification.tag); event.notification.close(); event.waitUntil( clients.openWindow(url) ); }; } var wait=ms => new Promise((r, j)=>setTimeout(r, ms)); self.addEventListener('push', function(event) { console.log('[Push]', event); if ( { var; var evtag=data.tag || 'notag'; self.popNotification(data.title || 'Default title', data.body || 'Body is not present', evtag, data.icon || '/static/images/default.svg', data.url || '/getevent?tag='+evtag); } else { event.waitUntil( getEndpoint().then(function(endpoint) { return fetch(endpoint); }).then(function(response) { return response.json(); }).then(function(payload) { console.debug('Payload',JSON.stringify(payload), payload.length); var evtag=payload.tag || 'notag'; self.popNotification(payload.title || 'Default title', payload.body || 'Body is not present', payload.tag || 'notag', payload.icon || '/static/images/default.svg', payload.url || '/getevent?tag='+evtag); }) ); } }); self.addEventListener('pushsubscriptionchange', function(event) { console.log('[Service Worker]: \'pushsubscriptionchange\' event fired.'); const applicationServerPublicKey=localStorage.getItem('applicationServerPublicKey'); const applicationServerKey=urlB64ToUint8Array(applicationServerPublicKey); event.waitUntil( self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: applicationServerKey }) .then(function(newSubscription) {//TODO: Send to application server console.log('[Service Worker] New subscription: ', newSubscription); }) ); }); 

According to the presented code, the Javascript file main.js initiates the receipt of a public VAPID key at its launch and forces the browser to subscribe to notifications.
For ease of debugging, the WebSocket server sends out a URL at the time of registration: .

Where does the username in the notification server come from. The whole point is that the initiation of the /subscription/ subscription occurs semi-automatically. Accordingly, depending on what you want to see as a user ID, you can transfer it after signing up at the time of the transfer of keys.

This is done by calling the POST method at the WebPush endpoint address sent by the server from the ServiceWorker module.

function subscribeUser() { const applicationServerPublicKey=localStorage.getItem('applicationServerPublicKey'); const applicationServerKey=urlB64ToUint8Array(applicationServerPublicKey); swRegistration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: applicationServerKey }) .then(function(subscription) { console.log('User is subscribed.', JSON.stringify(subscription)); localStorage.setItem('sub_token',JSON.stringify(subscription)); isSubscribed=true; fetch(subscription.endpoint, { method: 'POST', cache: 'no-cache', body: JSON.stringify(subscription) }) .then(function(response) { console.log('Push keys Update Response: ' + JSON.stringify(response)); }) }) .catch(function(err) { console.log('Failed to subscribe the user: ', err); }); } 

As it was written earlier, the server uses the connection point handler. This is a separate part of the code in the server script, but processing client WEB traffic from the browser instead of WebSocket .

As the processed header containing the user identifier, the basic version of the service used the basiclogin received during user authorization in LDAP.

location ~/subscription|/pushdata|/getdata|/wpush|/notify { proxy_pass http://localhost:8090; proxy_set_header LDAP-AuthUser $remote_user; proxy_set_header 'X-Remote-Addr' $remote_addr; add_header "Access-Control-Allow-Origin" "*"; add_header Last-Modified $date_gmt; proxy_hide_header "Authorization"; add_header Cache-Control 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0'; if_modified_since off; expires off; etag off; } 

To simplify the demonstration, I added a check in the headers of the user's external address. An authorization token can also be used, or any other method of user binding.

USERIDHEADERNAME='X-Remote-Addr' async def update_channel_keys(request, data): channel=request.path.replace('wpush','').replace('/','') logger.debug('update channel keys data: %s'%data) logger.debug('Update Channel keys Headers: %s' % request.headers) if USERIDHEADERNAME not in set(request.headers): return False basiclogin=request.headers[USERIDHEADERNAME] logger.debug('Login %s' % basiclogin) if basiclogin not in LOGINS_IN_CHANNELS: LOGINS_IN_CHANNELS.update({ '%s'%basiclogin : {} }) LOGINS_IN_CHANNELS['%s'%basiclogin].update({'%s' % channel : {} }) logger.debug('LOGINS_IN_CHANNELS: %s' % LOGINS_IN_CHANNELS) try: jdata=json.loads(data) if 'endpoint' in jdata and 'keys' in jdata: logger.debug('Saving Keys for Channel: %s => %s' % (channel, jdata)) CHANNELS[channel].register_keys(jdata['keys']) logger.debug('Registered channel keys %s:' % vars(CHANNELS[channel])) return True except Exception as ex: logger.error('Exception %s'%ex) return False 

This function saves on the server for the current channel of user alerts the key block and user name necessary for sending encrypted push messages to the browser. A user can have several sessions and each of them has its own set of keys.

Step 3
The session is registered, the keys are transferred to the server, it is time to send and receive messages.
As I described at the very beginning of the article, the notification service has two ways to deliver messages:

  • empty push notification when the browser "enters" the message queue itself
  • push notification containing encrypted data.

If the message is correctly formed, you must pass the tag field containing the unique identifier of the message. If the server has session keys for the client towards which the notification is transmitted, then it can encrypt this notification. If not, the server will send an empty notification and the client will come for this notification in the tag field.

The following code block implements the logic for receiving a message in ServiceWorker:

self.addEventListener('push', function(event) { console.log('[Push]', event); if ( { var; var evtag=data.tag || 'notag'; self.popNotification(data.title || 'Default title', data.body || 'Body is not present', evtag, data.icon || '/static/images/default.svg', data.url || '/getevent?tag='+evtag); } else { event.waitUntil( getEndpoint().then(function(endpoint) { return fetch(endpoint); }).then(function(response) { return response.json(); }).then(function(payload) { console.debug('Payload',JSON.stringify(payload), payload.length); var evtag=payload.tag || 'notag'; self.popNotification(payload.title || 'Default title', payload.body || 'Body is not present', payload.tag || 'notag', payload.icon || '/static/images/default.svg', payload.url || '/getevent?tag='+evtag); }) ); } }); 

If there are no encryption keys for the notification, you must organize a message queue through which ServiceWorker can receive all messages waiting for it. Since we are building a simple server implementation, all we need to do is save messages until a certain point, and then delete them. Because monitoring messages waiting to be received for more than 10 minutes are in most cases no longer relevant. At the same time, you can avoid storing “empty” messages in the queue to indicate the presence of notifications on the server.

The block of encryption of messages transmitted was taken from the "autopush" server code so as not to violate compatibility.

Message Encryption Block
def encrypt_message(self, data, content_encoding="aes128gcm"): """Encrypt the data. :param data: A serialized block of byte data (String, JSON, bit array, etc.) Make sure that whatever you send, your client knows how to understand it. :type data: str :param content_encoding: The content_encoding type to use to encrypt the data. Defaults to RFC8188 "aes128gcm". The previous draft-01 is "aesgcm", however this format is now deprecated. :type content_encoding: enum("aesgcm", "aes128gcm") """ # Salt is a random 16 byte array. if not data: logger.error("PushEncryptMessage: No data found...") return if not self.auth_key or not self.receiver_key: raise WebPushException("No keys specified in subscription info") logger.debug("PushEncryptMessage: Encoding data...") salt=None if content_encoding not in self.valid_encodings: raise WebPushException("Invalid content encoding specified. " "Select from " + json.dumps(self.valid_encodings)) if content_encoding == "aesgcm": logger.debug("PushEncryptMessage: Generating salt for aesgcm...") salt=os.urandom(16) # The server key is an ephemeral ECDH key used only for this # transaction server_key=ec.generate_private_key(ec.SECP256R1, default_backend()) crypto_key=server_key.public_key().public_bytes( encoding=serialization.Encoding.X962, format=serialization.PublicFormat.UncompressedPoint ) if isinstance(data, str): data=bytes(data.encode('utf8')) if content_encoding == "aes128gcm": logger.debug("Encrypting to aes128gcm...") encrypted=http_ece.encrypt( data, salt=salt, private_key=server_key, dh=self.receiver_key, auth_secret=self.auth_key, version=content_encoding) reply=CaseInsensitiveDict({ 'data': base64.urlsafe_b64encode(encrypted).decode() }) else: logger.debug("Encrypting to aesgcm...") crypto_key=base64.urlsafe_b64encode(crypto_key).strip(b'=') encrypted=http_ece.encrypt( data, salt=salt, private_key=server_key, keyid=crypto_key.decode(), dh=self.receiver_key, auth_secret=self.auth_key, version=content_encoding) reply=CaseInsensitiveDict({ 'crypto_key': crypto_key, 'data': base64.urlsafe_b64encode(encrypted).decode() }) if salt: reply['salt']=base64.urlsafe_b64encode(salt).strip(b'=') reply['headers']={ 'encoding': content_encoding } return reply 

The most interesting thing in this block of code is that for each message a new unique key pair is generated, which is used when encrypting the message. Otherwise, the usual implementation of the data encryption mechanism.

The full-fledged logic of the newly implemented server can be described in the article for a long time, so you can follow the link find a ready webpush server that does all the necessary work. I did not include in the logic of the webpush server the processing unit for broadcast requests and receiving data from the queue, becauseConsidered it redundant (cryptography works stably, so there is no need to overload the system with unused functionality). If necessary, this functionality is implemented very quickly.

WebPush AsyncIO server

To deploy the server you need:

  • Install the necessary Python modules, as well as configure nginx following the example of the attached configuration file.
  • Put the contents of the web directory at the root of a previously configured virtual server
  • Restart/re-read nginx config
  • In the browser, via about: config change the dom.push.serverURL parameter to the address wss://your server/ws
  • Before changing the address of the push server, you can clear the dom.push.userAgentID field, which will be automatically populated if your Push server is working correctly and accepts connections.
  • To test alerts, go to the page https://your server/indexpush.html and by opening the debug window make sure ServiceWorker is registered correctly
  • Click the "Check Push Notify"
  • If everything is configured correctly, a pop-up message will appear

As stated at the beginning of the article, the system was designed to promptly alert technical support. Depending on the desired behavior logic, the following webhook handler can be used in Zabbix

var req=new CurlHttpRequest(); req.AddHeader('Content-Type: application/x-www-form-urlencoded'); var jv=JSON.parse(value); if (jv.recovery_nstatus == '{EVENT.RECOVERY.VALUE}') { jv.icon='/static/images/problem/' + jv.event_severity + '.svg'; } else { jv.icon='/static/images/recovery/' + jv.event_severity + '.svg'; } value=JSON.stringify(jv); Zabbix.Log(2, 'webhook request value='+value); req.Post('', value ); Zabbix.Log(2, 'response code: '+req.Status()); return JSON.stringify({ 'tags': { 'endpoint': 'webpush' } }); 

with parameters

Key Value
url /zabbix/tr_events.php?triggerid={TRIGGER.IDasket&eventid={EVENT.ID}
recipient {ALERT.SENDTO}
event_severity {EVENT.NSEVERITY}
recovery_nstatus {EVENT.RECOVERY.VALUE}

If you add beautiful pictures from FontAwesome, it will turn out like this
ITKarma picture

WebPush server supports the following calls:

That's basically it. I hope you got some questions about how WebPush works. Thank you for your time reading the material.

© Aborche 2020