Merge lp:~mpontillo/maas/beaconing-packet-format into lp:maas/trunk

Proposed by Mike Pontillo on 2017-06-13
Status: Merged
Approved by: Mike Pontillo on 2017-06-21
Approved revision: 6094
Merged at revision: 6099
Proposed branch: lp:~mpontillo/maas/beaconing-packet-format
Merge into: lp:maas/trunk
Diff against target: 673 lines (+345/-74)
4 files modified
src/provisioningserver/security.py (+31/-9)
src/provisioningserver/tests/test_security.py (+55/-44)
src/provisioningserver/utils/beaconing.py (+135/-10)
src/provisioningserver/utils/tests/test_beaconing.py (+124/-11)
To merge this branch: bzr merge lp:~mpontillo/maas/beaconing-packet-format
Reviewer Review Type Date Requested Status
Данило Шеган (community) 2017-06-13 Approve on 2017-06-21
Review via email: mp+325601@code.launchpad.net

Commit message

Add methods to encode and decode beacon packets.

Also, change the name of the Fernet methods to make it clear that the encryption is done using a pre-shared key (the MAAS shared secret).

Minor test suite refactoring for SharedSecretTestCase to make it more general.

To post a comment you must log in.
Данило Шеган (danilo) wrote :

I do have a few questions inline. Looks generally good, though.

review: Needs Information
Mike Pontillo (mpontillo) wrote :

Thanks for the review. Some replies below. You've given me some things to think about, so I'll make this WIP for now.

Данило Шеган (danilo) wrote :

Looks great, thanks for the changes!

A few more things that you probably forgot to complete with it being past 1am :-)

Not blocking since the gist of the stuff is there (basically, you are not making use of the new exception and raw=True parameter).

review: Approve
Mike Pontillo (mpontillo) wrote :

Thanks much for the review; some replies below.

6093. By Mike Pontillo on 2017-06-21

Fix lint.

6094. By Mike Pontillo on 2017-06-21

Fix docstring.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/provisioningserver/security.py'
2--- src/provisioningserver/security.py 2017-06-02 17:28:04 +0000
3+++ src/provisioningserver/security.py 2017-06-21 15:18:45 +0000
4@@ -9,7 +9,10 @@
5 "get_shared_secret_from_filesystem",
6 ]
7
8-from base64 import urlsafe_b64encode
9+from base64 import (
10+ urlsafe_b64decode,
11+ urlsafe_b64encode,
12+)
13 import binascii
14 from binascii import (
15 a2b_hex,
16@@ -41,6 +44,10 @@
17 )
18
19
20+class MissingSharedSecret(RuntimeError):
21+ """Raised when the MAAS shared secret is missing."""
22+
23+
24 def to_hex(b):
25 """Convert byte string to hex encoding."""
26 assert isinstance(b, bytes), "%r is not a byte string" % (b,)
27@@ -140,7 +147,8 @@
28 global _fernet_psk
29 if _fernet_psk is None:
30 secret = get_shared_secret_from_filesystem()
31- assert secret is not None, "MAAS shared secret not found."
32+ if secret is None:
33+ raise MissingSharedSecret("MAAS shared secret not found.")
34 # Keying material is required by PBKDF2 to be a byte string.
35 kdf = PBKDF2HMAC(
36 algorithm=hashes.SHA256(),
37@@ -171,7 +179,7 @@
38 return f
39
40
41-def fernet_encrypt(message):
42+def fernet_encrypt_psk(message, raw=False):
43 """Encrypts the specified message using the Fernet format.
44
45 Returns the encrypted token, as a byte string.
46@@ -183,29 +191,43 @@
47
48 :param message: The message to encrypt.
49 :type message: Must be of type 'bytes' or a UTF-8 'str'.
50+ :param raw: if True, returns the decoded base64 bytes representing the
51+ Fernet token. The bytes must be converted back to base64 to be
52+ decrypted. (Or the 'raw' argument on the corresponding
53+ fernet_decrypt_psk() function can be used.)
54 :return: the encryption token, as a base64-encoded byte string.
55 """
56- f = _get_fernet_context()
57+ fernet = _get_fernet_context()
58 if isinstance(message, str):
59 message = message.encode("utf-8")
60- return f.encrypt(message)
61-
62-
63-def fernet_decrypt(token, ttl=None):
64+ token = fernet.encrypt(message)
65+ if raw is True:
66+ token = urlsafe_b64decode(token)
67+ return token
68+
69+
70+def fernet_decrypt_psk(token, ttl=None, raw=False):
71 """Decrypts the specified Fernet token using the MAAS secret.
72
73 Returns the decrypted token as a byte string; the user is responsible for
74 converting it to the correct format or encoding.
75
76 :param message: The token to decrypt.
77- :type token: bytes
78+ :type token: Must be of type 'bytes', or an ASCII base64 string.
79 :param ttl: Optional amount of time (in seconds) allowed to have elapsed
80 before the message is rejected upon decryption. Note that the Fernet
81 library considers times up to 60 seconds into the future (beyond the
82 TTL) to be valid.
83+ :param raw: if True, treats the string as the decoded base64 bytes of a
84+ Fernet token, and attempts to encode them (as expected by the Fernet
85+ APIs) before decrypting.
86 :return: bytes
87 """
88+ if raw is True:
89+ token = urlsafe_b64encode(token)
90 f = _get_fernet_context()
91+ if isinstance(token, str):
92+ token = token.encode("ascii")
93 return f.decrypt(token, ttl=ttl)
94
95
96
97=== modified file 'src/provisioningserver/tests/test_security.py'
98--- src/provisioningserver/tests/test_security.py 2017-06-02 17:28:04 +0000
99+++ src/provisioningserver/tests/test_security.py 2017-06-21 15:18:45 +0000
100@@ -28,8 +28,9 @@
101 from provisioningserver import security
102 from provisioningserver.path import get_data_path
103 from provisioningserver.security import (
104- fernet_decrypt,
105- fernet_encrypt,
106+ fernet_decrypt_psk,
107+ fernet_encrypt_psk,
108+ MissingSharedSecret,
109 )
110 from provisioningserver.utils.fs import (
111 FileLock,
112@@ -37,7 +38,10 @@
113 write_text_file,
114 )
115 from testtools import ExpectedException
116-from testtools.matchers import Equals
117+from testtools.matchers import (
118+ Equals,
119+ IsInstance,
120+)
121
122
123 class SharedSecretTestCase(MAASTestCase):
124@@ -49,17 +53,24 @@
125 # so that tests cannot interfere with each other.
126 get_secret.return_value = get_data_path(
127 "var", "lib", "maas", "secret-%s" % factory.make_string(16))
128- secret_file = security.get_shared_secret_filesystem_path()
129 # Extremely unlikely, but just in case.
130- if os.path.isfile(secret_file):
131- os.remove(secret_file)
132+ self.delete_secret()
133+ self.addCleanup(
134+ setattr, security, "DEFAULT_ITERATION_COUNT",
135+ security.DEFAULT_ITERATION_COUNT)
136+ # The default high iteration count would make the tests very slow.
137+ security.DEFAULT_ITERATION_COUNT = 2
138 super().setUp()
139
140 def tearDown(self):
141+ self.delete_secret()
142+ super().tearDown()
143+
144+ def delete_secret(self):
145+ security._fernet_psk = None
146 secret_file = security.get_shared_secret_filesystem_path()
147 if os.path.isfile(secret_file):
148 os.remove(secret_file)
149- super().tearDown()
150
151 def write_secret(self):
152 secret = factory.make_bytes()
153@@ -121,6 +132,12 @@
154
155 class TestSetSharedSecretOnFilesystem(MAASTestCase):
156
157+ def test__default_iteration_count_is_reasonably_large(self):
158+ # Ensure that the iteration count is high by default. This is very
159+ # important so that the MAAS secret cannot be determined by
160+ # brute-force.
161+ self.assertThat(security.DEFAULT_ITERATION_COUNT, Equals(100000))
162+
163 def read_secret(self):
164 secret_path = security.get_shared_secret_filesystem_path()
165 secret_hex = read_text_file(secret_path)
166@@ -299,40 +316,23 @@
167
168 class TestFernetEncryption(SharedSecretTestCase):
169
170- def setUp(self):
171- security._fernet_psk = None
172- # Ensure that the iteration count is high by default. This is very
173- # important so that the MAAS secret cannot be determined by
174- # brute-force. As a side effect, this ensures our tearDown (which
175- # resets the iteration count to its default) works properly.
176- self.assertThat(security.DEFAULT_ITERATION_COUNT, Equals(100000))
177- self._previous_iteration_count = security.DEFAULT_ITERATION_COUNT
178- # The default high iteration count would make the tests very slow.
179- security.DEFAULT_ITERATION_COUNT = 2
180- super().setUp()
181-
182- def tearDown(self):
183- security._fernet_psk = None
184- security.DEFAULT_ITERATION_COUNT = self._previous_iteration_count
185- super().tearDown()
186-
187 def test__first_encrypt_caches_psk(self):
188 self.write_secret()
189 self.assertIsNone(security._fernet_psk)
190 testdata = factory.make_string()
191- fernet_encrypt(testdata)
192+ fernet_encrypt_psk(testdata)
193 self.assertIsNotNone(security._fernet_psk)
194
195 def test__derives_identical_key_on_decrypt(self):
196 self.write_secret()
197 self.assertIsNone(security._fernet_psk)
198 testdata = factory.make_bytes()
199- token = fernet_encrypt(testdata)
200+ token = fernet_encrypt_psk(testdata)
201 first_key = security._fernet_psk
202 # Make it seem like we're decrypting something without ever encrypting
203 # anything first.
204 security._fernet_psk = None
205- decrypted = fernet_decrypt(token)
206+ decrypted = fernet_decrypt_psk(token)
207 second_key = security._fernet_psk
208 self.assertEqual(first_key, second_key)
209 self.assertEqual(testdata, decrypted)
210@@ -340,29 +340,40 @@
211 def test__can_encrypt_and_decrypt_string(self):
212 self.write_secret()
213 testdata = factory.make_string()
214- token = fernet_encrypt(testdata)
215- decrypted = fernet_decrypt(token)
216- decrypted = decrypted.decode("utf-8")
217+ token = fernet_encrypt_psk(testdata)
218+ # Round-trip this to a string, since Fernet tokens are used inside
219+ # strings (such as JSON objects) typically.
220+ token = token.decode("ascii")
221+ decrypted = fernet_decrypt_psk(token)
222+ decrypted = decrypted.decode("ascii")
223+ self.assertThat(decrypted, Equals(testdata))
224+
225+ def test__can_encrypt_and_decrypt_with_raw_bytes(self):
226+ self.write_secret()
227+ testdata = factory.make_bytes()
228+ token = fernet_encrypt_psk(testdata, raw=True)
229+ self.assertThat(token, IsInstance(bytes))
230+ decrypted = fernet_decrypt_psk(token, raw=True)
231 self.assertThat(decrypted, Equals(testdata))
232
233 def test__can_encrypt_and_decrypt_bytes(self):
234 self.write_secret()
235 testdata = factory.make_bytes()
236- token = fernet_encrypt(testdata)
237- decrypted = fernet_decrypt(token)
238+ token = fernet_encrypt_psk(testdata)
239+ decrypted = fernet_decrypt_psk(token)
240 self.assertThat(decrypted, Equals(testdata))
241
242 def test__raises_when_no_secret_exists(self):
243 testdata = factory.make_bytes()
244- with ExpectedException(AssertionError):
245- fernet_encrypt(testdata)
246- with ExpectedException(AssertionError):
247- fernet_decrypt(b"")
248+ with ExpectedException(MissingSharedSecret):
249+ fernet_encrypt_psk(testdata)
250+ with ExpectedException(MissingSharedSecret):
251+ fernet_decrypt_psk(b"")
252
253 def test__assures_data_integrity(self):
254 self.write_secret()
255 testdata = factory.make_bytes(size=10)
256- token = fernet_encrypt(testdata)
257+ token = fernet_encrypt_psk(testdata)
258 bad_token = bytearray(token)
259 # Flip a bit in the token, so we can ensure it won't decrypt if it
260 # has been corrupted. Subtract 4 to avoid the end of the token; that
261@@ -374,30 +385,30 @@
262 test_description = ("token=%s; token[%d] ^= 0x%02x" % (
263 token.decode("utf-8"), byte_to_flip, bit_to_flip))
264 with ExpectedException(InvalidToken, msg=test_description):
265- fernet_decrypt(bad_token)
266+ fernet_decrypt_psk(bad_token)
267
268 def test__messages_from_up_to_a_minute_in_the_future_accepted(self):
269 self.write_secret()
270 testdata = factory.make_bytes()
271 now = time.time()
272 self.patch(time, "time").side_effect = [now + 60, now]
273- token = fernet_encrypt(testdata)
274- fernet_decrypt(token, ttl=1)
275+ token = fernet_encrypt_psk(testdata)
276+ fernet_decrypt_psk(token, ttl=1)
277
278 def test__messages_from_the_past_exceeding_ttl_rejected(self):
279 self.write_secret()
280 testdata = factory.make_bytes()
281 now = time.time()
282 self.patch(time, "time").side_effect = [now - 2, now]
283- token = fernet_encrypt(testdata)
284+ token = fernet_encrypt_psk(testdata)
285 with ExpectedException(InvalidToken):
286- fernet_decrypt(token, ttl=1)
287+ fernet_decrypt_psk(token, ttl=1)
288
289 def test__messages_from_future_exceeding_clock_skew_limit_rejected(self):
290 self.write_secret()
291 testdata = factory.make_bytes()
292 now = time.time()
293 self.patch(time, "time").side_effect = [now + 61, now]
294- token = fernet_encrypt(testdata)
295+ token = fernet_encrypt_psk(testdata)
296 with ExpectedException(InvalidToken):
297- fernet_decrypt(token, ttl=1)
298+ fernet_decrypt_psk(token, ttl=1)
299
300=== modified file 'src/provisioningserver/utils/beaconing.py'
301--- src/provisioningserver/utils/beaconing.py 2017-06-13 05:26:53 +0000
302+++ src/provisioningserver/utils/beaconing.py 2017-06-21 15:18:45 +0000
303@@ -5,18 +5,35 @@
304
305 __all__ = [
306 "BeaconingPacket",
307+ "BeaconPayload",
308+ "InvalidBeaconingPacket",
309+ "create_beacon_payload",
310+ "read_beacon_payload",
311 "add_arguments",
312 "run"
313 ]
314
315+from collections import namedtuple
316+from gzip import (
317+ compress,
318+ decompress,
319+)
320 import json
321 import os
322 import stat
323+import struct
324 import subprocess
325 import sys
326 from textwrap import dedent
327+import uuid
328
329-import bson
330+from bson import BSON
331+from bson.errors import BSONError
332+from cryptography.fernet import InvalidToken
333+from provisioningserver.security import (
334+ fernet_decrypt_psk,
335+ fernet_encrypt_psk,
336+)
337 from provisioningserver.utils import sudo
338 from provisioningserver.utils.network import format_eui
339 from provisioningserver.utils.pcap import (
340@@ -30,8 +47,119 @@
341 )
342
343
344+BEACON_PORT = 5240
345+
346+BEACON_TYPES = {
347+ "solicitation": 1,
348+ "advertisement": 2
349+}
350+
351+BEACON_TYPE_VALUES = {
352+ value: name for name, value in BEACON_TYPES.items()
353+}
354+
355+PROTOCOL_VERSION = 1
356+BEACON_HEADER_FORMAT_V1 = "!BBH"
357+BEACON_HEADER_LENGTH_V1 = 4
358+
359+
360+BeaconPayload = namedtuple('BeaconPayload', (
361+ 'bytes',
362+ 'version',
363+ 'type',
364+ 'payload',
365+))
366+
367+
368+def create_beacon_payload(beacon_type, payload=None, version=PROTOCOL_VERSION):
369+ """Creates a beacon payload of the specified type, with the given data.
370+
371+ :param beacon_type: The beacon packet type. Indicates the purpose of the
372+ beacon to the receiver.
373+ :param payload: Optional JSON-encodable dictionary. Will be converted to an
374+ inner encrypted payload and presented in the "data" field in the
375+ resulting dictionary.
376+ :param version: Optional protocol version to use (defaults to most recent).
377+ :return: BeaconPayload namedtuple representing the packet bytes, the outer
378+ payload, and the inner encrypted data (if any).
379+ """
380+ beacon_type_code = BEACON_TYPES[beacon_type]
381+ if payload is not None:
382+ payload = payload.copy()
383+ payload["uuid"] = str(uuid.uuid1())
384+ payload["type"] = beacon_type_code
385+ data_bytes = BSON.encode(payload)
386+ compressed_bytes = compress(data_bytes, compresslevel=9)
387+ payload_bytes = fernet_encrypt_psk(compressed_bytes, raw=True)
388+ else:
389+ payload_bytes = b''
390+ beacon_bytes = struct.pack(
391+ BEACON_HEADER_FORMAT_V1 + "%ds" % len(payload_bytes),
392+ version, beacon_type_code, len(payload_bytes), payload_bytes)
393+ return BeaconPayload(
394+ beacon_bytes, version, BEACON_TYPE_VALUES[beacon_type_code], payload)
395+
396+
397+def read_beacon_payload(beacon_bytes):
398+ """Returns a BeaconPayload namedtuple representing the given beacon bytes.
399+
400+ Decrypts the inner beacon data if necessary.
401+
402+ :param beacon_bytes: beacon payload (bytes).
403+ :return: dict
404+ """
405+ if len(beacon_bytes) < BEACON_HEADER_LENGTH_V1:
406+ raise InvalidBeaconingPacket(
407+ "Beaconing packet must be at least %d bytes." % (
408+ BEACON_HEADER_LENGTH_V1))
409+ header = beacon_bytes[:BEACON_HEADER_LENGTH_V1]
410+ version, beacon_type_code, expected_payload_length = struct.unpack(
411+ BEACON_HEADER_FORMAT_V1, header)
412+ actual_payload_length = len(beacon_bytes) - BEACON_HEADER_LENGTH_V1
413+ if len(beacon_bytes) - BEACON_HEADER_LENGTH_V1 < expected_payload_length:
414+ raise InvalidBeaconingPacket(
415+ "Invalid payload length: expected %d bytes, got %d bytes." % (
416+ expected_payload_length, actual_payload_length))
417+ payload_start = BEACON_HEADER_LENGTH_V1
418+ payload_end = BEACON_HEADER_LENGTH_V1 + expected_payload_length
419+ payload_bytes = beacon_bytes[payload_start:payload_end]
420+ payload = None
421+ if version == 1:
422+ if len(payload_bytes) == 0:
423+ # No encrypted inner payload; nothing to do.
424+ pass
425+ else:
426+ try:
427+ decrypted_data = fernet_decrypt_psk(
428+ payload_bytes, raw=True)
429+ except InvalidToken:
430+ raise InvalidBeaconingPacket(
431+ "Failed to decrypt inner payload: check MAAS secret key.")
432+ try:
433+ decompressed_data = decompress(decrypted_data)
434+ except OSError:
435+ raise InvalidBeaconingPacket(
436+ "Failed to decompress inner payload: %r" % decrypted_data)
437+ try:
438+ # Replace the data in the dictionary with its decrypted form.
439+ payload = BSON.decode(decompressed_data)
440+ except BSONError:
441+ raise InvalidBeaconingPacket(
442+ "Inner beacon payload is not BSON: %r" % decompressed_data)
443+ else:
444+ raise InvalidBeaconingPacket(
445+ "Unknown beacon version: %d" % version)
446+ beacon_type_code = payload["type"] if payload else beacon_type_code
447+ return BeaconPayload(
448+ beacon_bytes, version, BEACON_TYPE_VALUES[beacon_type_code], payload)
449+
450+
451 class InvalidBeaconingPacket(Exception):
452- """Raised internally when a beaconing packet is not valid."""
453+ """Raised when a beaconing packet is not valid."""
454+
455+ def __init__(self, invalid_reason):
456+ self.invalid_reason = invalid_reason
457+ super().__init__(invalid_reason)
458
459
460 class BeaconingPacket:
461@@ -57,12 +185,12 @@
462 :param out: An object with `write(str)` and `flush()` methods.
463 """
464 try:
465- payload = bson.decode_all(self.packet)
466+ payload = read_beacon_payload(self.packet)
467 self.valid = True
468 return payload
469- except bson.InvalidBSON:
470+ except InvalidBeaconingPacket as ibp:
471 self.valid = False
472- self.invalid_reason = "Packet payload is not BSON."
473+ self.invalid_reason = ibp.invalid_reason
474 return None
475
476
477@@ -90,6 +218,8 @@
478 "destination_mac": format_eui(packet.l2.dst_eui),
479 "source_ip": str(packet.l3.src_ip),
480 "destination_ip": str(packet.l3.dst_ip),
481+ "source_port": packet.l4.packet.src_port,
482+ "destination_port": packet.l4.packet.dst_port,
483 }
484 if packet.l2.vid is not None:
485 output_json["vid"] = packet.l2.vid
486@@ -99,11 +229,6 @@
487 out.write(json.dumps(output_json))
488 out.write('\n')
489 out.flush()
490- else:
491- err.write(
492- "Invalid beacon payload (not BSON): %r.\n" % (
493- beacon.packet))
494- err.flush()
495 except PacketProcessingError as e:
496 err.write(e.error)
497 err.write("\n")
498
499=== modified file 'src/provisioningserver/utils/tests/test_beaconing.py'
500--- src/provisioningserver/utils/tests/test_beaconing.py 2017-06-13 16:09:05 +0000
501+++ src/provisioningserver/utils/tests/test_beaconing.py 2017-06-21 15:18:45 +0000
502@@ -6,39 +6,152 @@
503 __all__ = []
504
505 from argparse import ArgumentParser
506+from gzip import compress
507 import io
508+import random
509+import struct
510 import subprocess
511 from tempfile import NamedTemporaryFile
512 from unittest.mock import Mock
513+from uuid import UUID
514
515-from bson import BSON
516 from maastesting.factory import factory
517 from maastesting.matchers import MockCalledOnceWith
518 from maastesting.testcase import MAASTestCase
519+from provisioningserver.security import (
520+ fernet_encrypt_psk,
521+ MissingSharedSecret,
522+)
523+from provisioningserver.tests.test_security import SharedSecretTestCase
524 from provisioningserver.utils import beaconing as beaconing_module
525 from provisioningserver.utils.beaconing import (
526 add_arguments,
527+ BEACON_HEADER_FORMAT_V1,
528+ BEACON_TYPES,
529 BeaconingPacket,
530+ create_beacon_payload,
531+ InvalidBeaconingPacket,
532+ read_beacon_payload,
533 run,
534 )
535 from provisioningserver.utils.script import ActionScriptError
536+from testtools.matchers import (
537+ Equals,
538+ Is,
539+ IsInstance,
540+)
541 from testtools.testcase import ExpectedException
542
543
544-def make_beaconing_packet(payload):
545- # Beaconing packets are BSON-encoded byte strings.
546- beaconing_packet = BSON.encode(payload)
547- return beaconing_packet
548+class TestCreateBeaconPayload(SharedSecretTestCase):
549+
550+ def test__requires_maas_shared_secret_for_inner_data_payload(self):
551+ with ExpectedException(
552+ MissingSharedSecret, ".*shared secret not found.*"):
553+ create_beacon_payload("solicitation", payload={})
554+
555+ def test__returns_beaconpayload_namedtuple(self):
556+ beacon = create_beacon_payload("solicitation")
557+ self.assertThat(beacon.bytes, IsInstance(bytes))
558+ self.assertThat(beacon.payload, Is(None))
559+ self.assertThat(beacon.type, Equals("solicitation"))
560+ self.assertThat(beacon.version, Equals(1))
561+
562+ def test__succeeds_when_shared_secret_present(self):
563+ self.write_secret()
564+ beacon = create_beacon_payload(
565+ "solicitation", payload={})
566+ self.assertThat(beacon.type, Equals("solicitation"))
567+ self.assertThat(
568+ beacon.payload['type'], Equals(BEACON_TYPES["solicitation"]))
569+
570+ def test__supplements_data_and_returns_complete_data(self):
571+ self.write_secret()
572+ random_type = random.choice(list(BEACON_TYPES.keys()))
573+ random_key = factory.make_string(prefix="_")
574+ random_value = factory.make_string()
575+ beacon = create_beacon_payload(
576+ random_type, payload={random_key: random_value})
577+ # Ensure a valid UUID was added.
578+ self.assertIsNotNone(UUID(beacon.payload['uuid']))
579+ self.assertThat(beacon.type, Equals(random_type))
580+ # The type is replicated here for authentication purposes.
581+ self.assertThat(
582+ beacon.payload['type'], Equals(BEACON_TYPES[random_type]))
583+ self.assertThat(beacon.payload[random_key], Equals(random_value))
584+
585+ def test__creates_packet_that_can_decode(self):
586+ self.write_secret()
587+ random_type = random.choice(list(BEACON_TYPES.keys()))
588+ random_key = factory.make_string(prefix="_")
589+ random_value = factory.make_string()
590+ packet_bytes, _, _, _ = create_beacon_payload(
591+ random_type, payload={random_key: random_value})
592+ decrypted = read_beacon_payload(packet_bytes)
593+ self.assertThat(decrypted.type, Equals(random_type))
594+ self.assertThat(decrypted.payload[random_key], Equals(random_value))
595+
596+
597+def _make_beacon_payload(version=1, type_code=1, length=None, payload=None):
598+ if payload is None:
599+ payload = b''
600+ if length is None:
601+ length = len(payload)
602+ packet = struct.pack(BEACON_HEADER_FORMAT_V1, version, type_code, length)
603+ return packet + payload
604+
605+
606+class TestReadBeaconPayload(SharedSecretTestCase):
607+
608+ def test__raises_if_packet_too_small(self):
609+ with ExpectedException(
610+ InvalidBeaconingPacket, ".*packet must be at least 4 bytes.*"):
611+ read_beacon_payload(b"")
612+
613+ def test__raises_if_payload_too_small(self):
614+ packet = _make_beacon_payload(payload=b'1234')[:6]
615+ with ExpectedException(
616+ InvalidBeaconingPacket, ".*expected 4 bytes, got 2 bytes.*"):
617+ read_beacon_payload(packet)
618+
619+ def test__raises_when_version_incorrect(self):
620+ packet = _make_beacon_payload(version=0xfe)
621+ with ExpectedException(
622+ InvalidBeaconingPacket, ".*Unknown beacon version.*"):
623+ read_beacon_payload(packet)
624+
625+ def test__raises_when_inner_payload_does_not_decrypt(self):
626+ self.write_secret()
627+ packet = _make_beacon_payload(payload=b'\xfe')
628+ with ExpectedException(
629+ InvalidBeaconingPacket, ".*Failed to decrypt.*"):
630+ read_beacon_payload(packet)
631+
632+ def test__raises_when_inner_encapsulation_does_not_decompress(self):
633+ self.write_secret()
634+ packet = _make_beacon_payload(
635+ payload=fernet_encrypt_psk('\n\n', raw=True))
636+ with ExpectedException(
637+ InvalidBeaconingPacket, ".*Failed to decompress.*"):
638+ read_beacon_payload(packet)
639+
640+ def test__raises_when_inner_encapsulation_is_not_bson(self):
641+ self.write_secret()
642+ payload = fernet_encrypt_psk(compress(b"\n\n"), raw=True)
643+ packet = _make_beacon_payload(payload=payload)
644+ with ExpectedException(
645+ InvalidBeaconingPacket, ".*beacon payload is not BSON.*"):
646+ read_beacon_payload(packet)
647
648
649 class TestBeaconingPacket(MAASTestCase):
650
651- def test__is_valid__succeeds_for_valid_bson(self):
652- packet = make_beaconing_packet({"testing": 123})
653- beacon = BeaconingPacket(packet)
654- self.assertTrue(beacon.valid)
655+ def test__is_valid__succeeds_for_valid_payload(self):
656+ beacon = create_beacon_payload("solicitation")
657+ beacon_packet = BeaconingPacket(beacon.bytes)
658+ self.assertTrue(beacon_packet.valid)
659
660- def test__is_valid__fails_for_invalid_bson(self):
661+ def test__is_valid__fails_for_invalid_payload(self):
662 beacon = BeaconingPacket(b"\n\n\n\n")
663 self.assertFalse(beacon.valid)
664
665@@ -48,7 +161,7 @@
666 b'\x00@\x00\x00\x01\x00\x00\x00v\xe19Y\xadF\x08\x00^\x00\x00\x00^\x00\x00'
667 b'\x00\x01\x00^\x00\x00v\x00\x16>\x91zz\x08\x00E\x00\x00P\xe2E@\x00\x01'
668 b'\x11\xe0\xce\xac\x10*\x02\xe0\x00\x00v\xda\xc2\x14x\x00<h(4\x00\x00\x00'
669- b'\x02uuid\x00%\x00\x00\x0078d1a4f0-4ca4-11e7-b2bb-00163e917a7a\x00\x00')
670+ b'\x02uuid\x00%\x00\x00\x0000000000-0000-0000-0000-000000000000\x00\x00')
671
672
673 class TestObserveBeaconsCommand(MAASTestCase):