1###############################################################################
2#
3# The MIT License (MIT)
4#
5# Copyright (c) Crossbar.io Technologies GmbH
6#
7# Permission is hereby granted, free of charge, to any person obtaining a copy
8# of this software and associated documentation files (the "Software"), to deal
9# in the Software without restriction, including without limitation the rights
10# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11# copies of the Software, and to permit persons to whom the Software is
12# furnished to do so, subject to the following conditions:
13#
14# The above copyright notice and this permission notice shall be included in
15# all copies or substantial portions of the Software.
16#
17# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23# THE SOFTWARE.
24#
25###############################################################################
26
27from __future__ import absolute_import
28
29import six
30
31from autobahn.util import public
32from autobahn.wamp.interfaces import IPayloadCodec
33from autobahn.wamp.types import EncodedPayload
34from autobahn.wamp.serializer import _dumps as _json_dumps
35from autobahn.wamp.serializer import _loads as _json_loads
36
37__all__ = [
38    'HAS_CRYPTOBOX',
39    'EncodedPayload'
40]
41
42try:
43    # try to import everything we need for WAMP-cryptobox
44    from nacl.encoding import Base64Encoder, RawEncoder, HexEncoder
45    from nacl.public import PrivateKey, PublicKey, Box
46    from nacl.utils import random
47    from pytrie import StringTrie
48except ImportError:
49    HAS_CRYPTOBOX = False
50else:
51    HAS_CRYPTOBOX = True
52    __all__.extend(['Key', 'KeyRing'])
53
54
55if HAS_CRYPTOBOX:
56
57    @public
58    class Key(object):
59        """
60        Holds originator and responder keys for an URI.
61
62        The originator is either a caller or a publisher. The responder is either a callee or subscriber.
63        """
64
65        def __init__(self, originator_priv=None, originator_pub=None, responder_priv=None, responder_pub=None):
66            # the originator private and public keys, as available
67            if originator_priv:
68                self.originator_priv = PrivateKey(originator_priv, encoder=Base64Encoder)
69            else:
70                self.originator_priv = None
71
72            if self.originator_priv:
73                self.originator_pub = self.originator_priv.public_key
74                assert(originator_pub is None or originator_pub == self.originator_pub)
75            else:
76                self.originator_pub = PublicKey(originator_pub, encoder=Base64Encoder)
77
78            # the responder private and public keys, as available
79            if responder_priv:
80                self.responder_priv = PrivateKey(responder_priv, encoder=Base64Encoder)
81            else:
82                self.responder_priv = None
83
84            if self.responder_priv:
85                self.responder_pub = self.responder_priv.public_key
86                assert(responder_pub is None or responder_pub == self.responder_pub)
87            else:
88                self.responder_pub = PublicKey(responder_pub, encoder=Base64Encoder)
89
90            # this crypto box is for originators (callers, publishers):
91            #
92            #  1. _encrypting_ WAMP messages outgoing from originators: CALL*, PUBLISH*
93            #  2. _decrypting_ WAMP messages incoming to originators: RESULT*, ERROR
94            #
95            if self.originator_priv and self.responder_pub:
96                self.originator_box = Box(self.originator_priv, self.responder_pub)
97            else:
98                self.originator_box = None
99
100            # this crypto box is for responders (callees, subscribers):
101            #
102            #  1. _decrypting_ WAMP messages incoming to responders: INVOCATION*, EVENT*
103            #  2. _encrypting_ WAMP messages outgoing from responders: YIELD*, ERROR
104            #
105            if self.responder_priv and self.originator_pub:
106                self.responder_box = Box(self.responder_priv, self.originator_pub)
107            else:
108                self.responder_box = None
109
110            if not (self.originator_box or self.responder_box):
111                raise Exception("insufficient keys provided for at least originator or responder role")
112
113    @public
114    class SymKey(object):
115        """
116        Holds a symmetric key for an URI.
117        """
118        def __init__(self, raw=None):
119            pass
120
121    @public
122    class KeyRing(object):
123        """
124        A keyring holds (cryptobox) public-private key pairs for use with WAMP-cryptobox payload
125        encryption. The keyring can be set on a WAMP session and then transparently will get used
126        for encrypting and decrypting WAMP message payloads.
127        """
128
129        @public
130        def __init__(self, default_key=None):
131            """
132
133            Create a new key ring to hold public and private keys mapped from an URI space.
134            """
135            assert(default_key is None or isinstance(default_key, Key) or type(default_key == six.text_type))
136            self._uri_to_key = StringTrie()
137            if type(default_key) == six.text_type:
138                default_key = Key(originator_priv=default_key, responder_priv=default_key)
139            self._default_key = default_key
140
141        @public
142        def generate_key(self):
143            """
144            Generate a new private key and return a pair with the base64 encodings
145            of (priv_key, pub_key).
146            """
147            key = PrivateKey.generate()
148            priv_key = key.encode(encoder=Base64Encoder)
149            pub_key = key.public_key.encode(encoder=Base64Encoder)
150            return priv_key.decode('ascii'), pub_key.decode('ascii')
151
152        @public
153        def generate_key_hex(self):
154            """
155            Generate a new private key and return a pair with the hex encodings
156            of (priv_key, pub_key).
157            """
158            key = PrivateKey.generate()
159            priv_key = key.encode(encoder=HexEncoder)
160            pub_key = key.public_key.encode(encoder=HexEncoder)
161            return priv_key.decode('ascii'), pub_key.decode('ascii')
162
163        @public
164        def set_key(self, uri, key):
165            """
166            Add a key set for a given URI.
167            """
168            assert(type(uri) == six.text_type)
169            assert(key is None or isinstance(key, Key) or type(key) == six.text_type)
170            if type(key) == six.text_type:
171                key = Key(originator_priv=key, responder_priv=key)
172            if uri == u'':
173                self._default_key = key
174            else:
175                if key is None:
176                    if uri in self._uri_to_key:
177                        del self._uri_to_key[uri]
178                else:
179                    self._uri_to_key[uri] = key
180
181        @public
182        def rotate_key(self, uri):
183            assert(type(uri) == six.text_type)
184            if uri in self._uri_to_key:
185                self._uri_to_key[uri].rotate()
186            else:
187                self._uri_to_key[uri].rotate()
188
189        def _get_box(self, is_originating, uri, match_exact=False):
190            try:
191                if match_exact:
192                    key = self._uri_to_key[uri]
193                else:
194                    key = self._uri_to_key.longest_prefix_value(uri)
195            except KeyError:
196                if self._default_key:
197                    key = self._default_key
198                else:
199                    return None
200
201            if is_originating:
202                return key.originator_box
203            else:
204                return key.responder_box
205
206        @public
207        def encode(self, is_originating, uri, args=None, kwargs=None):
208            """
209            Encrypt the given WAMP URI, args and kwargs into an EncodedPayload instance, or None
210            if the URI should not be encrypted.
211            """
212            assert(type(is_originating) == bool)
213            assert(type(uri) == six.text_type)
214            assert(args is None or type(args) in (list, tuple))
215            assert(kwargs is None or type(kwargs) == dict)
216
217            box = self._get_box(is_originating, uri)
218
219            if not box:
220                # if we didn't find a crypto box, then return None, which
221                # signals that the payload travel unencrypted (normal)
222                return None
223
224            payload = {
225                u'uri': uri,
226                u'args': args,
227                u'kwargs': kwargs
228            }
229            nonce = random(Box.NONCE_SIZE)
230            payload_ser = _json_dumps(payload).encode('utf8')
231
232            payload_encr = box.encrypt(payload_ser, nonce, encoder=RawEncoder)
233
234            # above returns an instance of http://pynacl.readthedocs.io/en/latest/utils/#nacl.utils.EncryptedMessage
235            # which is a bytes _subclass_! hence we apply bytes() to get at the underlying plain
236            # bytes "scalar", which is the concatenation of `payload_encr.nonce + payload_encr.ciphertext`
237            payload_bytes = bytes(payload_encr)
238            payload_key = None
239
240            return EncodedPayload(payload_bytes, u'cryptobox', u'json', enc_key=payload_key)
241
242        @public
243        def decode(self, is_originating, uri, encoded_payload):
244            """
245            Decrypt the given WAMP URI and EncodedPayload into a tuple ``(uri, args, kwargs)``.
246            """
247            assert(type(uri) == six.text_type)
248            assert(isinstance(encoded_payload, EncodedPayload))
249            assert(encoded_payload.enc_algo == u'cryptobox')
250
251            box = self._get_box(is_originating, uri)
252
253            if not box:
254                raise Exception("received encrypted payload, but can't find key!")
255
256            payload_ser = box.decrypt(encoded_payload.payload, encoder=RawEncoder)
257
258            if encoded_payload.enc_serializer != u'json':
259                raise Exception("received encrypted payload, but don't know how to process serializer '{}'".format(encoded_payload.enc_serializer))
260
261            payload = _json_loads(payload_ser.decode('utf8'))
262
263            uri = payload.get(u'uri', None)
264            args = payload.get(u'args', None)
265            kwargs = payload.get(u'kwargs', None)
266
267            return uri, args, kwargs
268
269    # A WAMP-cryptobox keyring can work as a codec for
270    # payload transparency
271    IPayloadCodec.register(KeyRing)
272