Skip to content

Commit

Permalink
add more helpers to EthereumKey and CryptosignKey (#1583)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
oberstet committed Jul 19, 2022
1 parent bf10d28 commit 9f425ff
Show file tree
Hide file tree
Showing 20 changed files with 1,517 additions and 260 deletions.
2 changes: 1 addition & 1 deletion autobahn/_version.py
Expand Up @@ -24,6 +24,6 @@
#
###############################################################################

__version__ = '22.6.1'
__version__ = '22.7.1.dev1'

__build__ = '00000000-0000000'
12 changes: 11 additions & 1 deletion autobahn/asyncio/rawsocket.py
Expand Up @@ -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',
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 9 additions & 1 deletion autobahn/asyncio/websocket.py
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
12 changes: 11 additions & 1 deletion autobahn/twisted/rawsocket.py
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 13 additions & 8 deletions autobahn/twisted/util.py
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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
10 changes: 9 additions & 1 deletion autobahn/twisted/websocket.py
Expand Up @@ -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, \
Expand Down Expand Up @@ -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):
Expand Down
60 changes: 60 additions & 0 deletions autobahn/util.py
Expand Up @@ -35,6 +35,7 @@
import binascii
import socket
import subprocess
from collections import OrderedDict

from typing import Optional
from datetime import datetime, timedelta
Expand Down Expand Up @@ -69,6 +70,8 @@
"generate_serial_number",
"generate_user_password",
"machine_id",
'parse_keyfile',
'write_keyfile',
"hl",
"hltype",
"hlid",
Expand Down Expand Up @@ -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
83 changes: 68 additions & 15 deletions autobahn/wamp/cryptosign.py
Expand Up @@ -24,6 +24,7 @@
#
###############################################################################

import os
import binascii
from binascii import a2b_hex, b2a_hex
import struct
Expand All @@ -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',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand All @@ -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:
"""
Expand All @@ -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.
Expand All @@ -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':
"""
Expand All @@ -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':
"""
Expand All @@ -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':
"""
Expand Down Expand Up @@ -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):
Expand Down

0 comments on commit 9f425ff

Please sign in to comment.