-
Notifications
You must be signed in to change notification settings - Fork 6
Added S2Pairing implementation #85
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
bad86c9
7b251cd
54c2b95
dfe01ae
8565d8e
1ed9342
c05abec
d172b4c
81cbb06
baefbea
531387a
3e08c74
a79fff6
2a34578
89c3536
eea9962
e7db42b
a703b1d
7522b21
6f43d0c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,4 @@ | ||
| import argparse | ||
| import re | ||
| from functools import partial | ||
| import logging | ||
| import sys | ||
|
|
@@ -157,7 +156,7 @@ def stop(s2_connection, signal_num, _current_stack_frame): | |
| print(f"Received signal {signal_num}. Will stop S2 connection.") | ||
| s2_connection.stop() | ||
|
|
||
| def start_s2_session(url, client_node_id=str(uuid.uuid4())): | ||
| def start_s2_session(url, client_node_id=str(uuid.uuid4()), bearer_token=None): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Solution to the challenge
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @VladIftime one thing to keep in mind: we'd like it to be backwards compatible so that existing code will keep working when the S2 pairing is eventually put into the main library. That's why I added the default of None. |
||
| s2_conn = S2Connection( | ||
| url=url, | ||
| role=EnergyManagementRole.RM, | ||
|
|
@@ -172,7 +171,8 @@ def start_s2_session(url, client_node_id=str(uuid.uuid4())): | |
| provides_power_measurements=[CommodityQuantity.ELECTRIC_POWER_L1] | ||
| ), | ||
| reconnect=True, | ||
| verify_certificate=False | ||
| verify_certificate=False, | ||
| bearer_token=bearer_token | ||
| ) | ||
| signal.signal(signal.SIGINT, partial(stop, s2_conn)) | ||
| signal.signal(signal.SIGTERM, partial(stop, s2_conn)) | ||
|
|
@@ -181,7 +181,11 @@ def start_s2_session(url, client_node_id=str(uuid.uuid4())): | |
|
|
||
| if __name__ == "__main__": | ||
| parser = argparse.ArgumentParser(description="A simple S2 reseource manager example.") | ||
| parser.add_argument('endpoint', type=str, help="WebSocket endpoint uri for the server (CEM) e.g. ws://localhost:8080/backend/rm/s2python-frbc/cem/dummy_model/ws") | ||
| parser.add_argument( | ||
| 'endpoint', | ||
| type=str, | ||
| help="WebSocket endpoint uri for the server (CEM) e.h. ws://localhost:8080/websocket/s2/my-first-websocket-rm" | ||
| ) | ||
| args = parser.parse_args() | ||
|
|
||
| start_s2_session(args.endpoint) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import argparse | ||
| import uuid | ||
| import logging | ||
|
|
||
| from example_frbc_rm import start_s2_session | ||
| from s2python.s2_pairing import S2Pairing | ||
| from s2python.generated.gen_s2_pairing import S2NodeDescription, Deployment | ||
| from s2python.generated.gen_s2 import EnergyManagementRole | ||
|
|
||
| logger = logging.getLogger("s2python") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| parser = argparse.ArgumentParser(description="A simple S2 reseource manager example.") | ||
| parser.add_argument('endpoint', | ||
| type=str, | ||
| help="Rest endpoint to start S2 pairing. E.g. https://localhost/requestPairing") | ||
| parser.add_argument('pairing_token', | ||
| type=str, | ||
| help="The pairing toekn for teh endpoint. You should get this from the S2 server e.g. ca14fda4") | ||
| args = parser.parse_args() | ||
|
|
||
| nodeDescription: S2NodeDescription = \ | ||
| S2NodeDescription(brand="TNO", | ||
| logoUri = "https://www.tno.nl/publish/pages/5604/tno-logo-1484x835_003_.jpg", | ||
| type = "demo frbc example", | ||
| modelName = "S2 pairing example stub", | ||
| userDefinedName = "TNO S2 pairing example for frbc", | ||
| role = EnergyManagementRole.RM, | ||
| deployment = Deployment.LAN) | ||
| client_node_id: str = str(uuid.uuid4()) | ||
|
|
||
| pairing: S2Pairing = S2Pairing(request_pairing_endpoint = args.endpoint, | ||
| token = args.pairing_token, | ||
| s2_client_node_description = nodeDescription, | ||
| client_node_id = client_node_id) | ||
|
|
||
| logger.info("Pairing details: \n%s", pairing.pairing_details) | ||
|
|
||
| start_s2_session(pairing.pairing_details.connection_details.connectionUri, | ||
| bearer_token=pairing.pairing_details.decrypted_challenge_base64) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| # generated by datamodel-codegen: | ||
| # filename: s2-over-ip-pairing | ||
| # timestamp: 2025-02-28T14:52:45+00:00 | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from enum import Enum | ||
| from typing import List | ||
|
|
||
| from pydantic import BaseModel, ConfigDict, Field | ||
|
|
||
|
|
||
| class S2Role(str, Enum): | ||
| CEM = 'CEM' | ||
| RM = 'RM' | ||
|
|
||
| class Deployment(str, Enum): | ||
| WAN = 'WAN' | ||
| LAN = 'LAN' | ||
|
|
||
|
|
||
| class Protocols(str, Enum): | ||
| WebSocketSecure = 'WebSocketSecure' | ||
|
|
||
|
|
||
| class PairingInfo(BaseModel): | ||
| model_config = ConfigDict( | ||
| extra='forbid', | ||
| ) | ||
| pairingUri: str | ||
| token: str | ||
| validUntil: str | ||
|
|
||
|
|
||
| class S2NodeDescription(BaseModel): | ||
| model_config = ConfigDict( | ||
| extra='forbid', | ||
| ) | ||
| brand: str | ||
| logoUri: str | ||
| type: str | ||
| modelName: str | ||
| userDefinedName: str | ||
| role: S2Role | ||
| deployment: Deployment | ||
|
|
||
|
|
||
| class PairingRequest(BaseModel): | ||
| model_config = ConfigDict( | ||
| extra='forbid', | ||
| ) | ||
| token: str | ||
| publicKey: str | ||
| s2ClientNodeId: str | ||
| s2ClientNodeDescription: S2NodeDescription | ||
| supportedProtocols: List[Protocols] | ||
|
|
||
|
|
||
| class PairingResponse(BaseModel): | ||
| model_config = ConfigDict( | ||
| extra='forbid', | ||
| ) | ||
| s2ServerNodeId: str | ||
| serverNodeDescription: S2NodeDescription | ||
| requestConnectionUri: str | ||
|
|
||
|
|
||
| class ConnectionRequest(BaseModel): | ||
| model_config = ConfigDict( | ||
| extra='forbid', | ||
| ) | ||
| s2ClientNodeId: str | ||
| supportedProtocols: List[Protocols] | ||
|
|
||
|
|
||
| class ConnectionDetails(BaseModel): | ||
| model_config = ConfigDict( | ||
| extra='forbid', | ||
| ) | ||
| selectedProtocol: Protocols | ||
| challenge: str | ||
| connectionUri: str |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| import base64 | ||
| import logging | ||
| import uuid | ||
| import datetime | ||
| from dataclasses import dataclass | ||
| from typing import Tuple, Union, Mapping, Any | ||
| import json | ||
| import requests | ||
|
|
||
| from jwskate import JweCompact, Jwk, Jwt, SignedJwt | ||
|
|
||
| from s2python.generated.gen_s2_pairing import (Protocols, | ||
| PairingRequest, | ||
| S2NodeDescription, | ||
| PairingResponse, | ||
| ConnectionRequest, | ||
| ConnectionDetails) | ||
|
|
||
|
|
||
| logger = logging.getLogger("s2python") | ||
|
|
||
|
|
||
| REQTEST_TIMEOUT = 10 | ||
| PAIRING_TIMEOUT = datetime.timedelta(minutes=5) | ||
| KEY_ALGORITHM = "RSA-OAEP-256" | ||
|
|
||
| @dataclass(frozen=True) | ||
| class PairingDetails: | ||
| """The result of an S2 pairing | ||
| :param pairing_response: Details about the server. | ||
| :param connection_details: Details about how to connect. | ||
| :param decrypted_challenge_base64: The decrypted challenge needed as bearer token.""" | ||
| pairing_response: PairingResponse | ||
| connection_details: ConnectionDetails | ||
| decrypted_challenge_base64: str | ||
|
|
||
| class S2Pairing: # pylint: disable=too-many-instance-attributes | ||
| _pairing_details: PairingDetails | ||
| _paring_timestamp: datetime.datetime | ||
| _request_pairing_endpoint: str | ||
| _token: str | ||
| _s2_client_node_description: S2NodeDescription | ||
| _verify_certificate: Union[bool, str] | ||
| _client_node_id: str | ||
| _supported_protocols: Tuple[Protocols] | ||
| def __init__( # pylint: disable=too-many-arguments | ||
| self, | ||
| request_pairing_endpoint: str, | ||
| token: str, | ||
| s2_client_node_description: S2NodeDescription, | ||
| verify_certificate: Union[bool, str] = False, | ||
| client_node_id: str = str(uuid.uuid4()), | ||
| supported_protocols: Tuple[Protocols] = (Protocols.WebSocketSecure, ) | ||
| ) -> None: | ||
| """Creates an S2 pairing for the device and holds the challenge needed to be provided as bearer token | ||
| when setting up an S2 (websockets) communication session | ||
| :param request_pairing_endpoint: The full uri endpoint to request pairing from. | ||
| :param token: The token that needs to be provided to the server in teh pairing process. | ||
| :param s2_client_node_description: The descriptin ofr the client as a S2NodeDescription. | ||
| :param verify_certificate: Either a boolean whether or not to verify the server's SSL certificate | ||
| (defaults to False), or a path to a certificate file to use for verification purposes. | ||
| :param client_node_id: UUID for the client. If none is given, one will be generated. | ||
| :param supported_protocols: The protocols supported by the client (defaults: Protocols.WebSocketSecure).""" | ||
| self._paring_timestamp = datetime.datetime(year = datetime.MINYEAR, month = 1, day = 1) | ||
| self._request_pairing_endpoint = request_pairing_endpoint | ||
| self._token = token | ||
| self._s2_client_node_description = s2_client_node_description | ||
| self._verify_certificate = verify_certificate | ||
| self._client_node_id = client_node_id | ||
| self._supported_protocols = supported_protocols | ||
|
|
||
| def _pair(self) -> None: | ||
| """Private method establishing pairing""" | ||
| # If pairing has been established recently we don't need to do it again | ||
| if datetime.datetime.now() < (self._paring_timestamp + PAIRING_TIMEOUT): | ||
| return | ||
|
|
||
| self._paring_timestamp = datetime.datetime.now() | ||
|
|
||
| rsa_key_pair = Jwk.generate_for_alg(KEY_ALGORITHM).with_kid_thumbprint() | ||
| pairing_request: PairingRequest = PairingRequest(token=self._token, | ||
| publicKey=rsa_key_pair.public_jwk().to_pem(), | ||
| s2ClientNodeId=self._client_node_id, | ||
| s2ClientNodeDescription=self._s2_client_node_description, | ||
| supportedProtocols=self._supported_protocols) | ||
|
|
||
| response = requests.post(self._request_pairing_endpoint, | ||
| json = pairing_request.dict(), | ||
| timeout = REQTEST_TIMEOUT, | ||
| verify = self._verify_certificate) | ||
| response.raise_for_status() | ||
| pairing_response: PairingResponse = PairingResponse.parse_raw(response.text) | ||
|
|
||
| connection_request: ConnectionRequest = ConnectionRequest(s2ClientNodeId=self._client_node_id, | ||
| supportedProtocols=self._supported_protocols) | ||
|
|
||
|
|
||
| restest_pairing_uri: str = \ | ||
| pairing_response.requestConnectionUri if hasattr(pairing_response, 'requestConnectionUri') \ | ||
| and pairing_response.requestConnectionUri is not None \ | ||
| and pairing_response.requestConnectionUri != "" \ | ||
| else self._request_pairing_endpoint.replace('requestPairing', | ||
| 'requestConnection') | ||
|
|
||
| logger.info('restest_pairing_uri %s ', restest_pairing_uri) | ||
|
|
||
| response = requests.post(restest_pairing_uri, | ||
| json = connection_request.dict(), | ||
| timeout = REQTEST_TIMEOUT, | ||
| verify = self._verify_certificate) | ||
| response.raise_for_status() | ||
| connection_details: ConnectionDetails = ConnectionDetails.parse_raw(response.text) | ||
| # if websocket address doesn't start with ws:// or wss:// assume it's relative to the requestPairing | ||
| if not connection_details.connectionUri.startswith('ws://') \ | ||
| and not connection_details.connectionUri.startswith('wss://'): | ||
| connection_details.connectionUri = \ | ||
| self._request_pairing_endpoint.replace('http://', 'ws://') \ | ||
| .replace('https://', 'wss://') \ | ||
| .replace('requestPairing', '') \ | ||
| .rstrip('/') \ | ||
| + '/' + connection_details.connectionUri.lstrip('/') | ||
| logger.info('connectionUri %s ', connection_details.connectionUri) | ||
|
|
||
| challenge: Mapping[str, Any] = json.loads(JweCompact(connection_details.challenge).decrypt(rsa_key_pair)) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the 'solving' of the challenge |
||
| decrypted_challenge_token: SignedJwt = Jwt.unprotected(challenge) | ||
| decrypted_challenge_str: str = base64.b64encode(bytes(decrypted_challenge_token)).decode('utf-8') | ||
| self._pairing_details = PairingDetails(pairing_response, connection_details, decrypted_challenge_str) | ||
|
|
||
|
|
||
| @property | ||
| def pairing_details(self) -> PairingDetails: | ||
| """:raises: requests.exceptions.HTTPError, requests.exceptions.JSONDecodeError | ||
| :return: PairingDetails object that's the result of the latest pairing.""" | ||
| self._pair() | ||
| return self._pairing_details | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PyJWT also does JWKS https://pyjwt.readthedocs.io/en/stable/usage.html#retrieve-rsa-signing-keys-from-a-jwks-endpoint. I'd generally go with the library that hast most community adoption. PyJWT also has sponsorship from Auth0. I don't have experience with jwskate, but judging from the github repositories, pyjwt has much wider spread adoption and support.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did start by looking at PyJWT but everything I've read indicates that it can only sign with the private key and verify with the public key (or use the same key to sign & verify). What we need is the opposite, according to the spec:
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this already part of the S2 standard?
I think there are industry standard solutions that don't require us to invent custom logic here.
What I would suggest is to put OAuth2 authentication on the websocket endpoint. The resulting JWT token that is then issued by the authentication provider contains the data that is needed to start the S2 session and can be read as-is. All the websocket endpoint needs to do is verify the jwt token was issued by a valid issuer. There is no custom S2 code necessary before the HTTPUpgrade. All the websocket endpoint needs to do is verify that the jwt token that the RM provides was signed by a trusted issuer (which JWKS can be used for).
Encryption isn't really necessary either unless we need to put privacy/security sensitive stuff in the jwt token.
Further encryption of course can be handled with HTTPS/TLS.
As for the discovery side:
The only assumption when implementing OAuth for this is: The new device that starts communicating with the CEM is owned by a person/company/etc that has some type of existing relation with the CEM supplier.
With the OAuth Device Authorization flow, during hardware setup, the user would authenticate with the CEM provider (in a mobile app for instance), "add" a device, receive the required data for the new device, and store it in the hardware. This could be done over bluetooth, local network http, cloud api, nfc, etc in a mobile app either from the hardware supplier, or from the CEM provider. The device then retrieves its own token from the oauth provider (which is unique for the hardware and can be revoked by the user) and uses that from then on to authenticate when making an S2 connection to the websocket endpoint.
The elegance of this is that S2 does not need to have any knowledge of any of it. It only needs to make sure it trusts the right authorization provider.
Source: https://auth0.com/docs/get-started/authentication-and-authorization-flow/device-authorization-flow