In this commit, we moved all features related to the PWA and the PWA itself to the community. This includes: * PWA * Web Push Notification * VCARD Note from original commits: =========================== PWA (part 1) ------------ This commit adds a ServiceWorker to complement the WebManifest to complete the setup of the backend as a Progressive Web App. More precisely, it adds the route, registration and the most basic ServiceWorker to allow the backend to be recognized as an installable PWA. References: - https://web.dev/install-criteria/ - https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Installable_PWAs - https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers Task ID: 3063485 PWA (part 2) ------------ This commit adds a WebManifest as a first step toward setuping the backend as a Progressive Web App. In a nutshell: - the web app's name is configurable through a config parameter (available in the Settings, in debug); defaulting to "Odoo". - the web app's icon has been revamped to accommodate the required sizes; also its design matches the one from the Android app. - "theme-color" is used to color part of the browser/system UI to match Enterprise brand color; also supports the dark mode. References: - https://web.dev/learn/pwa/web-app-manifest/ - https://web.dev/install-criteria/ - https://developer.mozilla.org/en-US/docs/Web/Manifest Task ID: 3063485 PWA shortcuts ------------- The main goal of this commit is like we did inside the `Android Odoo Mobile App`, allowing users to have some Odoo application shortcuts. We added the following apps in the key `shortcuts` on `web.manifest` in these orders: `Discuss`, `CRM`, `Project`, `To-Do` (old `Notes`). Links: - https://w3c.github.io/manifest/#shortcuts-member - https://developer.mozilla.org/en-US/docs/Web/Manifest/shortcuts Task ID: 3123607 Offline mode ------------ This commit introduces a way to notify the user that he's "offline" (aka. cannot reach its Odoo server) and that Odoo doesn't work in a graceful way in this circumstance. To do so, the Service-Worker will return the response of the ´web/offline´ route, which is cached at its setup. Note: this screen is only show when launched while "offline" and fails to load the requested page. It does not "interrupt" the WebClient to show this screen when the connection drops off (cf. not a replacement for the existing notification). Task ID: 3203639 WebPush ------- WebPush allows sending data to the user browser/app(PWA) even when tab/app is closed. Web push is a "constant" link between the ServiceWorker of browser/app and a WebPush server. Note that each browser has its own custom WebPush server. e.g.: Chrome: https://fcm.googleapis.com/ Firefox: https://updates.push.services.mozilla.com/ Safari: https://web.push.apple.com/ Edge: https://wns2-ln2p.notify.windows.com/ WebPush introduces some cryptographic notion to ensure some the reliability of the data sent: VAPID: "Voluntary Application Server Identification" is the standard used to generate the public and the private to sign the message between the browser and the WebPush server JWT: "JSON Web Token" is the standard used to sign the payload to the WebPush server ECE: "Encrypted Content-Encoding" is the standard used by WebPush to encrypt the data of the payload to avoid sending RAW data outside trusted network. Simplified steps how to WebPush works: The Javascript code of a web page subscribes to the WebPush server (using the VAPID key generated at mail_entreprise install). The WebPush server replay with a subscription (and some other info like the unique URL endpoint per subscription where to send a notification) The application (odoo-bin in our case) sends a post request to the WebPush server using the specific URL endpoint of the user (using JWT and ECE). The WebPush server sends back to the browser the encrypted payload. The browser decrypts the payload and sends it to the ServiceWorker linked to the subscription. Here is a Sequence diagram of all interactions to process a web push notification. In Odoo, we use WebPush to send Notification to the user. This commit aims to have a parity with the Android/iOS Mobile App at the notification level. Notes: There are some ways to encrypt (ECE) the message for WebPush: AESGCM128: this is a draft AESGCM: very well documented AES128GCM: RFC8188 Standard encoding We implement only the RFC one as it is the only one implemented in all major updated browsers (Chrome, Firefox, Safari, Edge, ...) You need to allow the desktop "Notification" and "Push" inside your browser. For iOS Devices, it only works on iOS 16.4+ and it's requiring Odoo to first be added to the Home Screen. It's delivered silently, meaning no sound, vibration, haptics or screen wake. Note: Notifications are sent directly if there are less than five notifications, otherwise we use a cron triggered immediately. Also, we have changed the value of the "QueryCount" as mail_enterprise executes a new query to search the devices associated with the partner. We have added a "try/except" for any Exception before the push_to_end_point method as we want to avoid blocking a normal flow just for a not mandatory push notification if something happens during the push to the endpoint. See: odoo/enterprise@d0ae70103d Links: https://www.rfc-editor.org/rfc/rfc8030 https://www.rfc-editor.org/rfc/rfc8188 https://www.rfc-editor.org/rfc/rfc8291 https://www.rfc-editor.org/rfc/rfc8292 https://w3c.github.io/push-api/index.html https://autopush.readthedocs.io/en/latest/http.html https://web.dev/push-notifications-web-push-protocol/ https://github.com/web-push-libs/encrypted-content-encoding https://github.com/web-push-libs/pywebpush https://github.com/web-push-libs/vapid https://caniuse.com/push-api Task ID: 3123678 VCARD ----- In the process of replacing the native methods exposed in the mobile apps, this commit implements the download of a vCard containing a partner's information. By using this standard format, both regular web users and mobile ones are now able to save the partner's details to use them with their usual address book software. On a mobile device, the actual import of those informations is delegated to the operating system. References: - https://datatracker.ietf.org/doc/html/rfc6350 - https://en.wikipedia.org/wiki/VCard - https://github.com/eventable/vobject#vcards Task ID: 2583916 =========== End of note =========== Task ID: 3478014 closes odoo/odoo#133560 Related: odoo/enterprise#46530 Related: odoo/upgrade#5086 Signed-off-by: Adrien Dieudonné (adr) <adr@odoo.com> Co-authored-by: Romeo Fragomeli <rfr@odoo.com> Co-authored-by: Romain Estievenart <res@odoo.com> Co-authored-by: Pierre Paridans <app@odoo.com>
207 lines
8.4 KiB
Python
207 lines
8.4 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
import base64
|
|
import binascii
|
|
import json
|
|
import logging as logger
|
|
import os
|
|
import struct
|
|
import textwrap
|
|
import time
|
|
|
|
from cryptography.hazmat.backends import default_backend
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
from cryptography.hazmat.primitives.asymmetric import ec, utils
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
|
from urllib.parse import urlparse
|
|
|
|
MAX_PAYLOAD_SIZE = 4096
|
|
|
|
_logger = logger.getLogger(__name__)
|
|
|
|
def _base64_decode_with_padding(value):
|
|
return base64.urlsafe_b64decode(value + '==')
|
|
|
|
def generate_web_push_vapid_key():
|
|
"""
|
|
Generate the VAPID (Voluntary Application Server Identification) used for the Web Push
|
|
This function generates a signing key pair usable with the Elliptic Curve Digital
|
|
Signature Algorithm (ECDSA) over the P-256 curve.
|
|
These keys will be used during communication with the endpoint/browser
|
|
https://www.rfc-editor.org/rfc/rfc8292
|
|
"""
|
|
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
|
private_int = private_key.private_numbers().private_value
|
|
private = private_int.to_bytes(32, 'big')
|
|
private_string = base64.urlsafe_b64encode(private).decode('ascii').strip('=')
|
|
|
|
public_key = private_key.public_key()
|
|
public = public_key.public_bytes(
|
|
encoding=serialization.Encoding.X962,
|
|
format=serialization.PublicFormat.UncompressedPoint
|
|
)
|
|
public_string = base64.urlsafe_b64encode(public).decode('ascii').strip('=')
|
|
return private_string, public_string
|
|
|
|
def _generate_jwt(endpoint, base_url, vapid_private_key):
|
|
"""
|
|
JWT are a pair of JSON objects, turned into base64 strings, and signed with the private ECDH key
|
|
https://www.rfc-editor.org/rfc/rfc7519
|
|
https://www.rfc-editor.org/rfc/rfc8291
|
|
:param endpoint: the browser endpoint
|
|
:param base_url: the base url
|
|
:param vapid_private_key: the private ECDH key generate at mail_entreprise install
|
|
:return:
|
|
"""
|
|
url = urlparse(endpoint)
|
|
|
|
jwt_info = base64.urlsafe_b64encode(json.dumps({
|
|
'typ': 'JWT',
|
|
'alg': 'ES256'
|
|
}).encode())
|
|
|
|
# The expiration is a timestamp in seconds and must be no longer 12 hours.
|
|
token_validity = 12 * 60 * 60
|
|
|
|
jwt_data = base64.urlsafe_b64encode(json.dumps({
|
|
# aud: The “Audience” is a JWT construct that indicates the recipient scheme and host
|
|
# e.g. for an endpoint like https://updates.push.services.mozilla.com/wpush/v2/gAAAAABY...,
|
|
# the “aud” would be https://updates.push.services.mozilla.com
|
|
'aud': '{}://{}'.format(url.scheme, url.netloc),
|
|
# sub: the sub value needs to be either a URL address. This is so that if a push service needed to reach out
|
|
# to sender, it can find contact information from the JWT.
|
|
'sub': base_url,
|
|
# exp: It's the expiration of the JWT, this prevents snoopers from being able to re-use a JWT if they intercept it.
|
|
'exp': int(time.time()) + token_validity
|
|
}).encode())
|
|
|
|
unsigned_token = '{}.{}'.format(jwt_info.decode().strip('='), jwt_data.decode().strip('='))
|
|
|
|
# Retrieve the private key using a P256 elliptic curve
|
|
vapid_private_key_decoded = _base64_decode_with_padding(vapid_private_key)
|
|
private_key = ec.derive_private_key(int(binascii.hexlify(vapid_private_key_decoded), 16), ec.SECP256R1(), default_backend())
|
|
|
|
# sign with ECDSA SHA-256
|
|
signature = private_key.sign(unsigned_token.encode(), ec.ECDSA(hashes.SHA256()))
|
|
(r, s) = utils.decode_dss_signature(signature)
|
|
sig = base64.urlsafe_b64encode(r.to_bytes(32, 'big') + s.to_bytes(32, 'big'))
|
|
|
|
return '{}.{}'.format(unsigned_token, sig.decode().strip('='))
|
|
|
|
def _iv(base, counter):
|
|
mask = int.from_bytes(base[4:], 'big')
|
|
return base[:4] + (counter ^ mask).to_bytes(8, 'big')
|
|
|
|
def _derive_key(salt, private_key, device):
|
|
# browser keys
|
|
device_keys = json.loads(device["keys"])
|
|
p256dh = _base64_decode_with_padding(device_keys.get('p256dh'))
|
|
auth = _base64_decode_with_padding(device_keys.get('auth'))
|
|
|
|
# generate a public key derived from the browser public key
|
|
pub_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), p256dh)
|
|
sender_pub_key = private_key.public_key().public_bytes(
|
|
Encoding.X962, PublicFormat.UncompressedPoint
|
|
)
|
|
|
|
context = b"WebPush: info\x00" + p256dh + sender_pub_key
|
|
key_info = b"Content-Encoding: aes128gcm\x00"
|
|
nonce_info = b"Content-Encoding: nonce\x00"
|
|
|
|
# Create the 3 HKDF keys needed to encrypt the message (auth, key, nonce)
|
|
hkdf_auth = HKDF(
|
|
algorithm=hashes.SHA256(),
|
|
length=32,
|
|
salt=auth,
|
|
info=context,
|
|
backend=default_backend(),
|
|
)
|
|
hkdf_key = HKDF(
|
|
algorithm=hashes.SHA256(),
|
|
length=16,
|
|
salt=salt,
|
|
info=key_info,
|
|
backend=default_backend(),
|
|
)
|
|
hkdf_nonce = HKDF(
|
|
algorithm=hashes.SHA256(),
|
|
length=12,
|
|
salt=salt,
|
|
info=nonce_info,
|
|
backend=default_backend(),
|
|
)
|
|
secret = hkdf_auth.derive(private_key.exchange(ec.ECDH(), pub_key))
|
|
return hkdf_key.derive(secret), hkdf_nonce.derive(secret)
|
|
|
|
def _encrypt_payload(content, device, record_size=MAX_PAYLOAD_SIZE):
|
|
"""
|
|
Encrypt a payload for Push Notification Endpoint using AES128GCM
|
|
|
|
https://www.rfc-editor.org/rfc/rfc7516
|
|
https://www.rfc-editor.org/rfc/rfc8188
|
|
:param content: the unencrypted payload
|
|
:param device: the web push user browser information
|
|
:param record_size: record size must be bigger than 18
|
|
:return: the encrypted payload
|
|
"""
|
|
# The private_key is an ephemeral ECDH key used only for a transaction
|
|
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
|
|
salt = os.urandom(16)
|
|
# generate key
|
|
(key, nonce) = _derive_key(salt=salt, private_key=private_key, device=device)
|
|
# AEAD_AES_128_GCM produces ciphertext 16 octets longer than its input plaintext.
|
|
# Therefore, the unencrypted content of each record is shorter than the record size by 16 octets.
|
|
# Valid records always contain at least a padding delimiter octet and a 16-octet authentication tag.
|
|
overhead = 1 + 16
|
|
chunk_size = record_size - overhead
|
|
|
|
body = b""
|
|
end = len(content)
|
|
aesgcm = AESGCM(key)
|
|
for i in range(0, end, chunk_size):
|
|
padding = b"\x02" if (i + chunk_size) >= end else b"\x01"
|
|
body += aesgcm.encrypt(nonce, content[i: i + chunk_size] + padding, None)
|
|
|
|
sender_public_key = private_key.public_key().public_bytes(
|
|
Encoding.X962, PublicFormat.UncompressedPoint
|
|
)
|
|
|
|
# +-----------+-----------------+---------------------------+-------------------------------------------+
|
|
# | salt (16) | record_size (4) | sender_public_key.len (1) | sender_public_key (sender_public_key.len) |
|
|
# +-----------+-----------------+---------------------------+-------------------------------------------+
|
|
header = struct.pack("!16sLB", salt, record_size, len(sender_public_key))
|
|
header += sender_public_key
|
|
return header + body
|
|
|
|
def push_to_end_point(base_url, device, payload, vapid_private_key, vapid_public_key, session):
|
|
endpoint = device["endpoint"]
|
|
jwt = _generate_jwt(endpoint, base_url, vapid_private_key)
|
|
body_payload = payload.encode()
|
|
payload = _encrypt_payload(body_payload, device)
|
|
headers = {
|
|
# Authorization header field contains these parameters:
|
|
# - "t" is the JWT;
|
|
# - "k" the base64url-encoded key that signed that token.
|
|
'Authorization': 'vapid t={}, k={}'.format(jwt, vapid_public_key),
|
|
'Content-Encoding': 'aes128gcm',
|
|
'TTL': '0',
|
|
}
|
|
|
|
response = session.post(endpoint, headers=headers, data=payload, timeout=5)
|
|
if response.status_code == 201:
|
|
_logger.debug('Sent push notification %s', endpoint)
|
|
else:
|
|
error_message_shorten = textwrap.shorten(response.text, 100)
|
|
_logger.warning('Failed push notification %s %d - %s',
|
|
endpoint, response.status_code, error_message_shorten)
|
|
|
|
# Invalid subscription
|
|
if response.status_code == 404 or response.status_code == 410:
|
|
raise DeviceUnreachableError("Device Unreachable")
|
|
|
|
|
|
class DeviceUnreachableError(Exception):
|
|
pass
|