From 9f425ff5792348c2f6dc3e7c3297c82f7fb5e793 Mon Sep 17 00:00:00 2001 From: Tobias Oberstein Date: Tue, 19 Jul 2022 22:45:05 +0200 Subject: [PATCH] add more helpers to EthereumKey and CryptosignKey (#1583) * add more helpers to EthereumKey and CryptosignKey * add eip712 types for wamp-cryptosign certificates * forward correct TLS channel ID once the TLS handshake is complete * improve message logging at trace log level * add cert chain test * expand capabilities * update changelog; expand tests --- autobahn/_version.py | 2 +- autobahn/asyncio/rawsocket.py | 12 +- autobahn/asyncio/websocket.py | 10 +- autobahn/twisted/rawsocket.py | 12 +- autobahn/twisted/util.py | 21 +- autobahn/twisted/websocket.py | 10 +- autobahn/util.py | 60 +++ autobahn/wamp/cryptosign.py | 83 +++- autobahn/wamp/message.py | 157 +----- autobahn/wamp/websocket.py | 28 +- autobahn/xbr/__init__.py | 18 + autobahn/xbr/_eip712_authority_certificate.py | 304 ++++++++++++ autobahn/xbr/_eip712_base.py | 10 + autobahn/xbr/_eip712_certificate.py | 40 ++ autobahn/xbr/_eip712_certificate_chain.py | 119 +++++ autobahn/xbr/_eip712_delegate_certificate.py | 298 ++++++++++++ autobahn/xbr/_secmod.py | 66 ++- autobahn/xbr/_userkey.py | 69 +-- autobahn/xbr/test/test_xbr_eip712.py | 448 ++++++++++++++++++ docs/changelog.rst | 10 + 20 files changed, 1517 insertions(+), 260 deletions(-) create mode 100644 autobahn/xbr/_eip712_authority_certificate.py create mode 100644 autobahn/xbr/_eip712_certificate.py create mode 100644 autobahn/xbr/_eip712_certificate_chain.py create mode 100644 autobahn/xbr/_eip712_delegate_certificate.py create mode 100644 autobahn/xbr/test/test_xbr_eip712.py diff --git a/autobahn/_version.py b/autobahn/_version.py index eec737645..85f050c6e 100644 --- a/autobahn/_version.py +++ b/autobahn/_version.py @@ -24,6 +24,6 @@ # ############################################################################### -__version__ = '22.6.1' +__version__ = '22.7.1.dev1' __build__ = '00000000-0000000' diff --git a/autobahn/asyncio/rawsocket.py b/autobahn/asyncio/rawsocket.py index 02277a053..df981f8e4 100644 --- a/autobahn/asyncio/rawsocket.py +++ b/autobahn/asyncio/rawsocket.py @@ -33,7 +33,7 @@ import txaio from autobahn.util import public, _LazyHexFormatter, hltype from autobahn.wamp.exception import ProtocolError, SerializationError, TransportLost -from autobahn.asyncio.util import get_serializers, create_transport_details +from autobahn.asyncio.util import get_serializers, create_transport_details, transport_channel_id __all__ = ( 'WampRawSocketServerProtocol', @@ -279,7 +279,17 @@ class WampRawSocketMixinGeneral(object): def _on_handshake_complete(self): self.log.debug("WampRawSocketProtocol: Handshake complete") + # RawSocket connection established. Now let the user WAMP session factory + # create a new WAMP session and fire off session open callback. try: + if self._transport_details.is_secure: + # now that the TLS opening handshake is complete, the actual TLS channel ID + # will be available. make sure to set it! + channel_id = { + 'tls-unique': transport_channel_id(self.transport, self._transport_details.is_server, 'tls-unique'), + } + self._transport_details.channel_id = channel_id + self._session = self.factory._factory() self._session.onOpen(self) except Exception as e: diff --git a/autobahn/asyncio/websocket.py b/autobahn/asyncio/websocket.py index 73be6a25b..aca8781c1 100644 --- a/autobahn/asyncio/websocket.py +++ b/autobahn/asyncio/websocket.py @@ -34,7 +34,7 @@ txaio.use_asyncio() # noqa from autobahn.util import public, hltype -from autobahn.asyncio.util import create_transport_details +from autobahn.asyncio.util import create_transport_details, transport_channel_id from autobahn.wamp import websocket from autobahn.websocket import protocol @@ -119,6 +119,14 @@ def _closeConnection(self, abort=False): self.transport.close() def _onOpen(self): + if self._transport_details.is_secure: + # now that the TLS opening handshake is complete, the actual TLS channel ID + # will be available. make sure to set it! + channel_id = { + 'tls-unique': transport_channel_id(self.transport, self._transport_details.is_server, 'tls-unique'), + } + self._transport_details.channel_id = channel_id + res = self.onOpen() if yields(res): asyncio.ensure_future(res) diff --git a/autobahn/twisted/rawsocket.py b/autobahn/twisted/rawsocket.py index 7a87270a7..99e952c54 100644 --- a/autobahn/twisted/rawsocket.py +++ b/autobahn/twisted/rawsocket.py @@ -36,7 +36,7 @@ from twisted.internet.defer import CancelledError from autobahn.util import public, _LazyHexFormatter -from autobahn.twisted.util import create_transport_details +from autobahn.twisted.util import create_transport_details, transport_channel_id from autobahn.wamp.types import TransportDetails from autobahn.wamp.exception import ProtocolError, SerializationError, TransportLost, InvalidUriError from autobahn.exception import PayloadExceededError @@ -113,7 +113,17 @@ def connectionMade(self): self._max_len_send = None def _on_handshake_complete(self): + # RawSocket connection established. Now let the user WAMP session factory + # create a new WAMP session and fire off session open callback. try: + if self._transport_details.is_secure: + # now that the TLS opening handshake is complete, the actual TLS channel ID + # will be available. make sure to set it! + channel_id = { + 'tls-unique': transport_channel_id(self.transport, self._transport_details.is_server, 'tls-unique'), + } + self._transport_details.channel_id = channel_id + self._session = self.factory._factory() self.log.debug('{klass}._on_handshake_complete(): calling {method}', session=self._session, klass=self.__class__.__name__, method=self._session.onOpen) diff --git a/autobahn/twisted/util.py b/autobahn/twisted/util.py index c51795a63..f183ac3af 100644 --- a/autobahn/twisted/util.py +++ b/autobahn/twisted/util.py @@ -174,17 +174,19 @@ def transport_channel_id(transport: object, is_server: bool, channel_id_type: Op # see also: https://bugs.python.org/file22646/tls_channel_binding.patch if is_server != is_not_resumed: # for routers (=servers) XOR new sessions, the channel ID is based on the TLS Finished message we - # expected to receive from the client + # expected to receive from the client: contents of the message or None if the TLS handshake has + # not yet completed. tls_finished_msg = connection.get_peer_finished() else: # for clients XOR resumed sessions, the channel ID is based on the TLS Finished message we sent - # to the router (=server) + # to the router (=server): contents of the message or None if the TLS handshake has not yet completed. tls_finished_msg = connection.get_finished() if tls_finished_msg is None: - # this can occur if we made a successful connection (in a - # TCP sense) but something failed with the TLS handshake - # (e.g. invalid certificate) + # this can occur when: + # 1. we made a successful connection (in a TCP sense) but something failed with + # the TLS handshake (e.g. invalid certificate) + # 2. the TLS handshake has not yet completed return b'\x00' * 32 else: m = hashlib.sha256() @@ -280,6 +282,7 @@ def create_transport_details(transport: Union[ITransport, IProcessTransport], is if _HAS_TLS and ISSLTransport.providedBy(transport): channel_id = { + # this will only be filled when the TLS opening handshake is complete (!) 'tls-unique': transport_channel_id(transport, is_server, 'tls-unique'), } channel_type = TransportDetails.CHANNEL_TYPE_TLS @@ -294,6 +297,8 @@ def create_transport_details(transport: Union[ITransport, IProcessTransport], is # FIXME: really set a default (websocket)? channel_framing = TransportDetails.CHANNEL_FRAMING_WEBSOCKET - return TransportDetails(channel_type=channel_type, channel_framing=channel_framing, peer=peer, - is_server=is_server, own_pid=own_pid, own_tid=own_tid, own_fd=own_fd, - is_secure=is_secure, channel_id=channel_id, peer_cert=peer_cert) + td = TransportDetails(channel_type=channel_type, channel_framing=channel_framing, peer=peer, + is_server=is_server, own_pid=own_pid, own_tid=own_tid, own_fd=own_fd, + is_secure=is_secure, channel_id=channel_id, peer_cert=peer_cert) + + return td diff --git a/autobahn/twisted/websocket.py b/autobahn/twisted/websocket.py index c0d8dfe84..6a4cc352a 100644 --- a/autobahn/twisted/websocket.py +++ b/autobahn/twisted/websocket.py @@ -49,7 +49,7 @@ from autobahn.websocket.types import ConnectionRequest, ConnectionResponse, ConnectionDeny from autobahn.websocket import protocol from autobahn.websocket.interfaces import IWebSocketClientAgent -from autobahn.twisted.util import create_transport_details +from autobahn.twisted.util import create_transport_details, transport_channel_id from autobahn.websocket.compress import PerMessageDeflateOffer, \ PerMessageDeflateOfferAccept, \ @@ -355,6 +355,14 @@ def _closeConnection(self, abort=False): self.transport.loseConnection() def _onOpen(self): + if self._transport_details.is_secure: + # now that the TLS opening handshake is complete, the actual TLS channel ID + # will be available. make sure to set it! + channel_id = { + 'tls-unique': transport_channel_id(self.transport, self._transport_details.is_server, 'tls-unique'), + } + self._transport_details.channel_id = channel_id + self.onOpen() def _onMessageBegin(self, isBinary): diff --git a/autobahn/util.py b/autobahn/util.py index acc48cc50..191af3bc1 100644 --- a/autobahn/util.py +++ b/autobahn/util.py @@ -35,6 +35,7 @@ import binascii import socket import subprocess +from collections import OrderedDict from typing import Optional from datetime import datetime, timedelta @@ -69,6 +70,8 @@ "generate_serial_number", "generate_user_password", "machine_id", + 'parse_keyfile', + 'write_keyfile', "hl", "hltype", "hlid", @@ -982,3 +985,60 @@ def without_0x(address): if address and address.startswith('0x'): return address[2:] return address + + +def write_keyfile(filepath, tags, msg): + """ + Internal helper, write the given tags to the given file- + """ + with open(filepath, 'w') as f: + f.write(msg) + for (tag, value) in tags.items(): + if value: + f.write('{}: {}\n'.format(tag, value)) + + +def parse_keyfile(key_path: str, private: bool = True) -> OrderedDict: + """ + Internal helper. This parses a node.pub or node.priv file and + returns a dict mapping tags -> values. + """ + if os.path.exists(key_path) and not os.path.isfile(key_path): + raise Exception("Key file '{}' exists, but isn't a file".format(key_path)) + + allowed_tags = [ + # common tags + 'public-key-ed25519', + 'public-adr-eth', + 'created-at', + 'creator', + + # user profile + 'user-id', + + # node profile + 'machine-id', + 'node-authid', + 'node-cluster-ip', + ] + + if private: + # private key file tags + allowed_tags.extend(['private-key-ed25519', 'private-key-eth']) + + tags = OrderedDict() # type: ignore + with open(key_path, 'r') as key_file: + got_blankline = False + for line in key_file.readlines(): + if line.strip() == '': + got_blankline = True + elif got_blankline: + tag, value = line.split(':', 1) + tag = tag.strip().lower() + value = value.strip() + if tag not in allowed_tags: + raise Exception("Invalid tag '{}' in key file {}".format(tag, key_path)) + if tag in tags: + raise Exception("Duplicate tag '{}' in key file {}".format(tag, key_path)) + tags[tag] = value + return tags diff --git a/autobahn/wamp/cryptosign.py b/autobahn/wamp/cryptosign.py index e7f5bf22c..367cb0536 100644 --- a/autobahn/wamp/cryptosign.py +++ b/autobahn/wamp/cryptosign.py @@ -24,6 +24,7 @@ # ############################################################################### +import os import binascii from binascii import a2b_hex, b2a_hex import struct @@ -35,6 +36,7 @@ from autobahn.wamp.interfaces import ISecurityModule, ICryptosignKey from autobahn.wamp.types import Challenge from autobahn.wamp.message import _URI_PAT_REALM_NAME_ETH +from autobahn.util import parse_keyfile __all__ = [ 'HAS_CRYPTOSIGN', @@ -435,7 +437,6 @@ def process(signature_raw): return d2 - @util.public class CryptosignKey(object): """ A cryptosign private key for signing, and hence usable for authentication or a @@ -489,7 +490,6 @@ def can_sign(self) -> bool: """ return self._can_sign - @util.public def sign(self, data: bytes) -> bytes: """ Implements :meth:`autobahn.wamp.interfaces.IKey.sign`. @@ -508,7 +508,6 @@ def sign(self, data: bytes) -> bytes: # the signature return txaio.create_future_success(sig.signature) - @util.public def sign_challenge(self, challenge: Challenge, channel_id: Optional[bytes] = None, channel_id_type: Optional[str] = None) -> bytes: """ @@ -521,7 +520,6 @@ def sign_challenge(self, challenge: Challenge, channel_id: Optional[bytes] = Non return _sign_challenge(data, self.sign) - @util.public def public_key(self, binary: bool = False) -> Union[str, bytes]: """ Returns the public key part of a signing key or the (public) verification key. @@ -539,22 +537,32 @@ def public_key(self, binary: bool = False) -> Union[str, bytes]: else: return key.encode(encoder=encoding.HexEncoder).decode('ascii') - @util.public @classmethod - def from_bytes(cls, key_data: bytes, comment: Optional[str] = None) -> 'CryptosignKey': + def from_pubkey(cls, pubkey: bytes, comment: Optional[str] = None) -> 'CryptosignKey': if not (comment is None or type(comment) == str): raise ValueError("invalid type {} for comment".format(type(comment))) - if type(key_data) != bytes: - raise ValueError("invalid key type {} (expected binary)".format(type(key_data))) + if type(pubkey) != bytes: + raise ValueError("invalid key type {} (expected binary)".format(type(pubkey))) - if len(key_data) != 32: - raise ValueError("invalid key length {} (expected 32)".format(len(key_data))) + if len(pubkey) != 32: + raise ValueError("invalid key length {} (expected 32)".format(len(pubkey))) - key = signing.SigningKey(key_data) - return cls(key=key, can_sign=True, comment=comment) + return cls(key=signing.VerifyKey(pubkey), can_sign=False, comment=comment) + + @classmethod + def from_bytes(cls, key: bytes, comment: Optional[str] = None) -> 'CryptosignKey': + if not (comment is None or type(comment) == str): + raise ValueError("invalid type {} for comment".format(type(comment))) + + if type(key) != bytes: + raise ValueError("invalid key type {} (expected binary)".format(type(key))) + + if len(key) != 32: + raise ValueError("invalid key length {} (expected 32)".format(len(key))) + + return cls(key=signing.SigningKey(key), can_sign=True, comment=comment) - @util.public @classmethod def from_file(cls, filename: str, comment: Optional[str] = None) -> 'CryptosignKey': """ @@ -581,7 +589,6 @@ def from_file(cls, filename: str, comment: Optional[str] = None) -> 'CryptosignK return cls.from_bytes(key_data, comment=comment) - @util.public @classmethod def from_ssh_file(cls, filename: str) -> 'CryptosignKey': """ @@ -594,7 +601,6 @@ def from_ssh_file(cls, filename: str) -> 'CryptosignKey': key_data = f.read().decode('utf-8').strip() return cls.from_ssh_bytes(key_data) - @util.public @classmethod def from_ssh_bytes(cls, key_data: str) -> 'CryptosignKey': """ @@ -645,6 +651,53 @@ def from_seedphrase(cls, seedphrase: str, index: int = 0) -> 'CryptosignKey': return key + @classmethod + def from_keyfile(cls, keyfile: str) -> 'CryptosignKey': + """ + Create a public or private key from reading the given public or private key file. + + Here is an example key file that includes an CryptosignKey private key ``private-key-ed25519``, which + is loaded in this function, and other fields, which are ignored by this function: + + .. code-block:: + + This is a comment (all lines until the first empty line are comments indeed). + + creator: oberstet@intel-nuci7 + created-at: 2022-07-05T12:29:48.832Z + user-id: oberstet@intel-nuci7 + public-key-ed25519: 7326d9dc0307681cc6940fde0e60eb31a6e4d642a81e55c434462ce31f95deed + public-adr-eth: 0x10848feBdf7f200Ba989CDf7E3eEB3EC03ae7768 + private-key-ed25519: f750f42b0430e28a2e272c3cedcae4dcc4a1cf33bc345c35099d3322626ab666 + private-key-eth: 4d787714dcb0ae52e1c5d2144648c255d660b9a55eac9deeb80d9f506f501025 + + :param keyfile: Path (relative or absolute) to a public or private keys file. + :return: New instance of :class:`CryptosignKey` + """ + if not os.path.exists(keyfile) or not os.path.isfile(keyfile): + raise RuntimeError('keyfile "{}" is not a file'.format(keyfile)) + + # now load the private or public key file - this returns a dict which should + # include (for a private key): + # + # private-key-ed25519: 20e8c05d0ede9506462bb049c4843032b18e8e75b314583d0c8d8a4942f9be40 + # + # or (for a public key only): + # + # public-key-ed25519: 7326d9dc0307681cc6940fde0e60eb31a6e4d642a81e55c434462ce31f95deed + # + data = parse_keyfile(keyfile) + + privkey_ed25519_hex = data.get('private-key-ed25519', None) + if privkey_ed25519_hex is None: + pubkey_ed25519_hex = data.get('public-key-ed25519', None) + if pubkey_ed25519_hex is None: + raise RuntimeError('neither "private-key-ed25519" nor "public-key-ed25519" found in keyfile {}'.format(keyfile)) + else: + return CryptosignKey.from_pubkey(binascii.a2b_hex(pubkey_ed25519_hex)) + else: + return CryptosignKey.from_bytes(binascii.a2b_hex(privkey_ed25519_hex)) + ICryptosignKey.register(CryptosignKey) class CryptosignAuthextra(object): diff --git a/autobahn/wamp/message.py b/autobahn/wamp/message.py index c3bc162d5..100e36058 100644 --- a/autobahn/wamp/message.py +++ b/autobahn/wamp/message.py @@ -26,9 +26,12 @@ import re import binascii +import textwrap +from pprint import pformat from typing import Any, Dict, Optional import autobahn +from autobahn.util import hlval from autobahn.wamp.exception import ProtocolError, InvalidUriError from autobahn.wamp.role import ROLE_NAME_TO_CLASS @@ -531,6 +534,10 @@ def __ne__(self, other): """ return not self.__eq__(other) + def __str__(self) -> str: + return '{}\n{}'.format(hlval(self.__class__.__name__.upper() + '::', color='blue', bold=True), + hlval(textwrap.indent(pformat(self.marshal()), ' '), color='blue', bold=False)) + @staticmethod def parse(wmsg): """ @@ -830,12 +837,6 @@ def marshal(self): return [Hello.MESSAGE_TYPE, self.realm, details] - def __str__(self): - """ - Return a string representation of this message. - """ - return "Hello(realm={}, roles={}, authmethods={}, authid={}, authrole={}, authextra={}, resumable={}, resume_session={}, resume_token={})".format(self.realm, self.roles, self.authmethods, self.authid, self.authrole, self.authextra, self.resumable, self.resume_session, self.resume_token) - class Welcome(Message): """ @@ -1081,12 +1082,6 @@ def marshal(self): return [Welcome.MESSAGE_TYPE, self.session, details] - def __str__(self): - """ - Returns string representation of this message. - """ - return "Welcome(session={}, roles={}, realm={}, authid={}, authrole={}, authmethod={}, authprovider={}, authextra={}, resumed={}, resumable={}, resume_token={})".format(self.session, self.roles, self.realm, self.authid, self.authrole, self.authmethod, self.authprovider, self.authextra, self.resumed, self.resumable, self.resume_token) - class Abort(Message): """ @@ -1167,12 +1162,6 @@ def marshal(self): return [Abort.MESSAGE_TYPE, details, self.reason] - def __str__(self): - """ - Returns string representation of this message. - """ - return "Abort(message={0}, reason={1})".format(self.message, self.reason) - class Challenge(Message): """ @@ -1242,12 +1231,6 @@ def marshal(self): """ return [Challenge.MESSAGE_TYPE, self.method, self.extra] - def __str__(self): - """ - Returns string representation of this message. - """ - return "Challenge(method={0}, extra={1})".format(self.method, self.extra) - class Authenticate(Message): """ @@ -1317,12 +1300,6 @@ def marshal(self): """ return [Authenticate.MESSAGE_TYPE, self.signature, self.extra] - def __str__(self): - """ - Returns string representation of this message. - """ - return "Authenticate(signature={0}, extra={1})".format(self.signature, self.extra) - class Goodbye(Message): """ @@ -1425,12 +1402,6 @@ def marshal(self): return [Goodbye.MESSAGE_TYPE, details, self.reason] - def __str__(self): - """ - Returns string representation of this message. - """ - return "Goodbye(message={}, reason={}, resumable={})".format(self.message, self.reason, self.resumable) - class Error(Message): """ @@ -1731,12 +1702,6 @@ def marshal(self): else: return [self.MESSAGE_TYPE, self.request_type, self.request, details, self.error] - def __str__(self): - """ - Returns string representation of this message. - """ - return "Error(request_type={0}, request={1}, error={2}, args={3}, kwargs={4}, enc_algo={5}, enc_key={6}, enc_serializer={7}, payload={8}, callee={9}, callee_authid={10}, callee_authrole={11}, forward_for={12})".format(self.request_type, self.request, self.error, self.args, self.kwargs, self.enc_algo, self.enc_key, self.enc_serializer, b2a(self.payload), self.callee, self.callee_authid, self.callee_authrole, self.forward_for) - class Publish(Message): """ @@ -2714,12 +2679,6 @@ def marshal(self): else: return [Publish.MESSAGE_TYPE, self.request, options, self.topic] - def __str__(self): - """ - Returns string representation of this message. - """ - return "Publish(request={}, topic={}, args={}, kwargs={}, acknowledge={}, exclude_me={}, exclude={}, exclude_authid={}, exclude_authrole={}, eligible={}, eligible_authid={}, eligible_authrole={}, retain={}, transaction_hash={}, enc_algo={}, enc_key={}, enc_serializer={}, payload={}, forward_for={})".format(self.request, self.topic, self.args, self.kwargs, self.acknowledge, self.exclude_me, self.exclude, self.exclude_authid, self.exclude_authrole, self.eligible, self.eligible_authid, self.eligible_authrole, self.retain, self.transaction_hash, self.enc_algo, self.enc_key, self.enc_serializer, b2a(self.payload), self.forward_for) - class Published(Message): """ @@ -2786,12 +2745,6 @@ def marshal(self): """ return [Published.MESSAGE_TYPE, self.request, self.publication] - def __str__(self): - """ - Returns string representation of this message. - """ - return "Published(request={0}, publication={1})".format(self.request, self.publication) - class Subscribe(Message): """ @@ -2947,12 +2900,6 @@ def marshal(self): """ return [Subscribe.MESSAGE_TYPE, self.request, self.marshal_options(), self.topic] - def __str__(self): - """ - Returns string representation of this message. - """ - return "Subscribe(request={0}, topic={1}, match={2}, get_retained={3}, forward_for={4})".format(self.request, self.topic, self.match, self.get_retained, self.forward_for) - class Subscribed(Message): """ @@ -3019,12 +2966,6 @@ def marshal(self): """ return [Subscribed.MESSAGE_TYPE, self.request, self.subscription] - def __str__(self): - """ - Returns string representation of this message. - """ - return "Subscribed(request={0}, subscription={1})".format(self.request, self.subscription) - class Unsubscribe(Message): """ @@ -3135,12 +3076,6 @@ def marshal(self): else: return [Unsubscribe.MESSAGE_TYPE, self.request, self.subscription] - def __str__(self): - """ - Returns string representation of this message. - """ - return "Unsubscribe(request={0}, subscription={1}, forward_for={2})".format(self.request, self.subscription, self.forward_for) - class Unsubscribed(Message): """ @@ -3242,12 +3177,6 @@ def marshal(self): else: return [Unsubscribed.MESSAGE_TYPE, self.request] - def __str__(self): - """ - Returns string representation of this message. - """ - return "Unsubscribed(request={0}, reason={1}, subscription={2})".format(self.request, self.reason, self.subscription) - class Event(Message): """ @@ -3940,12 +3869,6 @@ def marshal(self): else: return [Event.MESSAGE_TYPE, self.subscription, self.publication, details] - def __str__(self): - """ - Returns string representation of this message. - """ - return "Event(subscription={}, publication={}, args={}, kwargs={}, publisher={}, publisher_authid={}, publisher_authrole={}, topic={}, retained={}, transaction_hash={}, enc_algo={}, enc_key={}, enc_serializer={}, payload={}, forward_for={})".format(self.subscription, self.publication, self.args, self.kwargs, self.publisher, self.publisher_authid, self.publisher_authrole, self.topic, self.retained, self.transaction_hash, self.enc_algo, self.enc_key, self.enc_serializer, b2a(self.payload), self.forward_for) - class EventReceived(Message): """ @@ -4006,12 +3929,6 @@ def marshal(self): """ return [EventReceived.MESSAGE_TYPE, self.publication] - def __str__(self): - """ - Returns string representation of this message. - """ - return "EventReceived(publication={})".format(self.publication) - class Call(Message): """ @@ -4366,12 +4283,6 @@ def marshal(self): else: return [Call.MESSAGE_TYPE, self.request, options, self.procedure] - def __str__(self): - """ - Returns string representation of this message. - """ - return "Call(request={}, procedure={}, args={}, kwargs={}, timeout={}, receive_progress={}, transaction_hash={}, enc_algo={}, enc_key={}, enc_serializer={}, payload={}, caller={}, caller_authid={}, caller_authrole={}, forward_for={})".format(self.request, self.procedure, self.args, self.kwargs, self.timeout, self.receive_progress, self.transaction_hash, self.enc_algo, self.enc_key, self.enc_serializer, b2a(self.payload), self.caller, self.caller_authid, self.caller_authrole, self.forward_for) - class Cancel(Message): """ @@ -4501,12 +4412,6 @@ def marshal(self): return [Cancel.MESSAGE_TYPE, self.request, options] - def __str__(self): - """ - Returns string representation of this message. - """ - return "Cancel(request={0}, mode={1})".format(self.request, self.mode) - class Result(Message): """ @@ -4798,12 +4703,6 @@ def marshal(self): else: return [Result.MESSAGE_TYPE, self.request, details] - def __str__(self): - """ - Returns string representation of this message. - """ - return "Result(request={0}, args={1}, kwargs={2}, progress={3}, enc_algo={4}, enc_key={5}, enc_serializer={6}, payload={7}, callee={8}, callee_authid={9}, callee_authrole={10}, forward_for={11})".format(self.request, self.args, self.kwargs, self.progress, self.enc_algo, self.enc_key, self.enc_serializer, b2a(self.payload), self.callee, self.callee_authid, self.callee_authrole, self.forward_for) - class Register(Message): """ @@ -5029,12 +4928,6 @@ def marshal(self): """ return [Register.MESSAGE_TYPE, self.request, self.marshal_options(), self.procedure] - def __str__(self): - """ - Returns string representation of this message. - """ - return "Register(request={0}, procedure={1}, match={2}, invoke={3}, concurrency={4}, force_reregister={5}, forward_for={6})".format(self.request, self.procedure, self.match, self.invoke, self.concurrency, self.force_reregister, self.forward_for) - class Registered(Message): """ @@ -5101,12 +4994,6 @@ def marshal(self): """ return [Registered.MESSAGE_TYPE, self.request, self.registration] - def __str__(self): - """ - Returns string representation of this message. - """ - return "Registered(request={0}, registration={1})".format(self.request, self.registration) - class Unregister(Message): """ @@ -5211,12 +5098,6 @@ def marshal(self): else: return [Unregister.MESSAGE_TYPE, self.request, self.registration] - def __str__(self): - """ - Returns string representation of this message. - """ - return "Unregister(request={0}, registration={1})".format(self.request, self.registration) - class Unregistered(Message): """ @@ -5317,12 +5198,6 @@ def marshal(self): else: return [Unregistered.MESSAGE_TYPE, self.request] - def __str__(self): - """ - Returns string representation of this message. - """ - return "Unregistered(request={0}, reason={1}, registration={2})".format(self.request, self.reason, self.registration) - class Invocation(Message): """ @@ -5686,12 +5561,6 @@ def marshal(self): else: return [Invocation.MESSAGE_TYPE, self.request, self.registration, options] - def __str__(self): - """ - Returns string representation of this message. - """ - return "Invocation(request={0}, registration={1}, args={2}, kwargs={3}, timeout={4}, receive_progress={5}, caller={6}, caller_authid={7}, caller_authrole={8}, procedure={9}, transaction_hash={10}, enc_algo={11}, enc_key={12}, enc_serializer={13}, payload={14})".format(self.request, self.registration, self.args, self.kwargs, self.timeout, self.receive_progress, self.caller, self.caller_authid, self.caller_authrole, self.procedure, self.transaction_hash, self.enc_algo, self.enc_key, self.enc_serializer, b2a(self.payload)) - class Interrupt(Message): """ @@ -5841,12 +5710,6 @@ def marshal(self): return [Interrupt.MESSAGE_TYPE, self.request, options] - def __str__(self): - """ - Returns string representation of this message. - """ - return "Interrupt(request={0}, mode={1}, reason={2})".format(self.request, self.mode, self.reason) - class Yield(Message): """ @@ -6135,9 +5998,3 @@ def marshal(self): return [Yield.MESSAGE_TYPE, self.request, options, self.args] else: return [Yield.MESSAGE_TYPE, self.request, options] - - def __str__(self): - """ - Returns string representation of this message. - """ - return "Yield(request={0}, args={1}, kwargs={2}, progress={3}, enc_algo={4}, enc_key={5}, enc_serializer={6}, payload={7}, callee={8}, callee_authid={9}, callee_authrole={10}, forward_for={11})".format(self.request, self.args, self.kwargs, self.progress, self.enc_algo, self.enc_key, self.enc_serializer, b2a(self.payload), self.callee, self.callee_authid, self.callee_authrole, self.forward_for) diff --git a/autobahn/wamp/websocket.py b/autobahn/wamp/websocket.py index d93e219b8..fcbe7925b 100644 --- a/autobahn/wamp/websocket.py +++ b/autobahn/wamp/websocket.py @@ -29,6 +29,7 @@ from typing import Optional, Dict, Tuple +from autobahn.util import hlval from autobahn.websocket import protocol from autobahn.websocket.types import ConnectionDeny, ConnectionRequest, ConnectionResponse from autobahn.wamp.types import TransportDetails @@ -63,6 +64,7 @@ def onOpen(self): try: self._session = self.factory._factory() self._session._transport = self + self._session.onOpen(self) except Exception as e: self.log.critical("{tb}", tb=traceback.format_exc()) @@ -94,12 +96,13 @@ def onMessage(self, payload: bytes, isBinary: bool): """ try: for msg in self._serializer.unserialize(payload, isBinary): - self.log.trace( - "WAMP RECV: message={message}, session={session}, authid={authid}", - authid=self._session._authid, - session=self._session._session_id, - message=msg, - ) + self.log.trace('\n{action1}{session}, {authid}{action2}\n {message}\n{action3}', + action1=hlval('WAMP-Receive(', color='green', bold=True), + authid=hlval(self._session._authid, color='green', bold=False) if self._session._authid else '-', + session=hlval(self._session._session_id, color='green', bold=False) if self._session._session_id else '-', + action2=hlval(') <<', color='green', bold=True), + action3=hlval('<<', color='green', bold=True), + message=msg) self._session.onMessage(msg) except ProtocolError as e: @@ -118,12 +121,13 @@ def send(self, msg): """ if self.isOpen(): try: - self.log.trace( - "WAMP SEND: message={message}, session={session}, authid={authid}", - authid=self._session._authid, - session=self._session._session_id, - message=msg, - ) + self.log.trace('\n{action1}{session}, {authid}{action2}\n {message}\n{action3}', + action1=hlval('WAMP-Transmit(', color='red', bold=True), + authid=hlval(self._session._authid, color='red', bold=False) if self._session._authid else '-', + session=hlval(self._session._session_id, color='red', bold=False) if self._session._session_id else '-', + action2=hlval(') >>', color='red', bold=True), + action3=hlval('>>', color='red', bold=True), + message=msg) payload, isBinary = self._serializer.serialize(msg) except Exception as e: self.log.error("WAMP message serialization error: {}".format(e)) diff --git a/autobahn/xbr/__init__.py b/autobahn/xbr/__init__.py index a6bd36d45..0ecb2ae48 100644 --- a/autobahn/xbr/__init__.py +++ b/autobahn/xbr/__init__.py @@ -50,6 +50,12 @@ from autobahn.xbr._abi import XBR_DEBUG_TOKEN_ADDR_SRC, XBR_DEBUG_NETWORK_ADDR_SRC, XBR_DEBUG_MARKET_ADDR_SRC, XBR_DEBUG_CATALOG_ADDR_SRC, XBR_DEBUG_CHANNEL_ADDR_SRC # noqa from autobahn.xbr._interfaces import IMarketMaker, IProvider, IConsumer, ISeller, IBuyer, IDelegate # noqa from autobahn.xbr._util import make_w3, pack_uint256, unpack_uint256 # noqa + from autobahn.xbr._eip712_certificate import EIP712Certificate # noqa + from autobahn.xbr._eip712_certificate_chain import parse_certificate_chain # noqa + from autobahn.xbr._eip712_authority_certificate import sign_eip712_authority_certificate, \ + recover_eip712_authority_certificate, create_eip712_authority_certificate, EIP712AuthorityCertificate # noqa + from autobahn.xbr._eip712_delegate_certificate import sign_eip712_delegate_certificate, \ + recover_eip712_delegate_certificate, create_eip712_delegate_certificate, EIP712DelegateCertificate # noqa from autobahn.xbr._eip712_member_register import sign_eip712_member_register, recover_eip712_member_register # noqa from autobahn.xbr._eip712_member_login import sign_eip712_member_login, recover_eip712_member_login # noqa from autobahn.xbr._eip712_market_create import sign_eip712_market_create, recover_eip712_market_create # noqa @@ -328,6 +334,18 @@ def account_from_ethkey(ethkey: bytes) -> eth_account.account.Account: 'account_from_seedphrase', 'ASCII_BOMB', + 'EIP712Certificate', + 'EIP712AuthorityCertificate', + 'EIP712DelegateCertificate', + 'parse_certificate_chain', + + 'create_eip712_authority_certificate', + 'sign_eip712_authority_certificate', + 'recover_eip712_authority_certificate', + 'create_eip712_delegate_certificate', + 'sign_eip712_delegate_certificate', + 'recover_eip712_delegate_certificate', + 'sign_eip712_member_register', 'recover_eip712_member_register', 'sign_eip712_member_login', diff --git a/autobahn/xbr/_eip712_authority_certificate.py b/autobahn/xbr/_eip712_authority_certificate.py new file mode 100644 index 000000000..63bc41b5b --- /dev/null +++ b/autobahn/xbr/_eip712_authority_certificate.py @@ -0,0 +1,304 @@ +############################################################################### +# +# The MIT License (MIT) +# +# Copyright (c) Crossbar.io Technologies GmbH +# +# 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. +# +############################################################################### + +from binascii import a2b_hex + +from autobahn.wamp.message import _URI_PAT_REALM_NAME_ETH + +from ._eip712_base import sign, recover, is_chain_id, is_address, is_block_number, is_signature, is_eth_privkey +from ._eip712_certificate import EIP712Certificate + + +def create_eip712_authority_certificate(chainId: int, + verifyingContract: bytes, + validFrom: int, + issuer: bytes, + subject: bytes, + realm: bytes, + capabilities: int, + meta: str) -> dict: + """ + Authority certificate: long-lived, on-chain L2. + + :param chainId: + :param verifyingContract: + :param validFrom: + :param issuer: + :param subject: + :param realm: + :param capabilities: + :param meta: + :return: + """ + assert is_chain_id(chainId) + assert is_address(verifyingContract) + assert is_block_number(validFrom) + assert is_address(issuer) + assert is_address(subject) + assert is_address(realm) + assert type(capabilities) == int and 0 <= capabilities <= 2 ** 53 + assert meta is None or type(meta) == str + + data = { + 'types': { + 'EIP712Domain': [ + { + 'name': 'name', + 'type': 'string' + }, + { + 'name': 'version', + 'type': 'string' + }, + ], + 'EIP712AuthorityCertificate': [ + { + 'name': 'chainId', + 'type': 'uint256' + }, + { + 'name': 'verifyingContract', + 'type': 'address' + }, + { + 'name': 'validFrom', + 'type': 'uint256' + }, + { + 'name': 'issuer', + 'type': 'address' + }, + { + 'name': 'subject', + 'type': 'address' + }, + { + 'name': 'realm', + 'type': 'address' + }, + { + 'name': 'capabilities', + 'type': 'uint64' + }, + { + 'name': 'meta', + 'type': 'string' + } + ] + }, + 'primaryType': 'EIP712AuthorityCertificate', + 'domain': { + 'name': 'WMP', + 'version': '1', + }, + 'message': { + 'chainId': chainId, + 'verifyingContract': verifyingContract, + 'validFrom': validFrom, + 'issuer': issuer, + 'subject': subject, + 'realm': realm, + 'capabilities': capabilities, + 'meta': meta or '', + } + } + + return data + + +def sign_eip712_authority_certificate(eth_privkey: bytes, + chainId: int, + verifyingContract: bytes, + validFrom: int, + issuer: bytes, + subject: bytes, + realm: bytes, + capabilities: int, + meta: str) -> bytes: + """ + Sign the given data using a EIP712 based signature with the provided private key. + + :param eth_privkey: + :param chainId: + :param verifyingContract: + :param validFrom: + :param issuer: + :param subject: + :param realm: + :param capabilities: + :param meta: + :return: + """ + assert is_eth_privkey(eth_privkey) + + data = create_eip712_authority_certificate(chainId, verifyingContract, validFrom, issuer, + subject, realm, capabilities, meta) + return sign(eth_privkey, data) + + +def recover_eip712_authority_certificate(chainId: int, + verifyingContract: bytes, + validFrom: int, + issuer: bytes, + subject: bytes, + realm: bytes, + capabilities: int, + meta: str, + signature: bytes) -> bytes: + """ + Recover the signer address the given EIP712 signature was signed with. + + :param chainId: + :param verifyingContract: + :param validFrom: + :param issuer: + :param subject: + :param realm: + :param capabilities: + :param meta: + :param signature: + :return: The (computed) signer address the signature was signed with. + """ + assert is_signature(signature) + + data = create_eip712_authority_certificate(chainId, verifyingContract, validFrom, issuer, + subject, realm, capabilities, meta) + return recover(data, signature) + + +class EIP712AuthorityCertificate(EIP712Certificate): + CAPABILITY_ROOT_CA = 1 + CAPABILITY_INTERMEDIATE_CA = 2 + CAPABILITY_PUBLIC_RELAY = 4 + CAPABILITY_PRIVATE_RELAY = 8 + CAPABILITY_PROVIDER = 16 + CAPABILITY_CONSUMER = 32 + + __slots__ = ( + 'chainId', + 'verifyingContract', + 'validFrom', + 'issuer', + 'subject', + 'realm', + 'capabilities', + 'meta', + ) + + def __init__(self, chainId: int, verifyingContract: bytes, validFrom: int, issuer: bytes, subject: bytes, + realm: bytes, capabilities: int, meta: str): + super().__init__(chainId, verifyingContract, validFrom) + self.issuer = issuer + self.subject = subject + self.realm = realm + self.capabilities = capabilities + self.meta = meta + + def recover(self, signature: bytes) -> bytes: + return recover_eip712_authority_certificate(self.chainId, + self.verifyingContract, + self.validFrom, + self.issuer, + self.subject, + self.realm, + self.capabilities, + self.meta, + signature) + + @staticmethod + def parse(data) -> 'EIP712AuthorityCertificate': + if type(data) != dict: + raise ValueError('invalid type {} for EIP712AuthorityCertificate'.format(type(data))) + for k in data: + if k not in ['chainId', 'verifyingContract', 'validFrom', 'issuer', 'subject', + 'realm', 'capabilities', 'meta']: + raise ValueError('invalid attribute "{}" in EIP712AuthorityCertificate'.format(k)) + + chainId = data.get('chainId', None) + if chainId is None: + raise ValueError('missing chainId in EIP712AuthorityCertificate') + if type(chainId) != int: + raise ValueError('invalid type {} for chainId in EIP712AuthorityCertificate'.format(type(chainId))) + + verifyingContract = data.get('verifyingContract', None) + if verifyingContract is None: + raise ValueError('missing verifyingContract in EIP712AuthorityCertificate') + if type(verifyingContract) != str: + raise ValueError( + 'invalid type {} for verifyingContract in EIP712AuthorityCertificate'.format(type(verifyingContract))) + if not _URI_PAT_REALM_NAME_ETH.match(verifyingContract): + raise ValueError( + 'invalid value "{}" for verifyingContract in EIP712AuthorityCertificate'.format(verifyingContract)) + verifyingContract = a2b_hex(verifyingContract[2:]) + + validFrom = data.get('validFrom', None) + if validFrom is None: + raise ValueError('missing validFrom in EIP712AuthorityCertificate') + if type(validFrom) != int: + raise ValueError('invalid type {} for validFrom in EIP712AuthorityCertificate'.format(type(validFrom))) + + issuer = data.get('issuer', None) + if issuer is None: + raise ValueError('missing issuer in EIP712AuthorityCertificate') + if type(issuer) != str: + raise ValueError('invalid type {} for issuer in EIP712AuthorityCertificate'.format(type(issuer))) + if not _URI_PAT_REALM_NAME_ETH.match(issuer): + raise ValueError('invalid value "{}" for issuer in EIP712AuthorityCertificate'.format(issuer)) + issuer = a2b_hex(issuer[2:]) + + subject = data.get('subject', None) + if subject is None: + raise ValueError('missing subject in EIP712AuthorityCertificate') + if type(subject) != str: + raise ValueError('invalid type {} for subject in EIP712AuthorityCertificate'.format(type(subject))) + if not _URI_PAT_REALM_NAME_ETH.match(subject): + raise ValueError('invalid value "{}" for subject in EIP712AuthorityCertificate'.format(subject)) + subject = a2b_hex(subject[2:]) + + realm = data.get('realm', None) + if realm is None: + raise ValueError('missing realm in EIP712AuthorityCertificate') + if type(realm) != str: + raise ValueError('invalid type {} for realm in EIP712AuthorityCertificate'.format(type(realm))) + if not _URI_PAT_REALM_NAME_ETH.match(realm): + raise ValueError('invalid value "{}" for realm in EIP712AuthorityCertificate'.format(realm)) + realm = a2b_hex(realm[2:]) + + capabilities = data.get('capabilities', None) + if capabilities is None: + raise ValueError('missing capabilities in EIP712AuthorityCertificate') + if type(capabilities) != int: + raise ValueError('invalid type {} for capabilities in EIP712AuthorityCertificate'.format(type(capabilities))) + + meta = data.get('meta', None) + if meta is None: + raise ValueError('missing meta in EIP712AuthorityCertificate') + if type(meta) != str: + raise ValueError('invalid type {} for meta in EIP712AuthorityCertificate'.format(type(meta))) + + obj = EIP712AuthorityCertificate(chainId=chainId, verifyingContract=verifyingContract, validFrom=validFrom, + issuer=issuer, subject=subject, realm=realm, capabilities=capabilities, meta=meta) + return obj diff --git a/autobahn/xbr/_eip712_base.py b/autobahn/xbr/_eip712_base.py index b23c3af29..0a3c41558 100644 --- a/autobahn/xbr/_eip712_base.py +++ b/autobahn/xbr/_eip712_base.py @@ -85,6 +85,16 @@ def is_bytes16(provided: Any) -> bool: return type(provided) == bytes and len(provided) == 16 +def is_bytes32(provided: Any) -> bool: + """ + Check if the value is of type bytes and length 32. + + :param provided: The value to check. + :return: True iff the value is of correct type. + """ + return type(provided) == bytes and len(provided) == 32 + + def is_signature(provided: Any) -> bool: """ Check if the value is a proper Ethereum signature. diff --git a/autobahn/xbr/_eip712_certificate.py b/autobahn/xbr/_eip712_certificate.py new file mode 100644 index 000000000..b05f78fa6 --- /dev/null +++ b/autobahn/xbr/_eip712_certificate.py @@ -0,0 +1,40 @@ +############################################################################### +# +# The MIT License (MIT) +# +# Copyright (c) Crossbar.io Technologies GmbH +# +# 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. +# +############################################################################### + + +class EIP712Certificate(object): + + def __init__(self, chainId: int, verifyingContract: bytes, validFrom: int): + self.chainId = chainId + self.verifyingContract = verifyingContract + self.validFrom = validFrom + + def recover(self, signature: bytes) -> bytes: + raise NotImplementedError() + + @staticmethod + def parse(data) -> 'EIP712Certificate': + raise NotImplementedError() diff --git a/autobahn/xbr/_eip712_certificate_chain.py b/autobahn/xbr/_eip712_certificate_chain.py new file mode 100644 index 000000000..0d341cc76 --- /dev/null +++ b/autobahn/xbr/_eip712_certificate_chain.py @@ -0,0 +1,119 @@ +############################################################################### +# +# The MIT License (MIT) +# +# Copyright (c) Crossbar.io Technologies GmbH +# +# 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. +# +############################################################################### + +from binascii import a2b_hex +from typing import List, Tuple, Dict, Any, Union + +from autobahn.xbr._eip712_delegate_certificate import EIP712DelegateCertificate +from autobahn.xbr._eip712_authority_certificate import EIP712AuthorityCertificate + + +def parse_certificate_chain(certificates: List[Tuple[Dict[str, Any], str]]) \ + -> List[Union[EIP712DelegateCertificate, EIP712AuthorityCertificate]]: + """ + + :param certificates: + :return: + """ + # parse the whole certificate chain + cert_chain = [] + cert_sigs = [] + for cert_data, cert_sig in certificates: + if cert_data['primaryType'] == 'EIP712DelegateCertificate': + cert = EIP712DelegateCertificate.parse(cert_data['message']) + elif cert_data['primaryType'] == 'EIP712AuthorityCertificate': + cert = EIP712AuthorityCertificate.parse(cert_data['message']) + else: + assert False, 'should not arrive here' + cert_chain.append(cert) + cert_sigs.append(cert_sig) + + # FIXME: allow length 2 and length > 3 + assert len(cert_chain) == 3 + + # Certificate Chain Rules (CCR): + # + # 1. **CCR-1**: The `chainId` and `verifyingContract` must match for all certificates to what we expect, and `validFrom` before current block number on the respective chain. + # 2. **CCR-2**: The `realm` must match for all certificates to the respective realm. + # 3. **CCR-3**: The type of the first certificate in the chain must be a `EIP712DelegateCertificate`, and all subsequent certificates must be of type `EIP712AuthorityCertificate`. + # 4. **CCR-4**: The last certificate must be self-signed (`issuer` equals `subject`), it is a root CA certificate. + # 5. **CCR-5**: The intermediate certificate's `issuer` must be equal to the `subject` of the previous certificate. + # 6. **CCR-6**: The root certificate must be `validFrom` before the intermediate certificate + # 7. **CCR-7**: The `capabilities` of intermediate certificate must be a subset of the root cert + # 8. **CCR-8**: The intermediate certificate's `subject` must be the delegate certificate `delegate` + # 9. **CCR-9**: The intermediate certificate must be `validFrom` before the delegate certificate + # 10. **CCR-10**: The root certificate's signature must be valid and signed by the root certificate's `issuer`. + # 11. **CCR-11**: The intermediate certificate's signature must be valid and signed by the intermediate certificate's `issuer`. + # 12. **CCR-12**: The delegate certificate's signature must be valid and signed by the `delegate`. + + # CCR-1 + chainId = 1 + verifyingContract = a2b_hex('0xf766Dc789CF04CD18aE75af2c5fAf2DA6650Ff57'[2:]) + for cert in cert_chain: + assert cert.chainId == chainId + assert cert.verifyingContract == verifyingContract + + # CCR-2 + realm = a2b_hex('0xA6e693CC4A2b4F1400391a728D26369D9b82ef96'[2:]) + for cert in cert_chain[1:]: + assert cert.realm == realm + + # CCR-3 + assert isinstance(cert_chain[0], EIP712DelegateCertificate) + for i in [1, len(cert_chain) - 1]: + assert isinstance(cert_chain[i], EIP712AuthorityCertificate) + + # CCR-4 + assert cert_chain[2].subject == cert_chain[2].issuer + + # CCR-5 + assert cert_chain[1].issuer == cert_chain[2].subject + + # CCR-6 + assert cert_chain[2].validFrom <= cert_chain[1].validFrom + + # CCR-7 + assert cert_chain[2].capabilities == cert_chain[2].capabilities | cert_chain[1].capabilities + + # CCR-8 + assert cert_chain[1].subject == cert_chain[0].delegate + + # CCR-9 + assert cert_chain[1].validFrom <= cert_chain[0].validFrom + + # CCR-10 + _issuer = cert_chain[2].recover(a2b_hex(cert_sigs[2])) + assert _issuer == cert_chain[2].issuer + + # CCR-11 + _issuer = cert_chain[1].recover(a2b_hex(cert_sigs[1])) + assert _issuer == cert_chain[1].issuer + + # CCR-12 + _issuer = cert_chain[0].recover(a2b_hex(cert_sigs[0])) + assert _issuer == cert_chain[0].delegate + + return cert_chain diff --git a/autobahn/xbr/_eip712_delegate_certificate.py b/autobahn/xbr/_eip712_delegate_certificate.py new file mode 100644 index 000000000..3cb0c6bb2 --- /dev/null +++ b/autobahn/xbr/_eip712_delegate_certificate.py @@ -0,0 +1,298 @@ +############################################################################### +# +# The MIT License (MIT) +# +# Copyright (c) Crossbar.io Technologies GmbH +# +# 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. +# +############################################################################### + +from binascii import a2b_hex +from typing import Dict, Any + +import json + +from autobahn.wamp.message import _URI_PAT_REALM_NAME_ETH +from autobahn.xbr._secmod import EthereumKey + +from ._eip712_base import sign, recover, is_chain_id, is_address, is_cs_pubkey, \ + is_block_number, is_signature, is_eth_privkey +from ._eip712_certificate import EIP712Certificate + + +def create_eip712_delegate_certificate(chainId: int, + verifyingContract: bytes, + validFrom: int, + delegate: bytes, + csPubKey: bytes, + bootedAt: int, + meta: str) -> dict: + """ + Delegate certificate: dynamic/one-time, off-chain. + + :param chainId: + :param verifyingContract: + :param validFrom: + :param delegate: + :param csPubKey: + :param bootedAt: + :param meta: + :return: + """ + assert is_chain_id(chainId) + assert is_address(verifyingContract) + assert is_block_number(validFrom) + assert is_address(delegate) + assert is_cs_pubkey(csPubKey) + assert type(bootedAt) == int + assert meta is None or type(meta) == str + + data = { + 'types': { + 'EIP712Domain': [ + { + 'name': 'name', + 'type': 'string' + }, + { + 'name': 'version', + 'type': 'string' + }, + ], + 'EIP712DelegateCertificate': [ + { + 'name': 'chainId', + 'type': 'uint256' + }, + { + 'name': 'verifyingContract', + 'type': 'address' + }, + { + 'name': 'validFrom', + 'type': 'uint256' + }, + { + 'name': 'delegate', + 'type': 'address' + }, + { + 'name': 'csPubKey', + 'type': 'bytes32' + }, + { + 'name': 'bootedAt', + 'type': 'uint64' + }, + { + 'name': 'meta', + 'type': 'string' + } + ] + }, + 'primaryType': 'EIP712DelegateCertificate', + 'domain': { + 'name': 'WMP', + 'version': '1', + }, + 'message': { + 'chainId': chainId, + 'verifyingContract': verifyingContract, + 'validFrom': validFrom, + 'delegate': delegate, + 'csPubKey': csPubKey, + 'bootedAt': bootedAt, + 'meta': meta or '', + } + } + + return data + + +def sign_eip712_delegate_certificate(eth_privkey: bytes, + chainId: int, + verifyingContract: bytes, + validFrom: int, + delegate: bytes, + csPubKey: bytes, + bootedAt: int, + meta: str) -> bytes: + """ + Sign the given data using a EIP712 based signature with the provided private key. + + :param eth_privkey: Signing key. + :param chainId: + :param verifyingContract: + :param validFrom: + :param delegate: + :param csPubKey: + :param bootedAt: + :param meta: + :return: The signature according to EIP712 (32+32+1 raw bytes). + """ + assert is_eth_privkey(eth_privkey) + + data = create_eip712_delegate_certificate(chainId, verifyingContract, validFrom, delegate, + csPubKey, bootedAt, meta) + return sign(eth_privkey, data) + + +def recover_eip712_delegate_certificate(chainId: int, + verifyingContract: bytes, + validFrom: int, + delegate: bytes, + csPubKey: bytes, + bootedAt: int, + meta: str, + signature: bytes) -> bytes: + """ + Recover the signer address the given EIP712 signature was signed with. + + :param chainId: + :param verifyingContract: + :param validFrom: + :param delegate: + :param csPubKey: + :param bootedAt: + :param signature: + :param meta: + :return: The (computed) signer address the signature was signed with. + """ + assert is_signature(signature) + + data = create_eip712_delegate_certificate(chainId, verifyingContract, validFrom, delegate, + csPubKey, bootedAt, meta) + return recover(data, signature) + + +class EIP712DelegateCertificate(EIP712Certificate): + __slots__ = ( + 'chainId', + 'verifyingContract', + 'validFrom', + 'delegate', + 'csPubKey', + 'bootedAt', + 'meta', + ) + + def __init__(self, chainId: int, verifyingContract: bytes, validFrom: int, delegate: bytes, csPubKey: bytes, + bootedAt: int, meta: str): + super().__init__(chainId, verifyingContract, validFrom) + self.delegate = delegate + self.csPubKey = csPubKey + self.bootedAt = bootedAt + self.meta = meta + + def sign(self, key: EthereumKey) -> bytes: + eip712 = create_eip712_delegate_certificate(self.chainId, + self.verifyingContract, + self.validFrom, + self.delegate, + self.csPubKey, + self.bootedAt, + self.meta) + # FIXME + data = json.dumps(eip712).encode() + return key.sign(data) + + def recover(self, signature: bytes) -> bytes: + return recover_eip712_delegate_certificate(self.chainId, + self.verifyingContract, + self.validFrom, + self.delegate, + self.csPubKey, + self.bootedAt, + self.meta, + signature) + + def marshal(self) -> Dict[str, Any]: + return create_eip712_delegate_certificate(self.chainId, + self.verifyingContract, + self.validFrom, + self.delegate, + self.csPubKey, + self.bootedAt, + self.meta) + + @staticmethod + def parse(data) -> 'EIP712DelegateCertificate': + if type(data) != dict: + raise ValueError('invalid type {} for EIP712DelegateCertificate'.format(type(data))) + for k in data: + if k not in ['chainId', 'verifyingContract', 'delegate', 'validFrom', 'csPubKey', 'bootedAt', 'meta']: + raise ValueError('invalid attribute "{}" in EIP712DelegateCertificate'.format(k)) + + chainId = data.get('chainId', None) + if chainId is None: + raise ValueError('missing chainId in EIP712DelegateCertificate') + if type(chainId) != int: + raise ValueError('invalid type {} for chainId in EIP712DelegateCertificate'.format(type(chainId))) + + verifyingContract = data.get('verifyingContract', None) + if verifyingContract is None: + raise ValueError('missing verifyingContract in EIP712DelegateCertificate') + if type(verifyingContract) != str: + raise ValueError( + 'invalid type {} for verifyingContract in EIP712DelegateCertificate'.format(type(verifyingContract))) + if not _URI_PAT_REALM_NAME_ETH.match(verifyingContract): + raise ValueError( + 'invalid value "{}" for verifyingContract in EIP712DelegateCertificate'.format(verifyingContract)) + verifyingContract = a2b_hex(verifyingContract[2:]) + + validFrom = data.get('validFrom', None) + if validFrom is None: + raise ValueError('missing validFrom in EIP712DelegateCertificate') + if type(validFrom) != int: + raise ValueError('invalid type {} for validFrom in EIP712DelegateCertificate'.format(type(validFrom))) + + delegate = data.get('delegate', None) + if delegate is None: + raise ValueError('missing delegate in EIP712DelegateCertificate') + if type(delegate) != str: + raise ValueError('invalid type {} for delegate in EIP712DelegateCertificate'.format(type(delegate))) + if not _URI_PAT_REALM_NAME_ETH.match(delegate): + raise ValueError('invalid value "{}" for verifyingContract in EIP712DelegateCertificate'.format(delegate)) + delegate = a2b_hex(delegate[2:]) + + csPubKey = data.get('csPubKey', None) + if csPubKey is None: + raise ValueError('missing csPubKey in EIP712DelegateCertificate') + if type(csPubKey) != str: + raise ValueError('invalid type {} for csPubKey in EIP712DelegateCertificate'.format(type(csPubKey))) + if len(csPubKey) != 64: + raise ValueError('invalid value "{}" for csPubKey in EIP712DelegateCertificate'.format(csPubKey)) + csPubKey = a2b_hex(csPubKey) + + bootedAt = data.get('bootedAt', None) + if bootedAt is None: + raise ValueError('missing bootedAt in EIP712DelegateCertificate') + if type(bootedAt) != int: + raise ValueError('invalid type {} for bootedAt in EIP712DelegateCertificate'.format(type(bootedAt))) + + meta = data.get('meta', None) + if meta is None: + raise ValueError('missing meta in EIP712DelegateCertificate') + if type(meta) != str: + raise ValueError('invalid type {} for meta in EIP712DelegateCertificate'.format(type(meta))) + + obj = EIP712DelegateCertificate(chainId=chainId, verifyingContract=verifyingContract, validFrom=validFrom, + delegate=delegate, csPubKey=csPubKey, bootedAt=bootedAt, meta=meta) + return obj diff --git a/autobahn/xbr/_secmod.py b/autobahn/xbr/_secmod.py index 825980bf9..99b686a5d 100644 --- a/autobahn/xbr/_secmod.py +++ b/autobahn/xbr/_secmod.py @@ -42,7 +42,7 @@ from autobahn.wamp.interfaces import ISecurityModule, IEthereumKey from autobahn.xbr._mnemonic import mnemonic_to_private_key -from autobahn.xbr._userkey import _parse_user_key_file +from autobahn.util import parse_keyfile from autobahn.wamp.cryptosign import CryptosignKey __all__ = ('EthereumKey', 'SecurityModuleMemory', ) @@ -107,8 +107,10 @@ def address(self, binary: bool = False) -> Union[str, bytes]: """ Implements :meth:`autobahn.wamp.interfaces.IEthereumKey.address`. """ - # FIXME: implement "binary" - return self._address + if binary: + return binascii.a2b_hex(self._address[2:]) + else: + return self._address def sign(self, data: bytes) -> bytes: """ @@ -124,7 +126,7 @@ def recover(self, data: bytes, signature: bytes) -> bytes: # FIXME: implement signing address recovery from signature of raw data raise NotImplementedError() - def sign_typed_data(self, data: Dict[str, Any]) -> bytes: + def sign_typed_data(self, data: Dict[str, Any], binary=True) -> bytes: """ Implements :meth:`autobahn.wamp.interfaces.IEthereumKey.sign_typed_data`. """ @@ -146,7 +148,10 @@ def sign_typed_data(self, data: Dict[str, Any]) -> bytes: except Exception as e: return txaio.create_future_error(e) else: - return txaio.create_future_success(signature) + if binary: + return txaio.create_future_success(signature) + else: + return txaio.create_future_success(binascii.b2a_hex(signature).decode()) def verify_typed_data(self, data: Dict[str, Any], signature: bytes) -> bool: """ @@ -212,6 +217,53 @@ def from_seedphrase(cls, seedphrase: str, index: int = 0) -> 'EthereumKey': account: LocalAccount = Account.from_key(key) return EthereumKey(key_or_address=account, can_sign=True) + @classmethod + def from_keyfile(cls, keyfile: str) -> 'EthereumKey': + """ + Create a public or private key from reading the given public or private key file. + + Here is an example key file that includes an Ethereum private key ``private-key-eth``, which + is loaded in this function, and other fields, which are ignored by this function: + + .. code-block:: + + This is a comment (all lines until the first empty line are comments indeed). + + creator: oberstet@intel-nuci7 + created-at: 2022-07-05T12:29:48.832Z + user-id: oberstet@intel-nuci7 + public-key-ed25519: 7326d9dc0307681cc6940fde0e60eb31a6e4d642a81e55c434462ce31f95deed + public-adr-eth: 0x10848feBdf7f200Ba989CDf7E3eEB3EC03ae7768 + private-key-ed25519: f750f42b0430e28a2e272c3cedcae4dcc4a1cf33bc345c35099d3322626ab666 + private-key-eth: 4d787714dcb0ae52e1c5d2144648c255d660b9a55eac9deeb80d9f506f501025 + + :param keyfile: Path (relative or absolute) to a public or private keys file. + :return: New instance of :class:`EthereumKey` + """ + if not os.path.exists(keyfile) or not os.path.isfile(keyfile): + raise RuntimeError('keyfile "{}" is not a file'.format(keyfile)) + + # now load the private or public key file - this returns a dict which should + # include (for a private key): + # + # private-key-eth: 6b08b6e186bd2a3b9b2f36e6ece3f8031fe788ab3dc4a1cfd3a489ea387c496b + # + # or (for a public key only): + # + # public-adr-eth: 0x10848feBdf7f200Ba989CDf7E3eEB3EC03ae7768 + # + data = parse_keyfile(keyfile) + + privkey_eth_hex = data.get('private-key-eth', None) + if privkey_eth_hex is None: + pub_adr_eth = data.get('public-adr-eth', None) + if pub_adr_eth is None: + raise RuntimeError('neither "private-key-eth" nor "public-adr-eth" found in keyfile {}'.format(keyfile)) + else: + return EthereumKey.from_address(pub_adr_eth) + else: + return EthereumKey.from_bytes(binascii.a2b_hex(privkey_eth_hex)) + IEthereumKey.register(EthereumKey) @@ -462,7 +514,7 @@ def from_config(cls, config: str, profile: str = 'default') -> 'SecurityModuleMe # now load the private key file - this returns a dict which should include: # private-key-eth: 6b08b6e186bd2a3b9b2f36e6ece3f8031fe788ab3dc4a1cfd3a489ea387c496b # private-key-ed25519: 20e8c05d0ede9506462bb049c4843032b18e8e75b314583d0c8d8a4942f9be40 - data = _parse_user_key_file(privkey) + data = parse_keyfile(privkey) # first, add Ethereum key privkey_eth_hex = data.get('private-key-eth', None) @@ -493,7 +545,7 @@ def from_keyfile(cls, keyfile: str) -> 'SecurityModuleMemory': # now load the private key file - this returns a dict which should include: # private-key-eth: 6b08b6e186bd2a3b9b2f36e6ece3f8031fe788ab3dc4a1cfd3a489ea387c496b # private-key-ed25519: 20e8c05d0ede9506462bb049c4843032b18e8e75b314583d0c8d8a4942f9be40 - data = _parse_user_key_file(keyfile) + data = parse_keyfile(keyfile) # first, add Ethereum key privkey_eth_hex = data.get('private-key-eth', None) diff --git a/autobahn/xbr/_userkey.py b/autobahn/xbr/_userkey.py index ee6b7c391..4326ded8e 100644 --- a/autobahn/xbr/_userkey.py +++ b/autobahn/xbr/_userkey.py @@ -39,7 +39,7 @@ from eth_keys import KeyAPI from eth_keys.backends import NativeECCBackend -from autobahn.util import utcnow +from autobahn.util import utcnow, write_keyfile, parse_keyfile from autobahn.wamp import cryptosign if 'USER' in os.environ: @@ -94,63 +94,6 @@ def _creator(yes_to_all=False): return '{}@{}'.format(user, hostname) -def _write_user_key(filepath, tags, msg): - """ - Internal helper, write the given tags to the given file- - """ - with open(filepath, 'w') as f: - f.write(msg) - for (tag, value) in tags.items(): - if value: - f.write('{}: {}\n'.format(tag, value)) - - -def _parse_user_key_file(key_path: str, private: bool = True) -> OrderedDict: - """ - Internal helper. This parses a node.pub or node.priv file and - returns a dict mapping tags -> values. - """ - if os.path.exists(key_path) and not os.path.isfile(key_path): - raise Exception("Key file '{}' exists, but isn't a file".format(key_path)) - - allowed_tags = [ - # common tags - 'public-key-ed25519', - 'public-adr-eth', - 'created-at', - 'creator', - - # user profile - 'user-id', - - # node profile - 'machine-id', - 'node-authid', - 'node-cluster-ip', - ] - - if private: - # private key file tags - allowed_tags.extend(['private-key-ed25519', 'private-key-eth']) - - tags = OrderedDict() # type: ignore - with open(key_path, 'r') as key_file: - got_blankline = False - for line in key_file.readlines(): - if line.strip() == '': - got_blankline = True - elif got_blankline: - tag, value = line.split(':', 1) - tag = tag.strip().lower() - value = value.strip() - if tag not in allowed_tags: - raise Exception("Invalid tag '{}' in key file {}".format(tag, key_path)) - if tag in tags: - raise Exception("Duplicate tag '{}' in key file {}".format(tag, key_path)) - tags[tag] = value - return tags - - class UserKey(object): def __init__(self, privkey, pubkey, yes_to_all=True): @@ -177,7 +120,7 @@ def _load_and_maybe_generate(self, privkey_path, pubkey_path, yes_to_all=False): # node private key seems to exist already .. check! - priv_tags = _parse_user_key_file(privkey_path, private=True) + priv_tags = parse_keyfile(privkey_path, private=True) for tag in ['creator', 'created-at', 'user-id', 'public-key-ed25519', 'private-key-ed25519']: if tag not in priv_tags: raise Exception("Corrupt user private key file {} - {} tag not found".format(privkey_path, tag)) @@ -208,7 +151,7 @@ def _load_and_maybe_generate(self, privkey_path, pubkey_path, yes_to_all=False): " correspond to private-key-eth").format(privkey_path)) if os.path.exists(pubkey_path): - pub_tags = _parse_user_key_file(pubkey_path, private=False) + pub_tags = parse_keyfile(pubkey_path, private=False) for tag in ['creator', 'created-at', 'user-id', 'public-key-ed25519']: if tag not in pub_tags: raise Exception("Corrupt user public key file {} - {} tag not found".format(pubkey_path, tag)) @@ -232,7 +175,7 @@ def _load_and_maybe_generate(self, privkey_path, pubkey_path, yes_to_all=False): ('public-adr-eth', eth_pubadr), ]) msg = 'Crossbar.io user public key\n\n' - _write_user_key(pubkey_path, pub_tags, msg) + write_keyfile(pubkey_path, pub_tags, msg) click.echo('Re-created user public key from private key: {}'.format(pubkey_path)) @@ -261,14 +204,14 @@ def _load_and_maybe_generate(self, privkey_path, pubkey_path, yes_to_all=False): ('public-adr-eth', eth_pubadr), ]) msg = 'Crossbar.io user public key\n\n' - _write_user_key(pubkey_path, tags, msg) + write_keyfile(pubkey_path, tags, msg) os.chmod(pubkey_path, 420) # now, add the private key and write the private file tags['private-key-ed25519'] = privkey_hex tags['private-key-eth'] = eth_privkey_seed_hex msg = 'Crossbar.io user private key - KEEP THIS SAFE!\n\n' - _write_user_key(privkey_path, tags, msg) + write_keyfile(privkey_path, tags, msg) os.chmod(privkey_path, 384) click.echo('New user public key generated: {}'.format(pubkey_path)) diff --git a/autobahn/xbr/test/test_xbr_eip712.py b/autobahn/xbr/test/test_xbr_eip712.py new file mode 100644 index 000000000..ef9c7a2a0 --- /dev/null +++ b/autobahn/xbr/test/test_xbr_eip712.py @@ -0,0 +1,448 @@ +############################################################################### +# +# The MIT License (MIT) +# +# Copyright (c) Crossbar.io Technologies GmbH +# +# 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. +# +############################################################################### + +import os +import sys +from binascii import a2b_hex, b2a_hex +from unittest import skipIf + +from twisted.internet.defer import inlineCallbacks +from twisted.trial.unittest import TestCase + +from autobahn.wamp.cryptosign import HAS_CRYPTOSIGN +from autobahn.xbr import HAS_XBR + +if HAS_XBR and HAS_CRYPTOSIGN: + from autobahn.wamp.cryptosign import CryptosignKey + from autobahn.xbr import make_w3, EthereumKey + from autobahn.xbr._secmod import SecurityModuleMemory + from autobahn.xbr import create_eip712_delegate_certificate, create_eip712_authority_certificate + from autobahn.xbr._eip712_delegate_certificate import EIP712DelegateCertificate + from autobahn.xbr._eip712_authority_certificate import EIP712AuthorityCertificate + from autobahn.xbr._eip712_certificate_chain import parse_certificate_chain + +# https://web3py.readthedocs.io/en/stable/providers.html#infura-mainnet +HAS_INFURA = 'WEB3_INFURA_PROJECT_ID' in os.environ and len(os.environ['WEB3_INFURA_PROJECT_ID']) > 0 + +# TypeError: As of 3.10, the *loop* parameter was removed from Lock() since it is no longer necessary +IS_CPY_310 = sys.version_info.minor == 10 + + +@skipIf(not os.environ.get('USE_TWISTED', False), 'only for Twisted') +@skipIf(not HAS_INFURA, 'env var WEB3_INFURA_PROJECT_ID not defined') +@skipIf(not (HAS_XBR and HAS_CRYPTOSIGN), 'package autobahn[encryption,xbr] not installed') +class TestEip712Certificate(TestCase): + + def setUp(self): + self._gw_config = { + 'type': 'infura', + 'key': os.environ.get('WEB3_INFURA_PROJECT_ID', ''), + 'network': 'mainnet', + } + self._w3 = make_w3(self._gw_config) + + self._seedphrase = "avocado style uncover thrive same grace crunch want essay reduce current edge" + self._sm: SecurityModuleMemory = SecurityModuleMemory.from_seedphrase(self._seedphrase, num_eth_keys=5, + num_cs_keys=5) + + @inlineCallbacks + def test_eip712_delegate_certificate(self): + yield self._sm.open() + + delegate_eth_key: EthereumKey = self._sm[1] + delegate_cs_key: CryptosignKey = self._sm[6] + + chainId = 1 + verifyingContract = a2b_hex('0xf766Dc789CF04CD18aE75af2c5fAf2DA6650Ff57'[2:]) + validFrom = 15124128 + delegate = delegate_eth_key.address(binary=True) + csPubKey = delegate_cs_key.public_key(binary=True) + bootedAt = 1657579546469365046 # txaio.time_ns() + meta = 'Qme7ss3ARVgxv6rXqVPiikMJ8u2NLgmgszg13pYrDKEoiu' + + cert_data = create_eip712_delegate_certificate(chainId=chainId, verifyingContract=verifyingContract, + validFrom=validFrom, delegate=delegate, csPubKey=csPubKey, + bootedAt=bootedAt, meta=meta) + + # print('\n\n{}\n\n'.format(pformat(cert_data))) + + cert_sig = yield delegate_eth_key.sign_typed_data(cert_data, binary=False) + + self.assertEqual(cert_sig, + '2bd697b2bdb9bc2c2494e53e9440ddb3e8a596eedaad717f8ecdb732d091a7de48d72d9a26d7e092ec55c074979ab039f8e003acf80224819ff396c9529eb1d11b') + + yield self._sm.close() + + @inlineCallbacks + def test_eip712_authority_certificate(self): + yield self._sm.open() + + trustroot_eth_key: EthereumKey = self._sm[0] + delegate_eth_key: EthereumKey = self._sm[1] + + chainId = 1 + verifyingContract = a2b_hex('0xf766Dc789CF04CD18aE75af2c5fAf2DA6650Ff57'[2:]) + validFrom = 15124128 + issuer = trustroot_eth_key.address(binary=True) + subject = delegate_eth_key.address(binary=True) + realm = a2b_hex('0xA6e693CC4A2b4F1400391a728D26369D9b82ef96'[2:]) + capabilities = 3 + meta = 'Qme7ss3ARVgxv6rXqVPiikMJ8u2NLgmgszg13pYrDKEoiu' + + cert_data = create_eip712_authority_certificate(chainId=chainId, verifyingContract=verifyingContract, + validFrom=validFrom, issuer=issuer, subject=subject, + realm=realm, capabilities=capabilities, meta=meta) + + # print('\n\n{}\n\n'.format(pformat(cert_data))) + + cert_sig = yield trustroot_eth_key.sign_typed_data(cert_data, binary=False) + + self.assertEqual(cert_sig, + '83590d4304cc5f6024d6a85ed2c511a60e804d609e4f498c8af777d5102c6d22657673e7b68876795e3c72f857b68e13cf616ee4c2ea559bceb344021bf977b61c') + + yield self._sm.close() + + +@skipIf(not os.environ.get('USE_TWISTED', False), 'only for Twisted') +@skipIf(not HAS_INFURA, 'env var WEB3_INFURA_PROJECT_ID not defined') +@skipIf(not (HAS_XBR and HAS_CRYPTOSIGN), 'package autobahn[encryption,xbr] not installed') +class TestEip712CertificateChain(TestCase): + + def setUp(self): + self._gw_config = { + 'type': 'infura', + 'key': os.environ.get('WEB3_INFURA_PROJECT_ID', ''), + 'network': 'mainnet', + } + self._w3 = make_w3(self._gw_config) + + self._seedphrase = "avocado style uncover thrive same grace crunch want essay reduce current edge" + self._sm: SecurityModuleMemory = SecurityModuleMemory.from_seedphrase(self._seedphrase, num_eth_keys=5, + num_cs_keys=5) + + # HELLO.Details.authextra.certificates + # + self._certs_expected1 = [({'domain': {'name': 'WMP', 'version': '1'}, + 'message': {'bootedAt': 1657781999086394759, + 'chainId': 1, + 'csPubKey': '12ae0184b180e9a9c5e45be4a1afbce3c6491320063701cd9c4011a777d04089', + 'delegate': '0xf5173a6111B2A6B3C20fceD53B2A8405EC142bF6', + 'meta': 'Qme7ss3ARVgxv6rXqVPiikMJ8u2NLgmgszg13pYrDKEoiu', + 'validFrom': 15139218, + 'verifyingContract': '0xf766Dc789CF04CD18aE75af2c5fAf2DA6650Ff57'}, + 'primaryType': 'EIP712DelegateCertificate', + 'types': {'EIP712DelegateCertificate': [{'name': 'chainId', + 'type': 'uint256'}, + {'name': 'verifyingContract', + 'type': 'address'}, + {'name': 'validFrom', + 'type': 'uint256'}, + {'name': 'delegate', + 'type': 'address'}, + {'name': 'csPubKey', + 'type': 'bytes32'}, + {'name': 'bootedAt', + 'type': 'uint64'}, + {'name': 'meta', 'type': 'string'}], + 'EIP712Domain': [{'name': 'name', 'type': 'string'}, + {'name': 'version', 'type': 'string'}]}}, + '70726dda677cac8f21366f8023d17203b2f4f9099e954f9bebb2134086e2ac291d80ce038a1342a7748d4b0750f06b8de491561d581c90c99f1c09c91cfa7e191c'), + ({'domain': {'name': 'WMP', 'version': '1'}, + 'message': {'capabilities': 12, + 'chainId': 1, + 'issuer': '0xf766Dc789CF04CD18aE75af2c5fAf2DA6650Ff57', + 'meta': 'QmNbMM6TMLAgqBKzY69mJKk5VKvpcTtAtwAaLC2FV4zC3G', + 'realm': '0xA6e693CC4A2b4F1400391a728D26369D9b82ef96', + 'subject': '0xf5173a6111B2A6B3C20fceD53B2A8405EC142bF6', + 'validFrom': 15139218, + 'verifyingContract': '0xf766Dc789CF04CD18aE75af2c5fAf2DA6650Ff57'}, + 'primaryType': 'EIP712AuthorityCertificate', + 'types': {'EIP712AuthorityCertificate': [{'name': 'chainId', + 'type': 'uint256'}, + {'name': 'verifyingContract', + 'type': 'address'}, + {'name': 'validFrom', + 'type': 'uint256'}, + {'name': 'issuer', + 'type': 'address'}, + {'name': 'subject', + 'type': 'address'}, + {'name': 'realm', + 'type': 'address'}, + {'name': 'capabilities', + 'type': 'uint64'}, + {'name': 'meta', 'type': 'string'}], + 'EIP712Domain': [{'name': 'name', 'type': 'string'}, + {'name': 'version', 'type': 'string'}]}}, + 'f031b2625ae7e32e7eec3a8fa09f4db3a43217f282b7695e5b09dd2e13c25dc679c1f3ce27b94a3074786f7f12183a2a275a00aea5a66b83c431281f1069bd841c'), + ({'domain': {'name': 'WMP', 'version': '1'}, + 'message': {'capabilities': 63, + 'chainId': 1, + 'issuer': '0xf766Dc789CF04CD18aE75af2c5fAf2DA6650Ff57', + 'meta': 'QmNbMM6TMLAgqBKzY69mJKk5VKvpcTtAtwAaLC2FV4zC3G', + 'realm': '0xA6e693CC4A2b4F1400391a728D26369D9b82ef96', + 'subject': '0xf766Dc789CF04CD18aE75af2c5fAf2DA6650Ff57', + 'validFrom': 15139218, + 'verifyingContract': '0xf766Dc789CF04CD18aE75af2c5fAf2DA6650Ff57'}, + 'primaryType': 'EIP712AuthorityCertificate', + 'types': {'EIP712AuthorityCertificate': [{'name': 'chainId', + 'type': 'uint256'}, + {'name': 'verifyingContract', + 'type': 'address'}, + {'name': 'validFrom', + 'type': 'uint256'}, + {'name': 'issuer', + 'type': 'address'}, + {'name': 'subject', + 'type': 'address'}, + {'name': 'realm', + 'type': 'address'}, + {'name': 'capabilities', + 'type': 'uint64'}, + {'name': 'meta', 'type': 'string'}], + 'EIP712Domain': [{'name': 'name', 'type': 'string'}, + {'name': 'version', 'type': 'string'}]}}, + 'c3bcd7a3c3c45ae45a24cd7745db3b39c4113e6b71a4220f943f0969282246b4083ef61277bd7ba9e92c9a07b79869ce63bc6206986480f9c5daddb27b91bebe1b')] + + @inlineCallbacks + def test_eip712_create_certificate_chain_manual(self): + yield self._sm.open() + + # keys needed to create all certificates in certificate chain + # + trustroot_eth_key: EthereumKey = self._sm[0] + delegate_eth_key: EthereumKey = self._sm[1] + delegate_cs_key: CryptosignKey = self._sm[6] + + # data needed for delegate certificate: cert1 + # + chainId = 1 # self._w3.eth.chain_id + verifyingContract = a2b_hex('0xf766Dc789CF04CD18aE75af2c5fAf2DA6650Ff57'[2:]) + validFrom = 15139218 # self._w3.eth.block_number + delegate = delegate_eth_key.address(binary=True) + csPubKey = delegate_cs_key.public_key(binary=True) + bootedAt = 1657781999086394759 # txaio.time_ns() + delegateMeta = 'Qme7ss3ARVgxv6rXqVPiikMJ8u2NLgmgszg13pYrDKEoiu' + + # data needed for intermediate authority certificate: cert2 + # + issuer_cert2 = trustroot_eth_key.address(binary=True) + subject_cert2 = delegate + realm_cert2 = a2b_hex('0xA6e693CC4A2b4F1400391a728D26369D9b82ef96'[2:]) + capabilities_cert2 = EIP712AuthorityCertificate.CAPABILITY_PUBLIC_RELAY | EIP712AuthorityCertificate.CAPABILITY_PRIVATE_RELAY + meta_cert2 = 'QmNbMM6TMLAgqBKzY69mJKk5VKvpcTtAtwAaLC2FV4zC3G' + + # data needed for root authority certificate: cert3 + # + issuer_cert3 = trustroot_eth_key.address(binary=True) + subject_cert3 = issuer_cert3 + realm_cert3 = a2b_hex('0xA6e693CC4A2b4F1400391a728D26369D9b82ef96'[2:]) + capabilities_cert3 = EIP712AuthorityCertificate.CAPABILITY_ROOT_CA | EIP712AuthorityCertificate.CAPABILITY_INTERMEDIATE_CA | EIP712AuthorityCertificate.CAPABILITY_PUBLIC_RELAY | EIP712AuthorityCertificate.CAPABILITY_PRIVATE_RELAY | EIP712AuthorityCertificate.CAPABILITY_PROVIDER | EIP712AuthorityCertificate.CAPABILITY_CONSUMER + meta_cert3 = 'QmNbMM6TMLAgqBKzY69mJKk5VKvpcTtAtwAaLC2FV4zC3G' + + # create delegate certificate + # + cert1_data = create_eip712_delegate_certificate(chainId=chainId, verifyingContract=verifyingContract, + validFrom=validFrom, delegate=delegate, csPubKey=csPubKey, + bootedAt=bootedAt, meta=delegateMeta) + + cert1_sig = yield delegate_eth_key.sign_typed_data(cert1_data, binary=False) + + cert1_data['message']['csPubKey'] = b2a_hex(cert1_data['message']['csPubKey']).decode() + cert1_data['message']['delegate'] = self._w3.toChecksumAddress(cert1_data['message']['delegate']) + cert1_data['message']['verifyingContract'] = self._w3.toChecksumAddress( + cert1_data['message']['verifyingContract']) + + # create intermediate authority certificate + # + cert2_data = create_eip712_authority_certificate(chainId=chainId, verifyingContract=verifyingContract, + validFrom=validFrom, issuer=issuer_cert2, + subject=subject_cert2, + realm=realm_cert2, capabilities=capabilities_cert2, + meta=meta_cert2) + + cert2_sig = yield trustroot_eth_key.sign_typed_data(cert2_data, binary=False) + + cert2_data['message']['verifyingContract'] = self._w3.toChecksumAddress( + cert2_data['message']['verifyingContract']) + cert2_data['message']['issuer'] = self._w3.toChecksumAddress(cert2_data['message']['issuer']) + cert2_data['message']['subject'] = self._w3.toChecksumAddress(cert2_data['message']['subject']) + cert2_data['message']['realm'] = self._w3.toChecksumAddress(cert2_data['message']['realm']) + + # create root authority certificate + # + cert3_data = create_eip712_authority_certificate(chainId=chainId, verifyingContract=verifyingContract, + validFrom=validFrom, issuer=issuer_cert3, + subject=subject_cert3, + realm=realm_cert3, capabilities=capabilities_cert3, + meta=meta_cert3) + + cert3_sig = yield trustroot_eth_key.sign_typed_data(cert3_data, binary=False) + + cert3_data['message']['verifyingContract'] = self._w3.toChecksumAddress( + cert3_data['message']['verifyingContract']) + cert3_data['message']['issuer'] = self._w3.toChecksumAddress(cert3_data['message']['issuer']) + cert3_data['message']['subject'] = self._w3.toChecksumAddress(cert3_data['message']['subject']) + cert3_data['message']['realm'] = self._w3.toChecksumAddress(cert3_data['message']['realm']) + + # create certificates chain + # + certificates = [(cert1_data, cert1_sig), (cert2_data, cert2_sig), (cert3_data, cert3_sig)] + + if False: + from pprint import pprint + print() + pprint(certificates) + print() + + # check certificates and certificate signatures of whole chain + # + self.assertEqual(certificates, self._certs_expected1) + + yield self._sm.close() + + @inlineCallbacks + def test_eip712_create_certificate_chain_highlevel(self): + yield self._sm.open() + # FIXME + yield self._sm.close() + + @inlineCallbacks + def test_eip712_verify_certificate_chain_manual(self): + yield self._sm.open() + + # keys originally used to sign the certificates in the certificate chain + trustroot_eth_key: EthereumKey = self._sm[0] + delegate_eth_key: EthereumKey = self._sm[1] + delegate_cs_key: CryptosignKey = self._sm[6] + + # parse the whole certificate chain + cert_chain = [] + cert_sigs = [] + for cert_data, cert_sig in self._certs_expected1: + self.assertIn('domain', cert_data) + self.assertIn('message', cert_data) + self.assertIn('primaryType', cert_data) + self.assertIn('types', cert_data) + self.assertIn(cert_data['primaryType'], cert_data['types']) + self.assertIn(cert_data['primaryType'], ['EIP712DelegateCertificate', 'EIP712AuthorityCertificate']) + if cert_data['primaryType'] == 'EIP712DelegateCertificate': + cert = EIP712DelegateCertificate.parse(cert_data['message']) + elif cert_data['primaryType'] == 'EIP712AuthorityCertificate': + cert = EIP712AuthorityCertificate.parse(cert_data['message']) + else: + assert False, 'should not arrive here' + cert_chain.append(cert) + cert_sigs.append(cert_sig) + + # FIXME: allow length 2 and length > 3 + self.assertEqual(len(cert_chain), 3) + self.assertEqual(cert_chain[0].delegate, delegate_eth_key.address(binary=True)) + self.assertEqual(cert_chain[0].csPubKey, delegate_cs_key.public_key(binary=True)) + self.assertEqual(cert_chain[1].issuer, trustroot_eth_key.address(binary=True)) + self.assertEqual(cert_chain[2].issuer, trustroot_eth_key.address(binary=True)) + + # Certificate Chain Rules (CCR): + # + # 1. **CCR-1**: The `chainId` and `verifyingContract` must match for all certificates to what we expect, and `validFrom` before current block number on the respective chain. + # 2. **CCR-2**: The `realm` must match for all certificates to the respective realm. + # 3. **CCR-3**: The type of the first certificate in the chain must be a `EIP712DelegateCertificate`, and all subsequent certificates must be of type `EIP712AuthorityCertificate`. + # 4. **CCR-4**: The last certificate must be self-signed (`issuer` equals `subject`), it is a root CA certificate. + # 5. **CCR-5**: The intermediate certificate's `issuer` must be equal to the `subject` of the previous certificate. + # 6. **CCR-6**: The root certificate must be `validFrom` before the intermediate certificate + # 7. **CCR-7**: The `capabilities` of intermediate certificate must be a subset of the root cert + # 8. **CCR-8**: The intermediate certificate's `subject` must be the delegate certificate `delegate` + # 9. **CCR-9**: The intermediate certificate must be `validFrom` before the delegate certificate + # 10. **CCR-10**: The root certificate's signature must be valid and signed by the root certificate's `issuer`. + # 11. **CCR-11**: The intermediate certificate's signature must be valid and signed by the intermediate certificate's `issuer`. + # 12. **CCR-12**: The delegate certificate's signature must be valid and signed by the `delegate`. + + # CCR-3 + self.assertIsInstance(cert_chain[0], EIP712DelegateCertificate) + for i in [1, len(cert_chain) - 1]: + self.assertIsInstance(cert_chain[i], EIP712AuthorityCertificate) + + # CCR-1 + chainId = cert_chain[2].chainId + verifyingContract = cert_chain[2].verifyingContract + for cert in cert_chain: + self.assertEqual(cert.chainId, chainId) + self.assertEqual(cert.verifyingContract, verifyingContract) + + # CCR-2 + realm = cert_chain[2].realm + for cert in cert_chain[1:]: + self.assertEqual(cert.realm, realm) + + # CCR-4 + self.assertEqual(cert_chain[2].subject, cert_chain[2].issuer) + + # CCR-5 + self.assertEqual(cert_chain[1].issuer, cert_chain[2].subject) + + # CCR-6 + self.assertLessEqual(cert_chain[2].validFrom, cert_chain[1].validFrom) + + # CCR-7 + self.assertTrue(cert_chain[2].capabilities == cert_chain[2].capabilities | cert_chain[1].capabilities) + + # CCR-8 + self.assertEqual(cert_chain[1].subject, cert_chain[0].delegate) + + # CCR-9 + self.assertLessEqual(cert_chain[1].validFrom, cert_chain[0].validFrom) + + # CCR-10 + _issuer = cert_chain[2].recover(a2b_hex(cert_sigs[2])) + self.assertEqual(_issuer, trustroot_eth_key.address(binary=True)) + + # CCR-11 + _issuer = cert_chain[1].recover(a2b_hex(cert_sigs[1])) + self.assertEqual(_issuer, trustroot_eth_key.address(binary=True)) + + # CCR-12 + _issuer = cert_chain[0].recover(a2b_hex(cert_sigs[0])) + self.assertEqual(_issuer, delegate_eth_key.address(binary=True)) + + yield self._sm.close() + + @inlineCallbacks + def test_eip712_verify_certificate_chain_highlevel(self): + yield self._sm.open() + + # keys originally used to sign the certificates in the certificate chain + trustroot_eth_key: EthereumKey = self._sm[0] + delegate_eth_key: EthereumKey = self._sm[1] + delegate_cs_key: CryptosignKey = self._sm[6] + + certificates = parse_certificate_chain(self._certs_expected1) + + self.assertEqual(certificates[2].issuer, trustroot_eth_key.address(binary=True)) + + self.assertEqual(certificates[0].delegate, delegate_eth_key.address(binary=True)) + self.assertEqual(certificates[0].csPubKey, delegate_cs_key.public_key(binary=True)) + + yield self._sm.close() diff --git a/docs/changelog.rst b/docs/changelog.rst index a870c67ff..569815d96 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -5,6 +5,16 @@ Changelog ========= +22.7.1 +------ + +* new: EIP712 certificate chains, incl. use for WAMP-Cryptosign +* fix: improve message logging at trace log level +* fix: forward correct TLS channel ID once the TLS handshake is complete +* new: add eip712 types for WAMP-Cryptosign certificates +* new: add more helpers to EthereumKey and CryptosignKey +* new: add EthereumKey.from_keyfile, CryptosignKey.from_keyfile, CryptosignKey.from_pubkey + 22.6.1 ------